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
### 稳定性与性能