context-mode 1.0.119 → 1.0.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "1.0.119"
9
+ "version": "1.0.120"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "context-mode",
14
14
  "source": "./",
15
15
  "description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
16
- "version": "1.0.119",
16
+ "version": "1.0.120",
17
17
  "author": {
18
18
  "name": "Mert Koseoğlu"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.119",
3
+ "version": "1.0.120",
4
4
  "description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -3,7 +3,7 @@
3
3
  "name": "Context Mode",
4
4
  "kind": "tool",
5
5
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
6
- "version": "1.0.119",
6
+ "version": "1.0.120",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.119",
3
+ "version": "1.0.120",
4
4
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -175,7 +175,16 @@ export const OPENCLAW_TOOL_DEFS = [
175
175
  },
176
176
  {
177
177
  name: "ctx_purge",
178
- description: "Permanently delete all indexed content and reset session stats. Destructive.",
178
+ description: "DESTRUCTIVE — permanently delete indexed content. CANNOT be undone.\n\n" +
179
+ "MUST specify exactly ONE scope:\n" +
180
+ " • {confirm:true, sessionId:\"<uuid>\"} → wipes ONLY that session's events + chunks; preserves stats and other sessions\n" +
181
+ " • {confirm:true, scope:\"project\"} → wipes ENTIRE project: FTS5 KB + every session DB + stats file\n\n" +
182
+ "REFUSED:\n" +
183
+ " • confirm:false → 'purge cancelled'\n" +
184
+ " • sessionId AND scope:\"project\" together → 'ambiguous — pick one'\n" +
185
+ " • scope:\"session\" without sessionId → throws\n" +
186
+ " • bare {confirm:true} → DEPRECATED: maps to scope:\"project\" with stderr warning\n\n" +
187
+ "Use sessionId for clearing one conversation. Use scope:\"project\" only when the user explicitly resets everything. NEVER call with bare {confirm:true}.",
179
188
  parameters: {
180
189
  type: "object",
181
190
  properties: {},
package/build/server.js CHANGED
@@ -2820,16 +2820,50 @@ server.registerTool("ctx_upgrade", {
2820
2820
  });
2821
2821
  });
2822
2822
  // ── ctx-purge: explicit knowledge base wipe ─────────────────────────────────
2823
+ //
2824
+ // Issue #520 — scoped purge.
2825
+ // The schema is ADDITIVE: bare {confirm:true} preserves the legacy
2826
+ // project-wide wipe verbatim (with a stderr deprecation warning so
2827
+ // future callers migrate to explicit scope). When sessionId is given,
2828
+ // only that session's rows + FTS5 chunks are removed; project-wide
2829
+ // files (events.md, FTS5 store file, stats file) are preserved.
2830
+ // Passing both sessionId AND scope:"project" is ambiguous (does the
2831
+ // caller want a per-session wipe or a project-wide one?) and is
2832
+ // rejected by the schema's refine().
2823
2833
  server.registerTool("ctx_purge", {
2824
2834
  title: "Purge Knowledge Base",
2825
- description: "Permanently deletes ALL session data for this project: " +
2826
- "FTS5 knowledge base (indexed content), session events DB (analytics, metadata, " +
2827
- "resume snapshots), and session events markdown. Resets in-memory stats. " +
2828
- "This is irreversible.",
2835
+ description: "DESTRUCTIVE permanently delete indexed content. CANNOT be undone.\n\n" +
2836
+ "You MUST specify exactly ONE scope:\n\n" +
2837
+ " { confirm: true, sessionId: \"<uuid>\" }\n" +
2838
+ " Deletes ONLY that session's events + per-session FTS5 chunks.\n" +
2839
+ " Preserves stats file and ALL other sessions.\n\n" +
2840
+ " • { confirm: true, scope: \"project\" }\n" +
2841
+ " Wipes the ENTIRE project: FTS5 knowledge base, every session DB row,\n" +
2842
+ " events markdown, AND resets the stats file.\n\n" +
2843
+ "REFUSAL RULES (tool returns an error):\n" +
2844
+ " • confirm: false → 'purge cancelled'\n" +
2845
+ " • Both sessionId AND scope:'project' provided → 'ambiguous — pick one'\n" +
2846
+ " • scope:'session' without sessionId → throws (sessionId required)\n" +
2847
+ " • Neither sessionId NOR scope provided → DEPRECATED: maps to\n" +
2848
+ " scope:'project' with a deprecation warning to stderr. Will be a hard\n" +
2849
+ " error in a future major.\n\n" +
2850
+ "Use sessionId when the user asks to clear a specific conversation's data.\n" +
2851
+ "Use scope:'project' ONLY when the user explicitly asks to reset everything.\n" +
2852
+ "NEVER call with bare {confirm:true} — always specify the scope.",
2829
2853
  inputSchema: z.object({
2830
- confirm: z.boolean().describe("Must be true to confirm the destructive operation."),
2854
+ confirm: z.boolean().describe("MUST be true. Destructive operation; false returns 'purge cancelled'."),
2855
+ sessionId: z.string().optional().describe("UUID of a single session. Pairs with confirm:true to wipe only that " +
2856
+ "session's events + per-session FTS5 chunks. Sibling sessions and the " +
2857
+ "stats file are preserved. MUST NOT be combined with scope:'project'."),
2858
+ scope: z.enum(["session", "project"]).optional().describe("Explicit scope selector. 'session' REQUIRES sessionId. 'project' wipes " +
2859
+ "the entire project (FTS5 + every session + stats). Omit only for the " +
2860
+ "deprecated bare-{confirm:true} back-compat path."),
2861
+ }).refine((v) => !(v.sessionId && v.scope === "project"), {
2862
+ message: "Ambiguous purge: sessionId implies scope:'session', cannot combine with scope:'project'. " +
2863
+ "Use scope:'project' WITHOUT sessionId for the legacy whole-project wipe.",
2864
+ path: ["scope"],
2831
2865
  }),
2832
- }, async ({ confirm }) => {
2866
+ }, async ({ confirm, sessionId, scope }) => {
2833
2867
  if (!confirm) {
2834
2868
  return trackResponse("ctx_purge", {
2835
2869
  content: [{
@@ -2838,6 +2872,17 @@ server.registerTool("ctx_purge", {
2838
2872
  }],
2839
2873
  });
2840
2874
  }
2875
+ // Effective scope resolution:
2876
+ // - explicit scope wins
2877
+ // - else "session" iff sessionId is given
2878
+ // - else "project" (back-compat — emit deprecation warning so
2879
+ // callers migrate to the explicit form before a future major).
2880
+ const effectiveScope = scope ?? (sessionId ? "session" : "project");
2881
+ if (!scope && !sessionId) {
2882
+ console.warn("[context-mode] ctx_purge: bare {confirm:true} is deprecated. " +
2883
+ "Pass scope:'project' for the whole-project wipe, or scope:'session' + sessionId " +
2884
+ "for a scoped wipe. See issue #520.");
2885
+ }
2841
2886
  // Close the persistent FTS5 content store handle BEFORE delegating to
2842
2887
  // purgeSession so the store's lock is released on Windows. The handle
2843
2888
  // is recreated lazily on the next getStore() call.
@@ -2870,27 +2915,39 @@ server.registerTool("ctx_purge", {
2870
2915
  // legacy hash here is correct: that pre-pre-legacy directory was
2871
2916
  // never migrated and still uses raw casing.
2872
2917
  contentHash: hashProjectDirLegacy(getProjectDir()),
2918
+ scope: effectiveScope,
2919
+ sessionId,
2873
2920
  });
2874
- // Reset in-memory session stats
2875
- sessionStats.calls = {};
2876
- sessionStats.bytesReturned = {};
2877
- sessionStats.bytesIndexed = 0;
2878
- sessionStats.bytesSandboxed = 0;
2879
- sessionStats.cacheHits = 0;
2880
- sessionStats.cacheBytesSaved = 0;
2881
- sessionStats.sessionStart = Date.now();
2882
- deleted.push("session stats");
2883
- // Also drop the persisted stats file so external readers see a fresh state
2884
- try {
2885
- const statsFile = getStatsFilePath();
2886
- if (existsSync(statsFile))
2887
- unlinkSync(statsFile);
2921
+ // Stats are PROJECT-scoped (one stats file per project, summing all
2922
+ // sessions). A scoped per-session purge MUST leave stats alone — they
2923
+ // still belong to other sessions in the same project. Stats reset
2924
+ // happens ONLY when scope === "project".
2925
+ if (effectiveScope === "project") {
2926
+ // Reset in-memory session stats
2927
+ sessionStats.calls = {};
2928
+ sessionStats.bytesReturned = {};
2929
+ sessionStats.bytesIndexed = 0;
2930
+ sessionStats.bytesSandboxed = 0;
2931
+ sessionStats.cacheHits = 0;
2932
+ sessionStats.cacheBytesSaved = 0;
2933
+ sessionStats.sessionStart = Date.now();
2934
+ deleted.push("session stats");
2935
+ // Also drop the persisted stats file so external readers see a fresh state
2936
+ try {
2937
+ const statsFile = getStatsFilePath();
2938
+ if (existsSync(statsFile))
2939
+ unlinkSync(statsFile);
2940
+ }
2941
+ catch { /* best effort */ }
2888
2942
  }
2889
- catch { /* best effort */ }
2943
+ const message = effectiveScope === "session"
2944
+ ? `Purged session ${sessionId}: ${deleted.length ? deleted.join(", ") : "no matching rows"}. ` +
2945
+ `Other sessions and project-wide stats preserved.`
2946
+ : `Purged: ${deleted.join(", ")}. All session data for this project has been permanently deleted.`;
2890
2947
  return trackResponse("ctx_purge", {
2891
2948
  content: [{
2892
2949
  type: "text",
2893
- text: `Purged: ${deleted.join(", ")}. All session data for this project has been permanently deleted.`,
2950
+ text: message,
2894
2951
  }],
2895
2952
  });
2896
2953
  });
@@ -84,6 +84,33 @@ export interface PurgeOpts {
84
84
  * session DB hash.
85
85
  */
86
86
  contentHash?: string;
87
+ /**
88
+ * Issue #520 — scoped purge.
89
+ *
90
+ * - `"project"` (default when omitted for back-compat callers that
91
+ * only pass `confirm:true` at the MCP layer): wipe ALL session
92
+ * artifacts for `projectDir`. This is the legacy destructive
93
+ * behavior preserved verbatim.
94
+ * - `"session"`: wipe ONLY the rows for `sessionId` inside the
95
+ * project's SessionDB plus FTS5 chunks tagged with that
96
+ * `session_id`. Project-wide files (events.md, content store
97
+ * file, stats file) are left intact. Requires `sessionId`.
98
+ *
99
+ * When `scope` is omitted but `sessionId` is set, behavior implies
100
+ * `scope:"session"` (a sessionId-only call cannot mean "wipe the
101
+ * whole project"). When neither is set, behavior implies
102
+ * `scope:"project"` for back-compat with the original handler.
103
+ */
104
+ scope?: "session" | "project";
105
+ /**
106
+ * Session identifier whose rows should be wiped from the project's
107
+ * SessionDB and tagged FTS5 chunks. Only consulted when `scope ===
108
+ * "session"`. The `session_events`, `session_meta`, and
109
+ * `session_resume` rows for this id are removed; rows for other
110
+ * sessions in the same DB are preserved. Match SessionDB.deleteSession
111
+ * semantics (see src/session/db.ts).
112
+ */
113
+ sessionId?: string;
87
114
  }
88
115
  export interface PurgeResult {
89
116
  /**
@@ -31,9 +31,10 @@
31
31
  * session-related kinds. On Linux the two hashes coincide, so the dual
32
32
  * sweep collapses into a single unique-path pass.
33
33
  */
34
- import { unlinkSync } from "node:fs";
34
+ import { existsSync, unlinkSync } from "node:fs";
35
35
  import { join } from "node:path";
36
- import { getWorktreeSuffix, hashProjectDirCanonical, hashProjectDirLegacy, } from "./db.js";
36
+ import { loadDatabase } from "../db-base.js";
37
+ import { getWorktreeSuffix, hashProjectDirCanonical, hashProjectDirLegacy, SessionDB, } from "./db.js";
37
38
  /** Canonical SQLite sidecar suffixes. The empty string is the main DB. */
38
39
  const SQLITE_SIDECARS = ["", "-wal", "-shm"];
39
40
  /** Try to unlink one path; report success without throwing on ENOENT etc. */
@@ -68,9 +69,110 @@ function tryUnlinkSqliteTriple(path, wipedPaths) {
68
69
  * without `contentHash`), which is a programmer bug not a runtime concern.
69
70
  */
70
71
  export function purgeSession(opts) {
71
- const { projectDir, sessionsDir, storePath, contentDir, legacyContentDir, contentHash } = opts;
72
+ const { projectDir, sessionsDir, storePath, contentDir, legacyContentDir, contentHash, sessionId, scope } = opts;
72
73
  const deleted = [];
73
74
  const wipedPaths = [];
75
+ // Issue #520 — scope discipline.
76
+ // Resolve effective scope: explicit `scope` wins; otherwise infer
77
+ // "session" iff sessionId is given, else "project".
78
+ const effectiveScope = scope ?? (sessionId ? "session" : "project");
79
+ if (effectiveScope === "session" && !sessionId) {
80
+ throw new TypeError("purgeSession: scope:'session' requires sessionId. " +
81
+ "Pass scope:'project' for the legacy whole-project wipe.");
82
+ }
83
+ // ── Session-scoped path (issue #520). ─────────────────────────────────
84
+ // Wipe ONLY this sessionId's rows from the project's SessionDB. The DB
85
+ // file itself, the events.md sidecar, the FTS5 store, and the stats
86
+ // file are all left intact — those are project-scoped concerns. The
87
+ // label "session rows for <id>" appears once when at least one row was
88
+ // removed, mirroring the project-scoped UI contract.
89
+ if (effectiveScope === "session" && sessionId) {
90
+ const worktreeSuffix = getWorktreeSuffix(projectDir);
91
+ const canonicalHash = hashProjectDirCanonical(projectDir);
92
+ const legacyHash = hashProjectDirLegacy(projectDir);
93
+ const hashes = canonicalHash === legacyHash
94
+ ? [canonicalHash]
95
+ : [canonicalHash, legacyHash];
96
+ let rowsRemoved = false;
97
+ for (const h of hashes) {
98
+ const dbPath = join(sessionsDir, `${h}${worktreeSuffix}.db`);
99
+ if (!existsSync(dbPath))
100
+ continue;
101
+ let db = null;
102
+ try {
103
+ db = new SessionDB({ dbPath });
104
+ const before = db.getEvents(sessionId).length;
105
+ db.deleteSession(sessionId);
106
+ if (before > 0)
107
+ rowsRemoved = true;
108
+ }
109
+ catch {
110
+ // Best-effort — corrupt DB is logged elsewhere; do not block purge.
111
+ }
112
+ finally {
113
+ // close() releases the handle WITHOUT deleting the file —
114
+ // this is what makes the scoped wipe non-destructive at the
115
+ // file-system level. Using cleanup() here would erase the
116
+ // entire DB (main + WAL + SHM), defeating per-session scope.
117
+ try {
118
+ db?.close();
119
+ }
120
+ catch { /* best effort */ }
121
+ }
122
+ }
123
+ if (rowsRemoved)
124
+ deleted.push(`session rows for ${sessionId}`);
125
+ // Per-session FTS5 chunk wipe. The chunks table has a `session_id
126
+ // UNINDEXED` column (src/store.ts schema). The public index() path
127
+ // currently inserts NULL — but a future per-session-tagged path
128
+ // (e.g. tool-call indexing keyed to a session) will populate it,
129
+ // and the SQL contract here keeps that future correct from day one.
130
+ // Today this is a safe no-op against existing data.
131
+ //
132
+ // Caller is responsible for closing any persistent ContentStore
133
+ // handle BEFORE invoking purgeSession (Windows file lock). The
134
+ // ctx_purge handler does this via _store?.cleanup() before delegating.
135
+ const ftsTargets = [];
136
+ if (storePath && existsSync(storePath))
137
+ ftsTargets.push(storePath);
138
+ if (contentDir) {
139
+ const canonicalH = hashProjectDirCanonical(projectDir);
140
+ const legacyH = hashProjectDirLegacy(projectDir);
141
+ const hh = canonicalH === legacyH ? [canonicalH] : [canonicalH, legacyH];
142
+ for (const h of hh) {
143
+ const p = join(contentDir, `${h}.db`);
144
+ if (existsSync(p) && !ftsTargets.includes(p))
145
+ ftsTargets.push(p);
146
+ }
147
+ }
148
+ let chunksRemoved = false;
149
+ for (const path of ftsTargets) {
150
+ try {
151
+ const Database = loadDatabase();
152
+ const fts = new Database(path, { timeout: 30000 });
153
+ try {
154
+ const before = fts.prepare("SELECT COUNT(*) AS c FROM chunks WHERE session_id = ?").get(sessionId).c;
155
+ fts.prepare("DELETE FROM chunks WHERE session_id = ?").run(sessionId);
156
+ fts.prepare("DELETE FROM chunks_trigram WHERE session_id = ?").run(sessionId);
157
+ if (before > 0)
158
+ chunksRemoved = true;
159
+ }
160
+ finally {
161
+ try {
162
+ fts.close();
163
+ }
164
+ catch { /* best effort */ }
165
+ }
166
+ }
167
+ catch {
168
+ // Best-effort — schema mismatch / corrupt DB / missing FTS5 must not
169
+ // block the per-session SessionDB wipe that already succeeded.
170
+ }
171
+ }
172
+ if (chunksRemoved)
173
+ deleted.push(`FTS5 chunks for ${sessionId}`);
174
+ return { deleted, wipedPaths };
175
+ }
74
176
  // ── 1. Knowledge base FTS5 store (per-platform). ──────────────────────
75
177
  // Two input modes:
76
178
  // - `storePath`: single absolute path; pre-resolved by caller. Wipes