From db3ada0a3908114cc38e5b81c5bc241311119820 Mon Sep 17 00:00:00 2001 From: LangLang03 Date: Thu, 1 Oct 2026 01:35:38 +0800 Subject: [PATCH] release: prepare v1.3.3 with workspace mounting and minimal mode --- platform/android/app/build.gradle | 4 +- .../android/app/src/main/AndroidManifest.xml | 9 +- ...LineCodeWorkspaceDirectoryShareModule.java | 57 +- .../platform/WorkspaceDirectoryProvider.java | 503 +++++++++++++----- .../platform/WorkspaceDocumentPaths.java | 88 +++ .../platform/WorkspaceDocumentPathsTest.java | 73 +++ resources/raw/tutorial_simple.md | 2 +- resources/strings/default.properties | 6 +- resources/strings/zh.properties | 6 +- src/application/auto_compaction_service.cpp | 18 +- src/application/auto_compaction_service.h | 6 +- .../behavior_settings_repository.cpp | 7 + .../behavior_settings_repository.h | 1 + src/application/context_compaction.cpp | 61 +-- src/application/context_compaction.h | 12 +- .../ports/workspace_directory_share.h | 5 +- src/application/prompt_request_composer.cpp | 42 +- src/application/prompt_request_composer.h | 4 + src/application/token_usage_tracker.h | 11 + src/application/tool_loop_compactor.cpp | 20 +- src/domain/behavior_settings.h | 1 + src/domain/context_usage.cpp | 13 +- src/domain/context_usage.h | 18 +- src/domain/memory.h | 10 +- .../android_workspace_directory_share.cpp | 4 +- src/infrastructure/openai_chat_codec.cpp | 14 +- src/infrastructure/sqlite_memory_store.cpp | 21 +- src/presentation/components/chat_composer.cpp | 5 +- src/presentation/components/chat_composer.h | 2 + .../components/chat_generation_runner.cpp | 23 +- src/presentation/components/chat_screen.cpp | 14 +- .../screens/extensions_screen.cpp | 8 +- .../screens/llm_settings_screen.cpp | 7 + tests/CMakeLists.txt | 29 + tests/completion_protocol_codec_tests.cpp | 52 ++ tests/context_compaction_tests.cpp | 23 +- tests/sqlite_memory_store_tests.cpp | 169 ++++++ update.md | 44 ++ 38 files changed, 1116 insertions(+), 276 deletions(-) create mode 100644 platform/android/app/src/main/java/cn/lineai/platform/WorkspaceDocumentPaths.java create mode 100644 platform/android/app/src/test/java/cn/lineai/platform/WorkspaceDocumentPathsTest.java create mode 100644 tests/sqlite_memory_store_tests.cpp diff --git a/platform/android/app/build.gradle b/platform/android/app/build.gradle index 45ce1f86..416e6e54 100644 --- a/platform/android/app/build.gradle +++ b/platform/android/app/build.gradle @@ -79,8 +79,8 @@ android { applicationId = "cn.lineai" minSdk = huxeruiMinSdk targetSdk = huxeruiTargetSdk - versionCode = 36 - versionName = "1.3.2" + versionCode = 37 + versionName = "1.3.3" externalNativeBuild { cmake { diff --git a/platform/android/app/src/main/AndroidManifest.xml b/platform/android/app/src/main/AndroidManifest.xml index 768d6b37..036a0e16 100644 --- a/platform/android/app/src/main/AndroidManifest.xml +++ b/platform/android/app/src/main/AndroidManifest.xml @@ -70,7 +70,12 @@ + android:exported="true" + android:grantUriPermissions="true" + android:permission="android.permission.MANAGE_DOCUMENTS"> + + + + diff --git a/platform/android/app/src/main/java/cn/lineai/platform/LineCodeWorkspaceDirectoryShareModule.java b/platform/android/app/src/main/java/cn/lineai/platform/LineCodeWorkspaceDirectoryShareModule.java index 168257e2..7688b2a8 100644 --- a/platform/android/app/src/main/java/cn/lineai/platform/LineCodeWorkspaceDirectoryShareModule.java +++ b/platform/android/app/src/main/java/cn/lineai/platform/LineCodeWorkspaceDirectoryShareModule.java @@ -1,9 +1,9 @@ package cn.lineai.platform; -import android.content.ClipData; import android.content.Context; import android.content.Intent; -import android.net.Uri; +import android.os.Build; +import android.provider.DocumentsContract; import android.widget.Toast; import cn.lineai.R; @@ -12,7 +12,7 @@ import org.huxerui.HuxerUIPlatformModule; import org.huxerui.PlatformPayload; -/** Android boundary for opening the private LineCode workspace directory. */ +/** Android boundary for opening the LineCode workspace in the system picker. */ public final class LineCodeWorkspaceDirectoryShareModule implements HuxerUIPlatformModule.Factory { @Override @@ -38,7 +38,7 @@ public HuxerUIPlatformChannel.Cancellation invoke( String method, PlatformPayload arguments, HuxerUIPlatformChannel.Result result) { - if (!"openHome".equals(method)) { + if (!"mountWorkspace".equals(method)) { result.fail( "linecode/workspace-directory-share/not-implemented", "Unknown workspace-directory-share method: " + method, @@ -47,7 +47,7 @@ public HuxerUIPlatformChannel.Cancellation invoke( } try { arguments.requireNull(); - openHome(context); + mountWorkspace(context); result.complete(PlatformPayload.nullValue()); } catch (RuntimeException error) { Toast.makeText( @@ -70,45 +70,20 @@ public void dispose() { } } - private static void openHome(Context context) { - File home = WorkspaceDirectoryProvider.homeDirectory(context); - if (!home.isDirectory() && !home.mkdirs()) { + private static void mountWorkspace(Context context) { + File workspace = WorkspaceDirectoryProvider.workspaceDirectory(context); + if (!workspace.isDirectory() && !workspace.mkdirs()) { throw new IllegalStateException( context.getString(R.string.workspace_provider_open_failed)); } - - Uri homeUri = WorkspaceDirectoryProvider.fileUri(context, "home"); - Intent view = new Intent(Intent.ACTION_VIEW); - view.setDataAndType(homeUri, "resource/folder"); - view.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION - | Intent.FLAG_GRANT_WRITE_URI_PERMISSION - | Intent.FLAG_ACTIVITY_NEW_TASK); - Intent chooser = Intent.createChooser( - view, context.getString(R.string.workspace_provider_open_title)); - chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - if (context.getPackageManager().queryIntentActivities(view, 0).isEmpty()) { - openSendFallback(context, home); - return; - } - try { - context.startActivity(chooser); - } catch (RuntimeException primary) { - openSendFallback(context, home); + Intent picker = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + picker.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + picker.putExtra(DocumentsContract.EXTRA_INITIAL_URI, + WorkspaceDirectoryProvider.rootDocumentUri(context)); } - } - - private static void openSendFallback(Context context, File home) { - Uri rootUri = WorkspaceDirectoryProvider.rootUri(context); - Intent send = new Intent(Intent.ACTION_SEND); - send.setType("text/plain"); - send.putExtra(Intent.EXTRA_TEXT, home.getAbsolutePath()); - send.putExtra(Intent.EXTRA_STREAM, rootUri); - send.setClipData(ClipData.newRawUri("LineCode workspace", rootUri)); - send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION - | Intent.FLAG_ACTIVITY_NEW_TASK); - Intent fallback = Intent.createChooser( - send, context.getString(R.string.workspace_provider_open_title)); - fallback.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - context.startActivity(fallback); + context.startActivity(picker); } } diff --git a/platform/android/app/src/main/java/cn/lineai/platform/WorkspaceDirectoryProvider.java b/platform/android/app/src/main/java/cn/lineai/platform/WorkspaceDirectoryProvider.java index f96d42e1..6372b95f 100644 --- a/platform/android/app/src/main/java/cn/lineai/platform/WorkspaceDirectoryProvider.java +++ b/platform/android/app/src/main/java/cn/lineai/platform/WorkspaceDirectoryProvider.java @@ -1,89 +1,357 @@ package cn.lineai.platform; -import android.content.ContentProvider; -import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.MatrixCursor; import android.net.Uri; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.Handler; +import android.os.Looper; import android.os.ParcelFileDescriptor; -import android.provider.OpenableColumns; +import android.provider.DocumentsContract; +import android.provider.DocumentsProvider; import android.webkit.MimeTypeMap; +import cn.lineai.R; import java.io.File; +import java.io.FileInputStream; import java.io.FileNotFoundException; +import java.io.FileOutputStream; import java.io.IOException; -import java.util.List; import java.util.Locale; -/** Exposes only files/.linecode through canonical, application-owned content URIs. */ -public final class WorkspaceDirectoryProvider extends ContentProvider { - private static final String[] DEFAULT_COLUMNS = new String[] { - OpenableColumns.DISPLAY_NAME, - OpenableColumns.SIZE, - "_id", - "_data" +/** Exposes the application-owned .linecode tree through the system document picker. */ +public final class WorkspaceDirectoryProvider extends DocumentsProvider { + private static final String ROOT_ID = WorkspaceDocumentPaths.ROOT_ID; + private static final String[] ROOT_COLUMNS = { + DocumentsContract.Root.COLUMN_ROOT_ID, + DocumentsContract.Root.COLUMN_DOCUMENT_ID, + DocumentsContract.Root.COLUMN_TITLE, + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.COLUMN_ICON, + DocumentsContract.Root.COLUMN_AVAILABLE_BYTES + }; + private static final String[] DOCUMENT_COLUMNS = { + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_FLAGS, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED }; - private File linecodeRoot; + private WorkspaceDocumentPaths paths; static String authority(Context context) { return context.getPackageName() + ".workspace"; } - static Uri rootUri(Context context) { - return new Uri.Builder() - .scheme("content") - .authority(authority(context)) - .appendPath("root") - .build(); + static Uri rootDocumentUri(Context context) { + return DocumentsContract.buildDocumentUri(authority(context), ROOT_ID); } - static Uri fileUri(Context context, String relativePath) { - Uri.Builder builder = new Uri.Builder() - .scheme("content") - .authority(authority(context)) - .appendPath("file"); - if (relativePath != null && !relativePath.isEmpty()) { - for (String segment : relativePath.split("/")) { - if (!segment.isEmpty()) { - builder.appendPath(segment); + static File workspaceDirectory(Context context) { + return new File(context.getFilesDir(), ".linecode"); + } + + @Override + public boolean onCreate() { + Context context = getContext(); + if (context == null) { + return false; + } + File root = workspaceDirectory(context); + if (!root.isDirectory() && !root.mkdirs()) { + return false; + } + File home = new File(root, "home"); + if (!home.isDirectory() && !home.mkdirs()) { + return false; + } + try { + paths = new WorkspaceDocumentPaths(root); + return true; + } catch (IOException error) { + return false; + } + } + + @Override + public Cursor queryRoots(String[] projection) { + String[] columns = projection == null ? ROOT_COLUMNS : projection; + MatrixCursor cursor = new MatrixCursor(columns, 1); + MatrixCursor.RowBuilder row = cursor.newRow(); + for (String column : columns) { + if (DocumentsContract.Root.COLUMN_ROOT_ID.equals(column) + || DocumentsContract.Root.COLUMN_DOCUMENT_ID.equals(column)) { + row.add(ROOT_ID); + } else if (DocumentsContract.Root.COLUMN_TITLE.equals(column)) { + row.add(getContext().getString(R.string.workspace_provider_open_title)); + } else if (DocumentsContract.Root.COLUMN_FLAGS.equals(column)) { + row.add(DocumentsContract.Root.FLAG_LOCAL_ONLY + | DocumentsContract.Root.FLAG_SUPPORTS_CREATE + | DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD); + } else if (DocumentsContract.Root.COLUMN_ICON.equals(column)) { + row.add(R.mipmap.ic_launcher); + } else if (DocumentsContract.Root.COLUMN_AVAILABLE_BYTES.equals(column)) { + row.add(paths.root().getUsableSpace()); + } else { + row.add(null); + } + } + return cursor; + } + + @Override + public Cursor queryDocument(String documentId, String[] projection) + throws FileNotFoundException { + File file = existingFile(documentId); + MatrixCursor cursor = new MatrixCursor( + projection == null ? DOCUMENT_COLUMNS : projection, 1); + addDocument(cursor, documentId, file); + cursor.setNotificationUri(getContext().getContentResolver(), documentUri(documentId)); + return cursor; + } + + @Override + public Cursor queryChildDocuments(String parentDocumentId, String[] projection, + String sortOrder) throws FileNotFoundException { + File parent = existingDirectory(parentDocumentId); + File[] children = parent.listFiles(); + if (children == null) { + throw new FileNotFoundException("Unable to list workspace directory"); + } + MatrixCursor cursor = new MatrixCursor( + projection == null ? DOCUMENT_COLUMNS : projection, children.length); + for (File child : children) { + try { + addDocument(cursor, paths.documentIdFor(child), child); + } catch (FileNotFoundException ignored) { + // Symbolic links are not part of the exposed document tree. + } + } + cursor.setNotificationUri(getContext().getContentResolver(), + childDocumentsUri(parentDocumentId)); + return cursor; + } + + @Override + public Cursor queryChildDocuments(String parentDocumentId, String[] projection, + Bundle queryArgs) throws FileNotFoundException { + return queryChildDocuments(parentDocumentId, projection, (String) null); + } + + @Override + public ParcelFileDescriptor openDocument(String documentId, String mode, + CancellationSignal signal) throws FileNotFoundException { + File file = existingFile(documentId); + if (!file.isFile()) { + throw new FileNotFoundException("Workspace document is not a file"); + } + int access = ParcelFileDescriptor.parseMode(mode); + if (mode.contains("w")) { + try { + return ParcelFileDescriptor.open(file, access, + new Handler(Looper.getMainLooper()), error -> { + getContext().getContentResolver().notifyChange( + documentUri(documentId), null); + notifyChildren(parentId(documentId)); + }); + } catch (IOException error) { + throw fileError("Unable to open workspace document", error); + } + } + return ParcelFileDescriptor.open(file, access); + } + + @Override + public String createDocument(String parentDocumentId, String mimeType, + String displayName) throws FileNotFoundException { + existingDirectory(parentDocumentId); + WorkspaceDocumentPaths.validateName(displayName); + File file = paths.resolve(parentDocumentId + "/" + displayName); + if (file.exists()) { + throw new FileNotFoundException("Workspace document already exists"); + } + try { + if (DocumentsContract.Document.MIME_TYPE_DIR.equals(mimeType)) { + if (!file.mkdir()) { + throw new IOException("Unable to create workspace directory"); + } + } else if (mimeType != null && !mimeType.isEmpty()) { + if (!file.createNewFile()) { + throw new IOException("Unable to create workspace file"); } + } else { + throw new FileNotFoundException("Document MIME type is required"); } + } catch (IOException error) { + throw fileError("Unable to create workspace document", error); } - return builder.build(); + notifyChildren(parentDocumentId); + return paths.documentIdFor(file); } - static File homeDirectory(Context context) { - return new File(linecodeDirectory(context), "home"); + @Override + public void deleteDocument(String documentId) throws FileNotFoundException { + if (ROOT_ID.equals(documentId)) { + throw new FileNotFoundException("Workspace root cannot be deleted"); + } + File file = existingFile(documentId); + validateTree(file); + deleteTree(file); + notifyChildren(parentId(documentId)); + getContext().getContentResolver().notifyChange(documentUri(documentId), null); + revokeDocumentPermission(documentId); } - private static File linecodeDirectory(Context context) { - return new File(context.getFilesDir(), ".linecode"); + @Override + public String renameDocument(String documentId, String displayName) + throws FileNotFoundException { + if (ROOT_ID.equals(documentId)) { + throw new FileNotFoundException("Workspace root cannot be renamed"); + } + WorkspaceDocumentPaths.validateName(displayName); + File source = existingFile(documentId); + String parentId = parentId(documentId); + File target = paths.resolve(parentId + "/" + displayName); + if (source.equals(target)) { + return documentId; + } + if (target.exists() || !source.renameTo(target)) { + throw new FileNotFoundException("Unable to rename workspace document"); + } + String renamedId = paths.documentIdFor(target); + notifyChildren(parentId); + getContext().getContentResolver().notifyChange(documentUri(documentId), null); + revokeDocumentPermission(documentId); + return renamedId; } @Override - public boolean onCreate() { - Context context = getContext(); - if (context == null) { - return false; + public String copyDocument(String sourceDocumentId, String targetParentDocumentId) + throws FileNotFoundException { + if (ROOT_ID.equals(sourceDocumentId)) { + throw new FileNotFoundException("Workspace root cannot be copied"); } - linecodeRoot = linecodeDirectory(context); - if (!linecodeRoot.isDirectory() && !linecodeRoot.mkdirs()) { - return false; + File source = existingFile(sourceDocumentId); + existingDirectory(targetParentDocumentId); + if (paths.isChild(sourceDocumentId, targetParentDocumentId)) { + throw new FileNotFoundException("Cannot copy a directory into itself"); + } + File target = paths.resolve(targetParentDocumentId + "/" + source.getName()); + if (target.exists()) { + throw new FileNotFoundException("Workspace document already exists"); + } + validateTree(source); + try { + copyTree(source, target); + } catch (IOException error) { + if (target.exists()) { + try { + deleteTree(target); + } catch (FileNotFoundException cleanupError) { + error.addSuppressed(cleanupError); + } + } + throw fileError("Unable to copy workspace document", error); + } + notifyChildren(targetParentDocumentId); + return paths.documentIdFor(target); + } + + @Override + public String moveDocument(String sourceDocumentId, String sourceParentDocumentId, + String targetParentDocumentId) throws FileNotFoundException { + if (ROOT_ID.equals(sourceDocumentId) + || !parentId(sourceDocumentId).equals(sourceParentDocumentId)) { + throw new FileNotFoundException("Invalid workspace move source"); + } + File source = existingFile(sourceDocumentId); + existingDirectory(targetParentDocumentId); + if (paths.isChild(sourceDocumentId, targetParentDocumentId)) { + throw new FileNotFoundException("Cannot move a directory into itself"); + } + File target = paths.resolve(targetParentDocumentId + "/" + source.getName()); + if (source.equals(target)) { + return sourceDocumentId; + } + if (target.exists() || !source.renameTo(target)) { + throw new FileNotFoundException("Unable to move workspace document"); } - File home = homeDirectory(context); - return home.isDirectory() || home.mkdirs(); + String movedId = paths.documentIdFor(target); + notifyChildren(sourceParentDocumentId); + notifyChildren(targetParentDocumentId); + revokeDocumentPermission(sourceDocumentId); + return movedId; } @Override - public String getType(Uri uri) { - File file = resolveFile(uri); - if (file == null) { - return null; + public boolean isChildDocument(String parentDocumentId, String documentId) { + try { + return existingFile(parentDocumentId).isDirectory() + && existingFile(documentId).exists() + && paths.isChild(parentDocumentId, documentId); + } catch (FileNotFoundException error) { + return false; + } + } + + private File existingFile(String documentId) throws FileNotFoundException { + File file = paths.resolve(documentId); + if (!file.exists()) { + throw new FileNotFoundException("Workspace document not found"); + } + return file; + } + + private File existingDirectory(String documentId) throws FileNotFoundException { + File file = existingFile(documentId); + if (!file.isDirectory()) { + throw new FileNotFoundException("Workspace document is not a directory"); } + return file; + } + + private void addDocument(MatrixCursor cursor, String documentId, File file) { + MatrixCursor.RowBuilder row = cursor.newRow(); + int flags = 0; + if (file.isDirectory() && file.canWrite()) { + flags |= DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE; + } else if (file.isFile() && file.canWrite()) { + flags |= DocumentsContract.Document.FLAG_SUPPORTS_WRITE; + } + if (!ROOT_ID.equals(documentId) && file.getParentFile().canWrite()) { + flags |= DocumentsContract.Document.FLAG_SUPPORTS_DELETE + | DocumentsContract.Document.FLAG_SUPPORTS_RENAME + | DocumentsContract.Document.FLAG_SUPPORTS_COPY + | DocumentsContract.Document.FLAG_SUPPORTS_MOVE; + } + for (String column : cursor.getColumnNames()) { + if (DocumentsContract.Document.COLUMN_DOCUMENT_ID.equals(column)) { + row.add(documentId); + } else if (DocumentsContract.Document.COLUMN_DISPLAY_NAME.equals(column)) { + row.add(ROOT_ID.equals(documentId) + ? getContext().getString(R.string.workspace_provider_open_title) + : file.getName()); + } else if (DocumentsContract.Document.COLUMN_MIME_TYPE.equals(column)) { + row.add(mimeType(file)); + } else if (DocumentsContract.Document.COLUMN_FLAGS.equals(column)) { + row.add(flags); + } else if (DocumentsContract.Document.COLUMN_SIZE.equals(column)) { + row.add(file.isFile() ? file.length() : 0L); + } else if (DocumentsContract.Document.COLUMN_LAST_MODIFIED.equals(column)) { + row.add(file.lastModified()); + } else { + row.add(null); + } + } + } + + private static String mimeType(File file) { if (file.isDirectory()) { - return "vnd.android.document/directory"; + return DocumentsContract.Document.MIME_TYPE_DIR; } String name = file.getName(); int dot = name.lastIndexOf('.'); @@ -97,103 +365,80 @@ public String getType(Uri uri) { return "application/octet-stream"; } - @Override - public Cursor query( - Uri uri, - String[] projection, - String selection, - String[] selectionArgs, - String sortOrder) { - File file = resolveFile(uri); - if (file == null || !file.exists()) { - return null; - } - String[] columns = projection == null ? DEFAULT_COLUMNS : projection; - MatrixCursor cursor = new MatrixCursor(columns, 1); - Object[] row = new Object[columns.length]; - for (int index = 0; index < columns.length; index++) { - String column = columns[index]; - if (OpenableColumns.DISPLAY_NAME.equals(column)) { - row[index] = file.getName(); - } else if (OpenableColumns.SIZE.equals(column)) { - row[index] = file.isFile() ? file.length() : 0L; - } else if ("_id".equals(column)) { - row[index] = 1; - } else if ("_data".equals(column)) { - row[index] = file.getAbsolutePath(); - } - } - cursor.addRow(row); - return cursor; + private static String parentId(String documentId) { + return documentId.substring(0, documentId.lastIndexOf('/')); } - @Override - public ParcelFileDescriptor openFile(Uri uri, String mode) - throws FileNotFoundException { - File file = resolveFile(uri); - if (file == null || !file.isFile()) { - throw new FileNotFoundException("Workspace file not found"); - } - int access = mode != null && mode.contains("w") - ? ParcelFileDescriptor.MODE_READ_WRITE - : ParcelFileDescriptor.MODE_READ_ONLY; - return ParcelFileDescriptor.open(file, access); + private Uri documentUri(String documentId) { + return DocumentsContract.buildDocumentUri(authority(getContext()), documentId); } - @Override - public Uri insert(Uri uri, ContentValues values) { - return null; + private Uri childDocumentsUri(String documentId) { + return DocumentsContract.buildChildDocumentsUri(authority(getContext()), documentId); } - @Override - public int delete(Uri uri, String selection, String[] selectionArgs) { - return 0; + private void notifyChildren(String documentId) { + getContext().getContentResolver().notifyChange(childDocumentsUri(documentId), null); } - @Override - public int update( - Uri uri, ContentValues values, String selection, String[] selectionArgs) { - return 0; - } - - private File resolveFile(Uri uri) { - if (uri == null || linecodeRoot == null - || !authority(contextOrThrow()).equals(uri.getAuthority())) { - return null; - } - List segments = uri.getPathSegments(); - if (segments == null || segments.isEmpty()) { - return null; - } - File requested; - if (segments.size() == 1 && "root".equals(segments.get(0))) { - requested = linecodeRoot; - } else if ("file".equals(segments.get(0))) { - requested = linecodeRoot; - for (int index = 1; index < segments.size(); index++) { - requested = new File(requested, segments.get(index)); + private void validateTree(File file) throws FileNotFoundException { + paths.documentIdFor(file); + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children == null) { + throw new FileNotFoundException("Unable to list workspace directory"); + } + for (File child : children) { + validateTree(child); } - } else { - return null; } - try { - String rootPath = linecodeRoot.getCanonicalPath(); - String requestedPath = requested.getCanonicalPath(); - if (!requestedPath.equals(rootPath) - && !requestedPath.startsWith(rootPath + File.separator)) { - return null; + } + + private void deleteTree(File file) throws FileNotFoundException { + paths.documentIdFor(file); + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children == null) { + throw new FileNotFoundException("Unable to list workspace directory"); } - return requested; - } catch (IOException error) { - return null; + for (File child : children) { + deleteTree(child); + } + } + if (!file.delete()) { + throw new FileNotFoundException("Unable to delete workspace document"); } } - private Context contextOrThrow() { - Context context = getContext(); - if (context == null) { - throw new IllegalStateException("Workspace provider has no Context"); + private void copyTree(File source, File target) throws IOException { + paths.documentIdFor(source); + paths.documentIdFor(target); + if (source.isDirectory()) { + if (!target.mkdir()) { + throw new IOException("Unable to create copied workspace directory"); + } + File[] children = source.listFiles(); + if (children == null) { + throw new IOException("Unable to list workspace directory"); + } + for (File child : children) { + copyTree(child, new File(target, child.getName())); + } + } else { + try (FileInputStream input = new FileInputStream(source); + FileOutputStream output = new FileOutputStream(target)) { + byte[] buffer = new byte[8192]; + int count; + while ((count = input.read(buffer)) != -1) { + output.write(buffer, 0, count); + } + } } - return context; + } + + private static FileNotFoundException fileError(String message, IOException cause) { + FileNotFoundException error = new FileNotFoundException(message); + error.initCause(cause); + return error; } } diff --git a/platform/android/app/src/main/java/cn/lineai/platform/WorkspaceDocumentPaths.java b/platform/android/app/src/main/java/cn/lineai/platform/WorkspaceDocumentPaths.java new file mode 100644 index 00000000..0c7568aa --- /dev/null +++ b/platform/android/app/src/main/java/cn/lineai/platform/WorkspaceDocumentPaths.java @@ -0,0 +1,88 @@ +package cn.lineai.platform; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; + +/** Maps document IDs to files without following links outside the workspace. */ +final class WorkspaceDocumentPaths { + static final String ROOT_ID = "workspace"; + + private final File root; + private final String rootPath; + + WorkspaceDocumentPaths(File root) throws IOException { + this.root = root.getCanonicalFile(); + this.rootPath = this.root.getPath(); + } + + File root() { + return root; + } + + File resolve(String documentId) throws FileNotFoundException { + if (ROOT_ID.equals(documentId)) { + return root; + } + if (documentId == null || !documentId.startsWith(ROOT_ID + "/")) { + throw new FileNotFoundException("Invalid workspace document ID"); + } + String relative = documentId.substring(ROOT_ID.length() + 1); + for (String segment : relative.split("/", -1)) { + validateName(segment); + } + File candidate = new File(root, relative); + try { + File canonical = candidate.getCanonicalFile(); + if (!canonical.equals(candidate.getAbsoluteFile()) + || !canonical.getPath().startsWith(rootPath + File.separator)) { + throw new FileNotFoundException("Workspace document leaves the root"); + } + return canonical; + } catch (IOException error) { + throw fileError(error); + } + } + + String documentIdFor(File file) throws FileNotFoundException { + try { + File canonical = file.getCanonicalFile(); + if (!canonical.equals(file.getAbsoluteFile())) { + throw new FileNotFoundException("Symbolic links are not workspace documents"); + } + String path = canonical.getPath(); + if (path.equals(rootPath)) { + return ROOT_ID; + } + if (!path.startsWith(rootPath + File.separator)) { + throw new FileNotFoundException("Workspace document leaves the root"); + } + String relative = path.substring(rootPath.length() + 1) + .replace(File.separatorChar, '/'); + for (String segment : relative.split("/", -1)) { + validateName(segment); + } + return ROOT_ID + "/" + relative; + } catch (IOException error) { + throw fileError(error); + } + } + + boolean isChild(String parentId, String childId) { + return childId.startsWith(parentId + "/"); + } + + static void validateName(String name) throws FileNotFoundException { + if (name == null || name.isEmpty() || ".".equals(name) || "..".equals(name) + || name.indexOf('/') >= 0 || name.indexOf('\\') >= 0 + || name.indexOf('\0') >= 0) { + throw new FileNotFoundException("Invalid workspace document name"); + } + } + + private static FileNotFoundException fileError(IOException cause) { + FileNotFoundException error = new FileNotFoundException("Invalid workspace path"); + error.initCause(cause); + return error; + } +} diff --git a/platform/android/app/src/test/java/cn/lineai/platform/WorkspaceDocumentPathsTest.java b/platform/android/app/src/test/java/cn/lineai/platform/WorkspaceDocumentPathsTest.java new file mode 100644 index 00000000..0b90c0ef --- /dev/null +++ b/platform/android/app/src/test/java/cn/lineai/platform/WorkspaceDocumentPathsTest.java @@ -0,0 +1,73 @@ +package cn.lineai.platform; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.FileNotFoundException; +import java.nio.file.Files; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public final class WorkspaceDocumentPathsTest { + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void mapsNestedDocumentsAndChecksDescendants() throws Exception { + File root = temporaryFolder.newFolder("workspace"); + File skills = new File(root, "skills/nested"); + assertTrue(skills.mkdirs()); + File file = new File(skills, "SKILL.md"); + assertTrue(file.createNewFile()); + + WorkspaceDocumentPaths paths = new WorkspaceDocumentPaths(root); + assertEquals(root.getCanonicalFile(), paths.resolve("workspace")); + assertEquals(file.getCanonicalFile(), + paths.resolve("workspace/skills/nested/SKILL.md")); + assertEquals("workspace/skills/nested/SKILL.md", paths.documentIdFor(file)); + assertTrue(paths.isChild("workspace/skills", "workspace/skills/nested/SKILL.md")); + assertFalse(paths.isChild("workspace/skills", "workspace/skills-other/file")); + } + + @Test + public void rejectsTraversalMalformedNamesAndSiblingPaths() throws Exception { + File root = temporaryFolder.newFolder("workspace"); + File sibling = temporaryFolder.newFolder("workspace-other"); + WorkspaceDocumentPaths paths = new WorkspaceDocumentPaths(root); + + assertThrows(FileNotFoundException.class, + () -> paths.resolve("workspace/../workspace-other")); + assertThrows(FileNotFoundException.class, + () -> paths.resolve("workspace/a//b")); + assertThrows(FileNotFoundException.class, + () -> paths.resolve("workspace/a\\b")); + assertThrows(FileNotFoundException.class, + () -> paths.resolve("other/file")); + assertThrows(FileNotFoundException.class, + () -> paths.documentIdFor(sibling)); + assertThrows(FileNotFoundException.class, + () -> WorkspaceDocumentPaths.validateName("../escape")); + } + + @Test + public void rejectsSymlinksInsideAndOutsideWorkspace() throws Exception { + File root = temporaryFolder.newFolder("workspace"); + File inside = temporaryFolder.newFolder(root.getName(), "inside"); + File outside = temporaryFolder.newFolder("outside"); + File outsideLink = new File(root, "outside-link"); + File insideLink = new File(root, "inside-link"); + Files.createSymbolicLink(outsideLink.toPath(), outside.toPath()); + Files.createSymbolicLink(insideLink.toPath(), inside.toPath()); + + WorkspaceDocumentPaths paths = new WorkspaceDocumentPaths(root); + assertThrows(FileNotFoundException.class, + () -> paths.resolve("workspace/outside-link/secret.txt")); + assertThrows(FileNotFoundException.class, + () -> paths.resolve("workspace/inside-link/file.txt")); + assertThrows(FileNotFoundException.class, + () -> paths.documentIdFor(outsideLink)); + } +} diff --git a/resources/raw/tutorial_simple.md b/resources/raw/tutorial_simple.md index 37adfa19..59625052 100644 --- a/resources/raw/tutorial_simple.md +++ b/resources/raw/tutorial_simple.md @@ -651,7 +651,7 @@ LineCode 通过 **HTTP(S)** 对接远程 MCP 服务(不支持本地启动进 已安装列表:行描述"位置 · SKILL.md 路径"(项目 / 全局 / SSH),Switch 启停;长按进入多选。 -另有 **分享工作区目录**:通过系统文件选择器打开内部 `.linecode` 目录。 +另有 **挂载工作区到文件系统**:在系统文件选择器中打开内部 `.linecode` 目录,可通过支持 SAF 的文件管理器浏览和管理其中的 `home/`、`project/`、`skills/` 文件。 --- diff --git a/resources/strings/default.properties b/resources/strings/default.properties index b0be66d7..99d03417 100644 --- a/resources/strings/default.properties +++ b/resources/strings/default.properties @@ -874,6 +874,8 @@ screen_llm_keep_reasoning_desc = "Send historical reasoning back to compatible m screen_llm_keep_reasoning_label = "Keep full reasoning" screen_llm_learning_desc = "Enable auto skills, long-term memory, project memory, short-term memory and chat indexing" screen_llm_learning_label = "Learning mode" +screen_llm_minimal_desc = "Use a compact role and tool prompt; skip custom prompts, memory and Skills" +screen_llm_minimal_label = "Minimal mode" screen_llm_prompts_desc = "Edit system, tone, session, learning memory and context compaction templates" screen_llm_prompts_label = "Custom prompts" screen_llm_scroll_desc = "When off, fully expands directly" @@ -1058,8 +1060,8 @@ screen_terminal_provider_delete_confirm = "Delete \"{0}\"?" screen_terminal_provider_loading = "Loading terminal providers…" screen_terminal_provider_scan_failed = "Could not scan terminal providers" screen_terminal_provider_store_failed = "Could not update terminal provider" -screen_extension_detail_workspace_share = "Share workspace folder" -screen_extension_detail_workspace_share_desc = "Open internal .linecode home via system file picker / FileProvider" +screen_extension_detail_workspace_share = "Mount workspace in Files" +screen_extension_detail_workspace_share_desc = "Browse and manage the internal .linecode folder in a system file manager" screen_extension_loading = "Loading..." screen_extension_modify = "Modify" screen_extension_modify_desc = "Edit config, prompt, headers or tool list" diff --git a/resources/strings/zh.properties b/resources/strings/zh.properties index 19b5edb2..d0b51c8b 100644 --- a/resources/strings/zh.properties +++ b/resources/strings/zh.properties @@ -874,6 +874,8 @@ screen_llm_keep_reasoning_desc = "将历史思考发回兼容模型,适合多 screen_llm_keep_reasoning_label = "保留完整 reasoning" screen_llm_learning_desc = "启用自动 Skills、长期记忆、项目记忆、短期记忆和聊天记录检索" screen_llm_learning_label = "学习模式" +screen_llm_minimal_desc = "仅使用精简角色和工具提示词,不注入自定义提示词、记忆或 Skills" +screen_llm_minimal_label = "极简模式" screen_llm_prompts_desc = "编辑 system、语气模式、会话模式、学习记忆和上下文压缩模板" screen_llm_prompts_label = "自定义提示词" screen_llm_scroll_desc = "关闭后直接完全展开显示" @@ -1058,8 +1060,8 @@ screen_terminal_provider_delete_confirm = "删除「{0}」?" screen_terminal_provider_loading = "正在加载终端提供者…" screen_terminal_provider_scan_failed = "无法扫描终端提供者" screen_terminal_provider_store_failed = "无法更新终端提供者" -screen_extension_detail_workspace_share = "分享工作区目录" -screen_extension_detail_workspace_share_desc = "通过系统文件选择器 / FileProvider 打开内部 .linecode 目录" +screen_extension_detail_workspace_share = "挂载工作区到文件系统" +screen_extension_detail_workspace_share_desc = "在系统文件管理器中浏览和管理内部 .linecode 目录" screen_extension_loading = "正在加载…" screen_extension_modify = "修改" screen_extension_modify_desc = "编辑配置、提示词、请求头或 tools 列表" diff --git a/src/application/auto_compaction_service.cpp b/src/application/auto_compaction_service.cpp index 27f3ef3a..7287c28d 100644 --- a/src/application/auto_compaction_service.cpp +++ b/src/application/auto_compaction_service.cpp @@ -143,12 +143,13 @@ bool ShouldAutoCompactBeforeRequest( const std::vector &messages, const int observed_input_tokens, const std::span preserved_tail_ids, - const bool include_reasoning) { + const bool include_reasoning, const int context_overhead_tokens) { if (!model.has_value()) return false; if (!ContextCompactionService::ShouldCompact(*model, messages, include_reasoning, - observed_input_tokens)) { + observed_input_tokens, + context_overhead_tokens)) { return false; } for (const auto &message : messages) { @@ -171,14 +172,15 @@ bool ShouldAutoSoftCompactBeforeRequest( const std::vector &messages, const int observed_input_tokens, const bool soft_compaction_enabled, const std::span preserved_tail_ids, - const bool include_reasoning) { + const bool include_reasoning, const int context_overhead_tokens) { if (!model.has_value()) return false; if (!soft_compaction_enabled) return false; if (!ContextCompactionService::ShouldSoftCompact(*model, messages, include_reasoning, - observed_input_tokens)) { + observed_input_tokens, + context_overhead_tokens)) { return false; } // Legacy lines 154-163: the base is the conversation without the preserved @@ -197,12 +199,14 @@ bool ShouldAutoSoftCompactBeforeRequest( bool ShouldAutoCompactMidLoop( const std::optional &model, const std::vector &messages, - const int observed_input_tokens, const bool include_reasoning) { + const int observed_input_tokens, const bool include_reasoning, + const int context_overhead_tokens) { if (!model.has_value()) return false; if (!ContextCompactionService::ShouldCompact(*model, messages, include_reasoning, - observed_input_tokens)) { + observed_input_tokens, + context_overhead_tokens)) { return false; } // Legacy line 244: the mid-loop trigger preserves nothing explicitly @@ -211,7 +215,7 @@ bool ShouldAutoCompactMidLoop( const auto preserved_ids = MessageIdSet(preserved); return ShouldAutoCompactBeforeRequest( model, messages, observed_input_tokens, preserved_ids, - include_reasoning); + include_reasoning, context_overhead_tokens); } domain::ChatMessage diff --git a/src/application/auto_compaction_service.h b/src/application/auto_compaction_service.h index f3c5e7ba..3c553df9 100644 --- a/src/application/auto_compaction_service.h +++ b/src/application/auto_compaction_service.h @@ -56,7 +56,7 @@ RetainedUserMessageIds(const std::vector &base_snapshot); const std::optional &model, const std::vector &messages, int observed_input_tokens, std::span preserved_tail_ids, - bool include_reasoning = true); + bool include_reasoning = true, int context_overhead_tokens = 0); // Port of `shouldAutoSoftCompactBeforeRequest` (legacy lines 139-164). The // legacy gate `AiBehaviorSettingsRepository.get().isSoftCompactionEnabled()` is @@ -68,7 +68,7 @@ RetainedUserMessageIds(const std::vector &base_snapshot); const std::vector &messages, int observed_input_tokens, bool soft_compaction_enabled, std::span preserved_tail_ids, - bool include_reasoning = true); + bool include_reasoning = true, int context_overhead_tokens = 0); // Port of `shouldAutoCompactMidLoop` (legacy lines 234-259). The preserved tail // is computed with an empty active user message id, exactly like the legacy @@ -76,7 +76,7 @@ RetainedUserMessageIds(const std::vector &base_snapshot); [[nodiscard]] bool ShouldAutoCompactMidLoop( const std::optional &model, const std::vector &messages, int observed_input_tokens, - bool include_reasoning = true); + bool include_reasoning = true, int context_overhead_tokens = 0); // Port of `hasCompactableBaseMessages` (legacy lines 713-736). A hidden summary // produced by an earlier compaction (the C++ stand-in for "isHidden() && diff --git a/src/application/behavior_settings_repository.cpp b/src/application/behavior_settings_repository.cpp index 52bd6fce..ad6a8488 100644 --- a/src/application/behavior_settings_repository.cpp +++ b/src/application/behavior_settings_repository.cpp @@ -11,6 +11,7 @@ constexpr auto kThinkingAutoExpand = "@lineai_thinking_auto_expand"; constexpr auto kReasoning = "@lineai_reasoning_effort"; constexpr auto kPreserveReasoning = "@lineai_preserve_reasoning"; constexpr auto kLearningMode = "@linecode_learning_mode_enabled"; +constexpr auto kMinimalMode = "@linecode_minimal_mode_enabled"; constexpr auto kSoftCompaction = "@lineai_soft_compaction"; constexpr auto kEnterKey = "@lineai_enter_key_behavior"; } // namespace @@ -35,6 +36,8 @@ AiBehaviorSettingsRepository::Load() { if (!preserve) co_return std::unexpected(preserve.error()); auto learning = co_await store_->GetBoolean(kLearningMode, false); if (!learning) co_return std::unexpected(learning.error()); + auto minimal = co_await store_->GetBoolean(kMinimalMode, false); + if (!minimal) co_return std::unexpected(minimal.error()); auto compaction = co_await store_->GetBoolean(kSoftCompaction, true); if (!compaction) co_return std::unexpected(compaction.error()); co_return domain::AiBehaviorSettings{ @@ -44,6 +47,7 @@ AiBehaviorSettingsRepository::Load() { .thinking_auto_expand = *expand, .preserve_reasoning = *preserve, .learning_mode = *learning, + .minimal_mode = *minimal, .soft_compaction = *compaction, }; } @@ -68,6 +72,9 @@ huxerui::Task> AiBehaviorSettingsRepository::SetPreserveRea huxerui::Task> AiBehaviorSettingsRepository::SetLearningMode(bool value) { co_return co_await store_->SetBoolean(kLearningMode, value); } +huxerui::Task> AiBehaviorSettingsRepository::SetMinimalMode(bool value) { + co_return co_await store_->SetBoolean(kMinimalMode, value); +} huxerui::Task> AiBehaviorSettingsRepository::SetSoftCompaction(bool value) { co_return co_await store_->SetBoolean(kSoftCompaction, value); } diff --git a/src/application/behavior_settings_repository.h b/src/application/behavior_settings_repository.h index ad3238dd..ab6c47f4 100644 --- a/src/application/behavior_settings_repository.h +++ b/src/application/behavior_settings_repository.h @@ -21,6 +21,7 @@ class AiBehaviorSettingsRepository final { [[nodiscard]] huxerui::Task> SetThinkingAutoExpand(bool value); [[nodiscard]] huxerui::Task> SetPreserveReasoning(bool value); [[nodiscard]] huxerui::Task> SetLearningMode(bool value); + [[nodiscard]] huxerui::Task> SetMinimalMode(bool value); [[nodiscard]] huxerui::Task> SetSoftCompaction(bool value); private: diff --git a/src/application/context_compaction.cpp b/src/application/context_compaction.cpp index 13563841..d9e7c895 100644 --- a/src/application/context_compaction.cpp +++ b/src/application/context_compaction.cpp @@ -200,13 +200,16 @@ bool ContextCompactionService::ShouldCompact( bool ContextCompactionService::ShouldCompact( const std::vector &messages, const int context_tokens, - const bool include_reasoning, const int observed_input_tokens) { - if (observed_input_tokens > 0) { - return static_cast(observed_input_tokens) >= - static_cast(std::max(1, context_tokens)) * - COMPACT_TRIGGER_RATIO; - } - return ShouldCompact(messages, context_tokens, include_reasoning); + const bool include_reasoning, const int observed_input_tokens, + const int context_overhead_tokens) { + if (messages.empty() && context_overhead_tokens <= 0) + return false; + const int maximum = std::max(1, context_tokens); + const int estimated = domain::EstimateContextTokens( + messages, include_reasoning, context_overhead_tokens); + const int usage = std::max(estimated, observed_input_tokens); + return static_cast(usage) >= + static_cast(maximum) * COMPACT_TRIGGER_RATIO; } bool ContextCompactionService::ShouldCompact( @@ -220,43 +223,35 @@ bool ContextCompactionService::ShouldCompact( bool ContextCompactionService::ShouldCompact( const domain::ModelConfig &model, const std::vector &messages, - const bool include_reasoning, const int observed_input_tokens) { + const bool include_reasoning, const int observed_input_tokens, + const int context_overhead_tokens) { return ShouldCompact(messages, domain::ResolveModelContext(model).context_tokens, - include_reasoning, observed_input_tokens); + include_reasoning, observed_input_tokens, + context_overhead_tokens); } bool ContextCompactionService::ShouldSoftCompact( const std::vector &messages, const int context_tokens, const bool include_reasoning) { - if (messages.empty()) - return false; - const int maximum = std::max(1, context_tokens); - const int usage = - domain::EstimateContextTokens(messages, include_reasoning); - if (static_cast(usage) < - static_cast(maximum) * SOFT_COMPACT_TRIGGER_RATIO) { - return false; - } - // The hard trigger owns everything at or above its own ratio. - if (static_cast(usage) >= - static_cast(maximum) * COMPACT_TRIGGER_RATIO) { - return false; - } - return CompactableMessageCount(messages) >= - SOFT_COMPACT_MIN_COMPACTABLE_MESSAGES; + return ShouldSoftCompact(messages, context_tokens, include_reasoning, 0, 0); } bool ContextCompactionService::ShouldSoftCompact( const std::vector &messages, const int context_tokens, - const bool include_reasoning, const int observed_input_tokens) { - if (observed_input_tokens <= 0) - return ShouldSoftCompact(messages, context_tokens, include_reasoning); + const bool include_reasoning, const int observed_input_tokens, + const int context_overhead_tokens) { + if (messages.empty() && context_overhead_tokens <= 0) + return false; const int maximum = std::max(1, context_tokens); - if (static_cast(observed_input_tokens) < + const int estimated = domain::EstimateContextTokens( + messages, include_reasoning, context_overhead_tokens); + const int usage = std::max(estimated, observed_input_tokens); + if (static_cast(usage) < static_cast(maximum) * SOFT_COMPACT_TRIGGER_RATIO) { return false; } - if (static_cast(observed_input_tokens) >= + // The hard trigger owns everything at or above its own ratio. + if (static_cast(usage) >= static_cast(maximum) * COMPACT_TRIGGER_RATIO) { return false; } @@ -276,10 +271,12 @@ bool ContextCompactionService::ShouldSoftCompact( bool ContextCompactionService::ShouldSoftCompact( const domain::ModelConfig &model, const std::vector &messages, - const bool include_reasoning, const int observed_input_tokens) { + const bool include_reasoning, const int observed_input_tokens, + const int context_overhead_tokens) { return ShouldSoftCompact(messages, domain::ResolveModelContext(model).context_tokens, - include_reasoning, observed_input_tokens); + include_reasoning, observed_input_tokens, + context_overhead_tokens); } std::vector ContextCompactionService::CompactableMessages( diff --git a/src/application/context_compaction.h b/src/application/context_compaction.h index 1e3ca00f..7c82a3bd 100644 --- a/src/application/context_compaction.h +++ b/src/application/context_compaction.h @@ -120,7 +120,8 @@ class ContextCompactionService final { [[nodiscard]] static bool ShouldCompact(const std::vector &messages, int context_tokens, bool include_reasoning, - int observed_input_tokens); + int observed_input_tokens, + int context_overhead_tokens = 0); // Convenience overloads resolving the window through // `domain::ResolveModelContext`, so callers reuse the ported parser. [[nodiscard]] static bool @@ -130,7 +131,8 @@ class ContextCompactionService final { [[nodiscard]] static bool ShouldCompact(const domain::ModelConfig &model, const std::vector &messages, - bool include_reasoning, int observed_input_tokens); + bool include_reasoning, int observed_input_tokens, + int context_overhead_tokens = 0); // Port of the two `shouldSoftCompact` overloads. [[nodiscard]] static bool @@ -139,7 +141,8 @@ class ContextCompactionService final { [[nodiscard]] static bool ShouldSoftCompact(const std::vector &messages, int context_tokens, bool include_reasoning, - int observed_input_tokens); + int observed_input_tokens, + int context_overhead_tokens = 0); [[nodiscard]] static bool ShouldSoftCompact(const domain::ModelConfig &model, const std::vector &messages, @@ -147,7 +150,8 @@ class ContextCompactionService final { [[nodiscard]] static bool ShouldSoftCompact(const domain::ModelConfig &model, const std::vector &messages, - bool include_reasoning, int observed_input_tokens); + bool include_reasoning, int observed_input_tokens, + int context_overhead_tokens = 0); // Port of `compactableMessages`. // diff --git a/src/application/ports/workspace_directory_share.h b/src/application/ports/workspace_directory_share.h index 15745a87..08393a98 100644 --- a/src/application/ports/workspace_directory_share.h +++ b/src/application/ports/workspace_directory_share.h @@ -6,9 +6,8 @@ class WorkspaceDirectoryShareService { public: virtual ~WorkspaceDirectoryShareService() = default; - // Returns whether a native surface accepted the request to expose the - // application-owned .linecode/home directory. - [[nodiscard]] virtual bool OpenHome() = 0; + // Opens the system directory picker at the application-owned .linecode root. + [[nodiscard]] virtual bool MountWorkspace() = 0; }; } // namespace linecode::application diff --git a/src/application/prompt_request_composer.cpp b/src/application/prompt_request_composer.cpp index 6802c507..b5dd6218 100644 --- a/src/application/prompt_request_composer.cpp +++ b/src/application/prompt_request_composer.cpp @@ -8,6 +8,7 @@ #include #include "domain/prompt_renderer.h" +#include "domain/context_usage.h" namespace linecode::application { namespace { @@ -92,6 +93,21 @@ std::string ToolContext(const CompletionRequest &request, return JoinSections({PermissionContext(context.permission_mode), tools}); } +std::string MinimalSystemPrompt(const CompletionRequest &request) { + std::string prompt = "You are LineCode. Call tools when useful; report only " + "completed actions.\n\nTools: "; + if (request.tools.empty()) { + prompt += "none."; + return prompt; + } + for (std::size_t index = 0; index < request.tools.size(); ++index) { + if (index > 0) + prompt += ", "; + prompt += request.tools[index].name; + } + return prompt; +} + bool CorrespondsTo(CompletionRole role, domain::MessageRole history_role) { return (role == CompletionRole::user && history_role == domain::MessageRole::user) || @@ -214,15 +230,31 @@ PromptRequestComposer::PromptRequestComposer( huxerui::Task> PromptRequestComposer::Compose(CompletionRequest request, PromptAssemblyContext context) { - auto templates = co_await prompt_templates_->Load(); - if (!templates) - co_return std::unexpected(std::move(templates.error())); auto behavior = co_await behavior_settings_->Load(); if (!behavior) co_return std::unexpected(std::move(behavior.error())); - const auto prompt = SystemPromptComposer{std::move(*templates)}.Compose( - request, *behavior, context); + std::string prompt; + if (behavior->minimal_mode) { + prompt = MinimalSystemPrompt(request); + } else { + auto templates = co_await prompt_templates_->Load(); + if (!templates) + co_return std::unexpected(std::move(templates.error())); + prompt = SystemPromptComposer{std::move(*templates)}.Compose( + request, *behavior, context); + } + int context_overhead_tokens = domain::message_overhead_tokens + + domain::EstimateTextTokens(prompt); + for (const auto &tool : request.tools) { + context_overhead_tokens += domain::message_overhead_tokens + + domain::EstimateTextTokens(tool.name) + + domain::EstimateTextTokens(tool.description) + + domain::EstimateTextTokens(tool.parameters_json); + } + if (context.on_context_overhead_estimated) + context.on_context_overhead_estimated(context_overhead_tokens); + InjectAttachmentPrompts(request, context.attachment_history, *attachment_renderer_); request.messages.insert( diff --git a/src/application/prompt_request_composer.h b/src/application/prompt_request_composer.h index 7c34bebc..2cf2d200 100644 --- a/src/application/prompt_request_composer.h +++ b/src/application/prompt_request_composer.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -32,6 +33,9 @@ struct PromptAssemblyContext final { std::string permission_mode{"auto"}; std::string tools_context; std::vector attachment_history; + // Reported back once the request is assembled, so callers can account for + // the system prompt and tool definitions in the context estimate. + std::function on_context_overhead_estimated{}; }; // Request-time policy boundary used by the tool loop after it has resolved the diff --git a/src/application/token_usage_tracker.h b/src/application/token_usage_tracker.h index fd861071..0de635a7 100644 --- a/src/application/token_usage_tracker.h +++ b/src/application/token_usage_tracker.h @@ -35,10 +35,19 @@ class TokenUsageTracker final { return last_output_tokens_; } + void SetContextOverheadTokens(int value) noexcept { + context_overhead_tokens_ = value > 0 ? value : 0; + } + + [[nodiscard]] int ContextOverheadTokens() const noexcept { + return context_overhead_tokens_; + } + void Reset() noexcept { last_input_tokens_ = 0; last_output_tokens_ = 0; measured_conversation_.clear(); + context_overhead_tokens_ = 0; } // Legacy `ContextCompactionController.onConversationChanged()`: the count @@ -50,6 +59,7 @@ class TokenUsageTracker final { if (transcript_empty || conversation_id != measured_conversation_) { last_input_tokens_ = 0; last_output_tokens_ = 0; + context_overhead_tokens_ = 0; measured_conversation_ = std::move(conversation_id); } } @@ -57,6 +67,7 @@ class TokenUsageTracker final { private: int last_input_tokens_{}; int last_output_tokens_{}; + int context_overhead_tokens_{}; std::string measured_conversation_; }; diff --git a/src/application/tool_loop_compactor.cpp b/src/application/tool_loop_compactor.cpp index cd6a5017..68382991 100644 --- a/src/application/tool_loop_compactor.cpp +++ b/src/application/tool_loop_compactor.cpp @@ -9,6 +9,7 @@ #include #include "domain/compaction_progress.h" +#include "domain/context_usage.h" namespace linecode::application { namespace { @@ -44,6 +45,22 @@ domain::ChatMessage ToDomain(const CompletionMessage &message) { return converted; } +int EstimateRequestOverhead(const CompletionRequest &request) { + int total = 0; + if (!request.messages.empty() && + request.messages.front().role == CompletionRole::system) { + total += domain::message_overhead_tokens + + domain::EstimateTextTokens(request.messages.front().content); + } + for (const auto &tool : request.tools) { + total += domain::message_overhead_tokens + + domain::EstimateTextTokens(tool.name) + + domain::EstimateTextTokens(tool.description) + + domain::EstimateTextTokens(tool.parameters_json); + } + return total; +} + } // namespace ToolLoopCompactor::ToolLoopCompactor( @@ -98,7 +115,8 @@ ToolLoopCompactor::CompactIfNeeded(CompletionRequest request, if (!ShouldAutoCompactMidLoop(model, converted, static_cast(observed_input_tokens), - include_reasoning_)) { + include_reasoning_, + EstimateRequestOverhead(request))) { co_return request; } diff --git a/src/domain/behavior_settings.h b/src/domain/behavior_settings.h index 6c6bdb4d..3164abcd 100644 --- a/src/domain/behavior_settings.h +++ b/src/domain/behavior_settings.h @@ -16,6 +16,7 @@ struct AiBehaviorSettings final { bool thinking_auto_expand{}; bool preserve_reasoning{}; bool learning_mode{}; + bool minimal_mode{}; bool soft_compaction{true}; bool operator==(const AiBehaviorSettings &) const = default; diff --git a/src/domain/context_usage.cpp b/src/domain/context_usage.cpp index 50a3649a..62bcc9a0 100644 --- a/src/domain/context_usage.cpp +++ b/src/domain/context_usage.cpp @@ -179,8 +179,9 @@ int EstimateMessageTokens(const ChatMessage &message, } int EstimateContextTokens(std::span messages, - const bool include_reasoning) { - int total = 0; + const bool include_reasoning, + const int additional_tokens) { + int total = std::max(0, additional_tokens); for (const auto &message : messages) total += EstimateMessageTokens(message, include_reasoning); return total; @@ -188,9 +189,13 @@ int EstimateContextTokens(std::span messages, ContextSnapshot SnapshotContext(std::span messages, const int context_tokens, - const bool include_reasoning) { + const bool include_reasoning, + const int additional_tokens, + const int observed_input_tokens) { const int maximum = std::max(1, context_tokens); - const int used = EstimateContextTokens(messages, include_reasoning); + const int estimated = EstimateContextTokens(messages, include_reasoning, + additional_tokens); + const int used = std::max(estimated, observed_input_tokens); const int percent = static_cast( std::lround(static_cast(used) * 100.0 / static_cast(maximum))); return ContextSnapshot{.used_tokens = used, diff --git a/src/domain/context_usage.h b/src/domain/context_usage.h index aa28eb41..bbde0fac 100644 --- a/src/domain/context_usage.h +++ b/src/domain/context_usage.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include #include @@ -48,9 +50,20 @@ struct ModelContextInfo final { [[nodiscard]] int EstimateMessageTokens(const ChatMessage &message, bool include_reasoning); +// Estimates arbitrary request metadata such as the system prompt using the +// same local characters-over-four heuristic as message content. +[[nodiscard]] inline int EstimateTextTokens(std::string_view text) { + if (text.empty()) + return 0; + return std::max( + 1, static_cast(std::ceil(static_cast(text.size()) / + static_cast(characters_per_token)))); +} + // Port of `ContextManager.estimateTokens(List, boolean)`. [[nodiscard]] int EstimateContextTokens( - std::span messages, bool include_reasoning = true); + std::span messages, bool include_reasoning = true, + int additional_tokens = 0); // Port of the `ContextSnapshot` value object. struct ContextSnapshot final { @@ -69,6 +82,7 @@ struct ContextSnapshot final { // Computes the snapshot the header indicator and usage sheet render. [[nodiscard]] ContextSnapshot SnapshotContext( std::span messages, int context_tokens, - bool include_reasoning = true); + bool include_reasoning = true, int additional_tokens = 0, + int observed_input_tokens = 0); } // namespace linecode::domain diff --git a/src/domain/memory.h b/src/domain/memory.h index 36dcf811..03ee1f9a 100644 --- a/src/domain/memory.h +++ b/src/domain/memory.h @@ -90,11 +90,11 @@ struct MemorySkillRecord final { struct MemoryOverview final { std::string project_id; - std::vector long_term; - std::vector project; - std::vector environment; - std::vector short_term; - std::vector history; + std::vector long_term{}; + std::vector project{}; + std::vector environment{}; + std::vector short_term{}; + std::vector history{}; bool operator==(const MemoryOverview &) const = default; }; diff --git a/src/infrastructure/android_workspace_directory_share.cpp b/src/infrastructure/android_workspace_directory_share.cpp index 7386034d..0d75bb55 100644 --- a/src/infrastructure/android_workspace_directory_share.cpp +++ b/src/infrastructure/android_workspace_directory_share.cpp @@ -25,11 +25,11 @@ class AndroidWorkspaceDirectoryShareService final huxerui::PlatformChannel channel) : channel_(std::move(channel)) {} - [[nodiscard]] bool OpenHome() override { + [[nodiscard]] bool MountWorkspace() override { if (!channel_.IsOpen()) return false; channel_.Invoke( - "openHome", [](huxerui::PlatformResult) {}); + "mountWorkspace", [](huxerui::PlatformResult) {}); return true; } diff --git a/src/infrastructure/openai_chat_codec.cpp b/src/infrastructure/openai_chat_codec.cpp index 51d67c61..c3f210a4 100644 --- a/src/infrastructure/openai_chat_codec.cpp +++ b/src/infrastructure/openai_chat_codec.cpp @@ -623,11 +623,15 @@ void EmitMinimax(std::string &json, const ReasoningContext &context) { } void EmitDeepseek(std::string &json, const ReasoningContext &context) { - const bool enabled = ReasoningEnabled(context.effort); - json += std::string{",\"thinking\":{\"type\":\""} + - (enabled ? "enabled" : "disabled") + "\"}"; - if (enabled) { - json += ",\"reasoning_effort\":"; + if (context.effort == domain::ReasoningEffort::automatic) + return; + json += ",\"reasoning_effort\":"; + if (context.effort == domain::ReasoningEffort::off || + context.effort == domain::ReasoningEffort::medium) { + AppendEscaped(json, context.effort == domain::ReasoningEffort::off + ? "none" + : "high"); + } else { AppendEscaped(json, ConcreteEffort(context.effort)); } } diff --git a/src/infrastructure/sqlite_memory_store.cpp b/src/infrastructure/sqlite_memory_store.cpp index 9f2f7a20..c244cf9f 100644 --- a/src/infrastructure/sqlite_memory_store.cpp +++ b/src/infrastructure/sqlite_memory_store.cpp @@ -58,6 +58,19 @@ MemoryStoreError StoreError(const huxerui::sqlite::Error &error) { return {.message = error.Message()}; } +// `memories_fts` is the FTS4 index the legacy Android build maintained beside +// `memories`. This runtime's SQLite has no FTS module, so a database upgraded +// from that build still carries a virtual table SQLite cannot load: every +// statement naming it fails with "no such module: fts4", and even +// `DROP TABLE` cannot remove it. The index is derived data nothing here reads, +// so removing a memory must not fail because its stale index row is out of +// reach -- a database without the index at all reports the same way. +bool IsUnreachableMemoryIndex(const huxerui::sqlite::Error &error) { + const auto message = error.Message(); + return message.find("no such table") != std::string::npos || + message.find("no such module") != std::string::npos; +} + std::int64_t NowMilliseconds() noexcept { return std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()) @@ -433,12 +446,10 @@ SqliteMemoryStore::Delete(std::vector ids) { transaction.Execute("DELETE FROM memories WHERE id = ?", id); if (!row) return row.Error(); - auto fts = + auto index = transaction.Execute("DELETE FROM memories_fts WHERE id = ?", id); - if (!fts && fts.Error().Message().find("no such table") == - std::string::npos) { - return fts.Error(); - } + if (!index && !IsUnreachableMemoryIndex(index.Error())) + return index.Error(); } return {}; }); diff --git a/src/presentation/components/chat_composer.cpp b/src/presentation/components/chat_composer.cpp index 0cbc9007..b28a29a8 100644 --- a/src/presentation/components/chat_composer.cpp +++ b/src/presentation/components/chat_composer.cpp @@ -543,16 +543,13 @@ using namespace huxerui; const auto permission_mode = state.permission_mode; auto toast = std::move(state.toast); auto auto_compaction = std::move(state.auto_compaction); + auto token_usage = std::move(state.token_usage); auto retry_labels = std::move(state.retry_labels); auto show_attachment_picker = std::move(actions.show_attachment_picker); auto show_image_picker = std::move(actions.show_image_picker); auto show_model_picker = std::move(actions.show_model_picker); auto show_reasoning_picker = std::move(actions.show_reasoning_picker); auto handle_slash_command = std::move(actions.handle_slash_command); - // Composition-scoped so the token count survives the runner being rebuilt on - // every recomposition. - const auto token_usage = - UseState(std::make_shared()).Get(); auto runner = MakeChatGenerationRunner( ChatGenerationDependencies{ .session = session, diff --git a/src/presentation/components/chat_composer.h b/src/presentation/components/chat_composer.h index 6bb116ce..928756e5 100644 --- a/src/presentation/components/chat_composer.h +++ b/src/presentation/components/chat_composer.h @@ -30,6 +30,7 @@ class ModelStore; class SkillRepository; class TodoStateStore; class ToolReviewCoordinator; +class TokenUsageTracker; } // namespace linecode::application namespace linecode::presentation::chat_composer { @@ -77,6 +78,7 @@ struct ViewState final { domain::ToolPermissionMode::automatic}; huxerui::ToastHandle toast; std::shared_ptr auto_compaction; + std::shared_ptr token_usage; RetryLabels retry_labels; }; diff --git a/src/presentation/components/chat_generation_runner.cpp b/src/presentation/components/chat_generation_runner.cpp index 0a9694d6..d71bdcde 100644 --- a/src/presentation/components/chat_generation_runner.cpp +++ b/src/presentation/components/chat_generation_runner.cpp @@ -165,11 +165,18 @@ class DefaultChatGenerationRunner final : 0; const bool hard = application::ShouldAutoCompactBeforeRequest( model_option, messages, observed_tokens, preserved_ids, - behavior.preserve_reasoning); + behavior.preserve_reasoning, + dependencies_.token_usage + ? dependencies_.token_usage->ContextOverheadTokens() + : 0); const bool soft = !hard && application::ShouldAutoSoftCompactBeforeRequest( model_option, messages, observed_tokens, behavior.soft_compaction, preserved_ids, - behavior.preserve_reasoning); + behavior.preserve_reasoning, + dependencies_.token_usage + ? dependencies_.token_usage + ->ContextOverheadTokens() + : 0); if (!hard && !soft) co_return; @@ -352,7 +359,7 @@ class DefaultChatGenerationRunner final auto context = co_await dependencies_.memory_context->Prepare( current_project_id_, user_text, conversation_id, - behavior->learning_mode); + behavior->learning_mode && !behavior->minimal_mode); if (!dependencies_.generation->IsCurrent(work.generation_id)) co_return; if (!context) { @@ -364,13 +371,21 @@ class DefaultChatGenerationRunner final } auto prompt_context = prompt_context_; + if (dependencies_.token_usage) { + prompt_context.on_context_overhead_estimated = + [token_usage = dependencies_.token_usage, + revision = revision_](int tokens) { + token_usage->SetContextOverheadTokens(tokens); + revision += 1; + }; + } if (dependencies_.todo_state) { auto todo = co_await dependencies_.todo_state->Load(); if (todo) prompt_context.todo_state = application::RenderTodoState(*todo); } prompt_context.learning_context = context->prompt; - if (dependencies_.skills) { + if (dependencies_.skills && !behavior->minimal_mode) { auto extensions = co_await dependencies_.skills->BuildExtensionPrompt(); if (extensions && !extensions->empty()) { // Legacy `SystemPromptProvider.build(homePath, tone, chatMode, diff --git a/src/presentation/components/chat_screen.cpp b/src/presentation/components/chat_screen.cpp index cf37b0ed..ef03bef8 100644 --- a/src/presentation/components/chat_screen.cpp +++ b/src/presentation/components/chat_screen.cpp @@ -564,6 +564,8 @@ struct ContextSnapshotCache final { std::size_t source_count{}; std::uint64_t last_message_id{}; int context_tokens{}; + int context_overhead_tokens{}; + int observed_input_tokens{}; bool preserve_reasoning{}; bool valid{}; domain::ContextSnapshot snapshot; @@ -854,6 +856,8 @@ void ShowTextSelectionDialog(const DialogHandle &dialogs, std::string content) { const auto image_picker = UseService(); auto compaction_busy = UseState(std::make_shared>(false)); auto context_cache = UseState(std::make_shared()); + const auto token_usage = + UseState(std::make_shared()).Get(); auto attachment_visible = UseState(false); auto more_visible = UseState(false); auto compaction_confirm_visible = UseState(false); @@ -1681,6 +1685,7 @@ void ShowTextSelectionDialog(const DialogHandle &dialogs, std::string content) { .permission_mode = permission_state->mode, .toast = toast, .auto_compaction = auto_compaction.Get(), + .token_usage = token_usage, .retry_labels = retry_labels, }, chat_composer::Actions{ @@ -1728,21 +1733,28 @@ void ShowTextSelectionDialog(const DialogHandle &dialogs, std::string content) { const std::string conversation_id{session->CurrentConversationId()}; const std::uint64_t last_message_id = source_messages.empty() ? 0U : source_messages.back().id; + const int context_overhead_tokens = token_usage->ContextOverheadTokens(); + const int observed_input_tokens = token_usage->LastInputTokens(); if (!cached_context->valid || cached_context->conversation_id != conversation_id || (!generation_running && cached_context->revision != revision.Get()) || cached_context->source_count != source_messages.size() || cached_context->last_message_id != last_message_id || cached_context->context_tokens != context_tokens || + cached_context->context_overhead_tokens != context_overhead_tokens || + cached_context->observed_input_tokens != observed_input_tokens || cached_context->preserve_reasoning != preserve_reasoning) { cached_context->conversation_id = conversation_id; cached_context->revision = revision.Get(); cached_context->source_count = source_messages.size(); cached_context->last_message_id = last_message_id; cached_context->context_tokens = context_tokens; + cached_context->context_overhead_tokens = context_overhead_tokens; + cached_context->observed_input_tokens = observed_input_tokens; cached_context->preserve_reasoning = preserve_reasoning; cached_context->snapshot = domain::SnapshotContext( - source_messages, context_tokens, preserve_reasoning); + source_messages, context_tokens, preserve_reasoning, + context_overhead_tokens, observed_input_tokens); cached_context->valid = true; } const auto context_snapshot = cached_context->snapshot; diff --git a/src/presentation/screens/extensions_screen.cpp b/src/presentation/screens/extensions_screen.cpp index 61ae0063..396fe5e5 100644 --- a/src/presentation/screens/extensions_screen.cpp +++ b/src/presentation/screens/extensions_screen.cpp @@ -141,7 +141,7 @@ void ShowUnavailableAction( void OpenSkillStore(const ExtensionScreenServices &services, RouteNavigationController navigation, ToastHandle toast); -void ShareSkillWorkspace(const ExtensionScreenServices &services, +void MountSkillWorkspace(const ExtensionScreenServices &services, RouteNavigationController navigation, ToastHandle toast); bool AlwaysSupplementAvailable(const ExtensionScreenServices &services); @@ -353,7 +353,7 @@ const std::array kSkillSupplements{ app::strings::screen_extension_detail_workspace_share, app::strings::screen_extension_detail_workspace_share_desc, app::images::folder_open, 83.0F, WorkspaceShareAvailable, - ShareSkillWorkspace}, + MountSkillWorkspace}, }; const std::array kNoSupplements{}; @@ -1164,10 +1164,10 @@ void OpenSkillStore(const ExtensionScreenServices &, navigation.Push(domain::AppRoute::skill_store); } -void ShareSkillWorkspace(const ExtensionScreenServices &services, +void MountSkillWorkspace(const ExtensionScreenServices &services, RouteNavigationController, ToastHandle toast) { - if (!services.workspace_share || !services.workspace_share->OpenHome()) + if (!services.workspace_share || !services.workspace_share->MountWorkspace()) toast.Show(app::strings::screen_extension_unavailable); } diff --git a/src/presentation/screens/llm_settings_screen.cpp b/src/presentation/screens/llm_settings_screen.cpp index 2c92aef1..7eb35f26 100644 --- a/src/presentation/screens/llm_settings_screen.cpp +++ b/src/presentation/screens/llm_settings_screen.cpp @@ -156,6 +156,13 @@ struct ReasoningMeta final { } std::vector learning_rows; + learning_rows.push_back(SwitchRow( + app::images::zap, app::strings::screen_llm_minimal_label, + app::strings::screen_llm_minimal_desc, settings->minimal_mode, + [settings, repository, persist](bool value) { + settings.Update([value](auto &next) { next.minimal_mode = value; }); + persist([repository, value] { return repository->SetMinimalMode(value); }); + }, 89.5F)); learning_rows.push_back(SwitchRow( app::images::brain, app::strings::screen_llm_learning_label, app::strings::screen_llm_learning_desc, settings->learning_mode, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e932a623..734c4e97 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -933,6 +933,35 @@ else () endif () add_test(NAME memory_store_contract_tests COMMAND memory_store_contract_tests) +add_executable(sqlite_memory_store_tests + sqlite_memory_store_tests.cpp + ../src/domain/memory.cpp + ../src/domain/memory_rag.cpp + ../src/infrastructure/sqlite_memory_store.cpp +) +target_include_directories(sqlite_memory_store_tests PRIVATE ../src) +target_compile_features(sqlite_memory_store_tests PRIVATE cxx_std_23) +huxerui_use_library(sqlite_memory_store_tests + TARGET HuxerUI::SQLite + URL "https://github.com/HuxerUI/Lib-SQLite.git" + COMMIT "0b316df41cbd88b212e6aeead326196e3db6c96a" +) +target_link_libraries(sqlite_memory_store_tests PRIVATE + HuxerUI::testing) +set_target_properties(sqlite_memory_store_tests PROPERTIES + CXX_STANDARD 23 + CXX_STANDARD_REQUIRED ON + CXX_EXTENSIONS OFF +) +if (MSVC) + target_compile_options(sqlite_memory_store_tests PRIVATE + /W4 /permissive-) +else () + target_compile_options(sqlite_memory_store_tests PRIVATE + -Wall -Wextra -Wpedantic -Werror) +endif () +add_test(NAME sqlite_memory_store_tests COMMAND sqlite_memory_store_tests) + add_executable(memory_rag_tests memory_rag_tests.cpp ../src/domain/memory.cpp diff --git a/tests/completion_protocol_codec_tests.cpp b/tests/completion_protocol_codec_tests.cpp index f51989c1..26383214 100644 --- a/tests/completion_protocol_codec_tests.cpp +++ b/tests/completion_protocol_codec_tests.cpp @@ -121,6 +121,57 @@ void OpenAiCodecCarriesSystemAndProviderReasoning() { EXPECT_EXPRESSION(!nvidia->body.contains("enable_thinking")); } +void DeepseekUsesSupportedReasoningEffortWithoutThinking() { + const auto *codec = + FindCompletionProtocolCodec(ModelProtocol::openai_compatible); + EXPECT_EXPRESSION(codec != nullptr); + auto request = Request(ModelProtocol::openai_compatible, true); + request.model.base_url = "https://api.deepseek.com/v1"; + request.model.model_id = "deepseek-flash-v4.1"; + + using linecode::domain::ReasoningEffort; + const std::array cases{ + std::pair{ReasoningEffort::off, std::string_view{"none"}}, + std::pair{ReasoningEffort::low, std::string_view{"low"}}, + std::pair{ReasoningEffort::medium, std::string_view{"high"}}, + std::pair{ReasoningEffort::high, std::string_view{"high"}}, + std::pair{ReasoningEffort::maximum, std::string_view{"max"}}, + }; + for (const auto &[effort, expected] : cases) { + request.reasoning_effort = effort; + const auto wire = codec->encode(request, request.model.base_url); + EXPECT_EXPRESSION(wire.has_value()); + json::Value storage{json::Null{}}; + const auto &body = BodyObject(wire->body, storage); + EXPECT_EXPRESSION(json::Find(body, "thinking") == nullptr); + const auto *reasoning_effort = + json::AsString(json::Find(body, "reasoning_effort")); + EXPECT_EXPRESSION(reasoning_effort != nullptr); + EXPECT_EXPRESSION(*reasoning_effort == expected); + } + + request.reasoning_effort = ReasoningEffort::automatic; + const auto automatic = codec->encode(request, request.model.base_url); + EXPECT_EXPRESSION(automatic.has_value()); + json::Value storage{json::Null{}}; + const auto &body = BodyObject(automatic->body, storage); + EXPECT_EXPRESSION(json::Find(body, "thinking") == nullptr); + EXPECT_EXPRESSION(json::Find(body, "reasoning_effort") == nullptr); + + request.model.base_url = "https://proxy.example.test/v1"; + request.model.model_id = "deepseek-flash"; + request.reasoning_effort = ReasoningEffort::maximum; + const auto proxy = codec->encode(request, request.model.base_url); + EXPECT_EXPRESSION(proxy.has_value()); + json::Value proxy_storage{json::Null{}}; + const auto &proxy_body = BodyObject(proxy->body, proxy_storage); + EXPECT_EXPRESSION(json::Find(proxy_body, "thinking") == nullptr); + const auto *proxy_effort = + json::AsString(json::Find(proxy_body, "reasoning_effort")); + EXPECT_EXPRESSION(proxy_effort != nullptr); + EXPECT_EXPRESSION(*proxy_effort == "max"); +} + void OpenAiCodecSupportsToolRoundTrips() { const auto *codec = FindCompletionProtocolCodec(ModelProtocol::openai_compatible); @@ -541,6 +592,7 @@ void StreamUsageSurvivesEmptyChoices() { TEST(completion_protocol_codec_tests, LegacySuite) { OpenAiCodecStillUsesChatCompletions(); OpenAiCodecCarriesSystemAndProviderReasoning(); + DeepseekUsesSupportedReasoningEffortWithoutThinking(); OpenAiCodecSupportsToolRoundTrips(); OpenAiCodecDecodesReasoningSeparatelyFromAnswerText(); AnthropicCodecMatchesMessagesContract(); diff --git a/tests/context_compaction_tests.cpp b/tests/context_compaction_tests.cpp index 7f5be112..d613de1a 100644 --- a/tests/context_compaction_tests.cpp +++ b/tests/context_compaction_tests.cpp @@ -144,16 +144,22 @@ void HardTriggerThresholdsMatchLegacy() { EXPECT_EXPRESSION(!ContextCompactionService::ShouldCompact(below, 1000, true)); EXPECT_EXPRESSION(ContextCompactionService::ShouldCompact(at, 1000, true)); - // Observed server input tokens win over the local estimate. + // The larger of the local estimate and the observed server input tokens + // decides, so a lower server count cannot hide an over-limit transcript. EXPECT_EXPRESSION(!ContextCompactionService::ShouldCompact(below, 1000, true, 799)); EXPECT_EXPRESSION(ContextCompactionService::ShouldCompact(below, 1000, true, 800)); - // Zero observed tokens mean "not observed" and fall back to the estimate. + // Zero observed tokens mean "not observed"; the estimate decides alone. EXPECT_EXPRESSION(!ContextCompactionService::ShouldCompact(below, 1000, true, 0)); EXPECT_EXPRESSION(ContextCompactionService::ShouldCompact(at, 1000, true, 0)); + // Fixed request overhead (system prompt and tool definitions) counts too. + EXPECT_EXPRESSION(!ContextCompactionService::ShouldCompact(below, 1000, true, 0, 0)); + EXPECT_EXPRESSION(ContextCompactionService::ShouldCompact(below, 1000, true, 0, 1)); - // Degenerate inputs: no messages never triggers, and a zero window floors to - // one token instead of dividing by zero. + // Degenerate inputs: an empty transcript alone never triggers, while a large + // enough fixed overhead still does; a zero window floors to one token instead + // of dividing by zero. EXPECT_EXPRESSION(!ContextCompactionService::ShouldCompact({}, 1000, true)); + EXPECT_EXPRESSION(ContextCompactionService::ShouldCompact({}, 1000, true, 0, 800)); EXPECT_EXPRESSION(ContextCompactionService::ShouldCompact({UserMessage("x")}, 0, true)); // Reasoning is only counted when the caller asks for it. @@ -205,11 +211,16 @@ void SoftTriggerThresholdsMatchLegacy() { EXPECT_EXPRESSION(!ContextCompactionService::ShouldSoftCompact(below, 1000, true)); EXPECT_EXPRESSION(ContextCompactionService::ShouldSoftCompact(eight, 1000, true)); - // Observed server input tokens win over the local estimate. + // The larger of the local estimate and the observed server input tokens + // decides: `below` only reaches the trigger through the server count, while + // `eight` already reaches it on its own. EXPECT_EXPRESSION(ContextCompactionService::ShouldSoftCompact(below, 1000, true, 500)); - EXPECT_EXPRESSION(!ContextCompactionService::ShouldSoftCompact(eight, 1000, true, 499)); + EXPECT_EXPRESSION(ContextCompactionService::ShouldSoftCompact(eight, 1000, true, 499)); EXPECT_EXPRESSION(!ContextCompactionService::ShouldSoftCompact(eight, 1000, true, 800)); EXPECT_EXPRESSION(ContextCompactionService::ShouldSoftCompact(eight, 1000, true, 0)); + // Fixed request overhead counts as well, and the hard trigger still owns 80%. + EXPECT_EXPRESSION(ContextCompactionService::ShouldSoftCompact(below, 1000, true, 0, 400)); + EXPECT_EXPRESSION(!ContextCompactionService::ShouldSoftCompact(below, 1000, true, 0, 600)); // The model overload resolves the window through the ported parser. auto model = ProtocolModel(ModelProtocol::openai_compatible, false); diff --git a/tests/sqlite_memory_store_tests.cpp b/tests/sqlite_memory_store_tests.cpp new file mode 100644 index 00000000..53958fe3 --- /dev/null +++ b/tests/sqlite_memory_store_tests.cpp @@ -0,0 +1,169 @@ +#include "gtest_support.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "infrastructure/sqlite_memory_store.h" + +namespace { + +namespace infrastructure = linecode::infrastructure; + +constexpr std::string_view kLegacyMemoryId{"legacy-memory"}; + +// The schema entry the legacy Android build left behind for the FTS4 index it +// maintained beside `memories`. A database upgraded from that build still +// carries it, but this runtime's SQLite is built without an FTS module, so the +// table can neither be read nor written: every statement naming it fails with +// "no such module: fts4". The fixture writes the entry directly because +// creating the table the way the legacy build did needs an FTS-capable SQLite. +constexpr std::string_view kLegacyFtsSchema = + "CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts4(" + "id, scope, project_id, content, tokenize=unicode61)"; + +// The `memories` layout and row such an upgraded database already holds. +constexpr std::string_view kCreateLegacyMemories = R"sql( +CREATE TABLE memories ( + id TEXT PRIMARY KEY, + scope TEXT NOT NULL, + project_id TEXT, + content TEXT NOT NULL, + source TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_used_at INTEGER, + use_count INTEGER NOT NULL DEFAULT 0, + raw_json TEXT, + title TEXT NOT NULL DEFAULT '' +) +)sql"; + +constexpr std::string_view kInsertLegacyMemory = R"sql( +INSERT INTO memories + (id, scope, project_id, content, source, confidence, created_at, updated_at, + use_count, raw_json, title) +VALUES ('legacy-memory', 'user', NULL, 'delete me', 'manual', 1, 10, 20, 0, + '', '') +)sql"; + +std::string LegacyFtsSchemaStatement() { + return "INSERT INTO sqlite_master(type, name, tbl_name, rootpage, sql) " + "VALUES ('table', 'memories_fts', 'memories_fts', 0, '" + + std::string{kLegacyFtsSchema} + "')"; +} + +huxerui::Task> SeedLegacyDatabase( + huxerui::File file) { + auto opened = co_await huxerui::sqlite::Database::OpenAsync( + std::move(file), {.create_parent_directories = true}); + if (!opened) + co_return huxerui::sqlite::Result{opened.Error()}; + std::array statements{ + std::string{kCreateLegacyMemories}, + std::string{kInsertLegacyMemory}, + std::string{"PRAGMA writable_schema = ON"}, + LegacyFtsSchemaStatement(), + std::string{"PRAGMA writable_schema = OFF"}, + std::string{"PRAGMA schema_version = 2"}, + }; + for (const auto &statement : statements) { + auto executed = co_await opened->ExecuteAsync(statement); + if (!executed) + co_return huxerui::sqlite::Result{executed.Error()}; + } + co_return huxerui::sqlite::Result{}; +} + +struct DeleteScenario final { + std::shared_ptr store; + std::string database_path; + bool done{}; + std::string failure; +}; + +std::shared_ptr scenario; + +huxerui::View DeleteProbe() { + const auto current = scenario; + const auto tasks = huxerui::UseTaskScope(); + huxerui::Lifecycle([current, tasks] { + const auto handle = tasks.Launch([current]() -> huxerui::Task { + const auto fail = [current](std::string message) { + current->failure = std::move(message); + current->done = true; + }; + { + auto seeded = + co_await SeedLegacyDatabase(huxerui::File{current->database_path}); + if (!seeded) { + fail("legacy fixture failed: " + seeded.Error().Message()); + co_return; + } + } + + auto deleted = + co_await current->store->Delete({std::string{kLegacyMemoryId}}); + if (!deleted) { + fail("deleting a memory failed: " + deleted.error().message); + co_return; + } + auto overview = co_await current->store->LoadOverview(""); + if (!overview) { + fail("reloading memories failed: " + overview.error().message); + co_return; + } + if (!overview->long_term.empty()) { + fail("the deleted memory is still stored"); + co_return; + } + current->done = true; + }); + return [handle] { handle.Cancel(); }; + }); + return huxerui::Text("sqlite-memory-store-probe"); +} + +void DeleteSucceedsWithUnreachableLegacyIndex() { + const auto nonce = std::chrono::steady_clock::now() + .time_since_epoch() + .count(); + const auto temporary = std::filesystem::temp_directory_path() / + ("linecode-memory-store-" + std::to_string(nonce)); + const auto database = temporary / "linecode.db"; + + scenario = std::make_shared(); + scenario->database_path = database.string(); + scenario->store = std::make_shared( + huxerui::File{scenario->database_path}); + { + const huxerui::Application app(DeleteProbe, {.show_debug_overlay = false}); + huxerui::testing::UiTest ui(app); + for (int attempt = 0; attempt < 2'000 && !scenario->done; ++attempt) { + ui.Pump(std::chrono::milliseconds{1}); + std::this_thread::sleep_for(std::chrono::milliseconds{1}); + } + EXPECT_EXPRESSION(scenario->done); + EXPECT_EXPRESSION(scenario->failure.empty()) << scenario->failure; + } + scenario.reset(); + std::error_code ignored; + std::filesystem::remove_all(temporary, ignored); +} + +} // namespace + +TEST(sqlite_memory_store_tests, LegacySuite) { + DeleteSucceedsWithUnreachableLegacyIndex(); + std::cout << "sqlite memory store tests passed\n"; +} diff --git a/update.md b/update.md index f7a2a693..e473be14 100644 --- a/update.md +++ b/update.md @@ -1,5 +1,49 @@ # 更新日志 +## v1.3.3 + +### 工作区与文件 + +- **挂载工作区到文件系统** - 扩展页的「分享工作区目录」改为「挂载工作区到文件系统」: + 内部 `.linecode` 目录改由完整的系统文档提供者(SAF)暴露,点击后打开系统的目录 + 选择器并直接落在工作区根目录(Android 8.0 以下选择器不保证从该目录开始), + 确认授权后即可在支持 SAF 的文件管理器里浏览、新建、重命名、复制、移动和删除 + 其中的 `home/`、`project/`、`skills/` 文件。 +- **路径安全** - 文档 ID 与真实路径的映射集中到 `WorkspaceDocumentPaths`, + 拒绝 `..`、非法文件名、符号链接以及任何越出工作区根目录的路径; + 删除与复制前先校验整棵子树,避免顺着链接删到工作区之外。 + +### 模型与提示词 + +- **极简模式** - 「设置 → AI 行为」新增开关(学习模式上方):开启后系统提示词压缩为 + 一句角色说明加工具名清单,跳过自定义提示词、记忆检索与 Skills 注入, + 适合上下文较小的模型,或只想让它直接干活时。 +- **DeepSeek 思考档位** - DeepSeek 请求不再发送 `thinking` 字段,改为服务端支持的 + `reasoning_effort`:关闭对应 `none`、中对应 `high`、最高对应 `max`, + 自动档不发送该字段;自建与代理端点同样生效。 + +### 上下文与压缩 + +- **用量包含固定开销** - 上下文指示器和用量面板把系统提示词、工具定义的固定开销 + 计入估算,并与服务端返回的输入 token 取较大值,不再只统计消息文本。 +- **压缩触发更贴近实际** - 自动压缩(含工具循环中途的硬压缩与软压缩)改用同一口径: + 本地估算加固定开销,再与服务端实测输入 token 取较大值; + 系统提示词或工具很多时也能按真实占用触发,对话为空且没有固定开销时不触发。 +- **用量状态** - token 用量状态从输入框组件提升到聊天页,输入框重建时不再丢失, + 用量面板也能立刻用上最新的固定开销估算。 + +### 记忆 + +- **删除记忆报错修复** - 从旧版本升级的数据库删除记忆时不再报 + `no such module: fts4`:旧版建立的 FTS4 索引在当前 SQLite 里已无法访问, + 删除记忆会跳过这个不可达的索引行,不再因此整体回滚。 + +### 发布 + +- **版本** - `versionCode` 37、`versionName` 1.3.3。 +- **回归覆盖** - 原生测试新增升级库删除记忆的用例,并为 DeepSeek 思考档位补齐 + 请求体断言;`WorkspaceDocumentPaths` 新增 Android 单元测试。 + ## v1.3.2 ### 稳定性与性能