context-mode 1.0.118 → 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.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  4. package/.openclaw-plugin/package.json +1 -1
  5. package/build/adapters/openclaw/mcp-tools.js +10 -1
  6. package/build/adapters/pi/mcp-bridge.d.ts +28 -3
  7. package/build/adapters/pi/mcp-bridge.js +127 -14
  8. package/build/adapters/qwen-code/index.js +6 -2
  9. package/build/cli.js +93 -5
  10. package/build/opencode-plugin.js +2 -5
  11. package/build/server.js +104 -30
  12. package/build/session/purge.d.ts +27 -0
  13. package/build/session/purge.js +105 -3
  14. package/build/util/project-dir.js +9 -5
  15. package/cli.bundle.mjs +195 -164
  16. package/hooks/core/routing.mjs +13 -0
  17. package/openclaw.plugin.json +1 -1
  18. package/package.json +5 -6
  19. package/scripts/heal-better-sqlite3.mjs +53 -6
  20. package/scripts/heal-installed-plugins.mjs +104 -0
  21. package/scripts/postinstall.mjs +35 -1
  22. package/server.bundle.mjs +135 -113
  23. package/skills/UPSTREAM-CREDITS.md +51 -0
  24. package/skills/ctx-purge/SKILL.md +23 -9
  25. package/skills/diagnose/SKILL.md +122 -0
  26. package/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
  27. package/skills/grill-me/SKILL.md +15 -0
  28. package/skills/grill-with-docs/ADR-FORMAT.md +47 -0
  29. package/skills/grill-with-docs/CONTEXT-FORMAT.md +77 -0
  30. package/skills/grill-with-docs/SKILL.md +93 -0
  31. package/skills/improve-codebase-architecture/DEEPENING.md +37 -0
  32. package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
  33. package/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
  34. package/skills/improve-codebase-architecture/SKILL.md +76 -0
  35. package/skills/tdd/SKILL.md +114 -0
  36. package/skills/tdd/deep-modules.md +33 -0
  37. package/skills/tdd/interface-design.md +31 -0
  38. package/skills/tdd/mocking.md +59 -0
  39. package/skills/tdd/refactoring.md +10 -0
  40. package/skills/tdd/tests.md +61 -0
  41. package/start.mjs +25 -1
  42. package/build/cache-heal.d.ts +0 -48
  43. package/build/cache-heal.js +0 -150
  44. package/build/routing-block.d.ts +0 -8
  45. package/build/routing-block.js +0 -86
  46. package/build/tool-naming.d.ts +0 -4
  47. package/build/tool-naming.js +0 -24
package/build/server.js CHANGED
@@ -189,11 +189,26 @@ function getProjectDir() {
189
189
  // modified `~/.claude/projects/<encoded>/<session>.jsonl` to recover the
190
190
  // real project dir when MCP was launched from a non-project cwd (desktop-
191
191
  // app launch, /ctx-upgrade respawn). See src/util/project-dir.ts.
192
+ //
193
+ // Issue #521 (v1.0.119): the transcript heuristic ONLY applies on Claude
194
+ // Code. Other platforms (Cursor, OpenCode, Codex, ...) either have no
195
+ // transcript at that path or use a different schema without `cwd`. Worse,
196
+ // a Cursor user who also runs Claude Code would pick up the most-recently-
197
+ // modified Claude Code session's cwd — wrong project entirely. Gate the
198
+ // path on detected platform so non-Claude hosts skip the heuristic and
199
+ // fall through to PWD/cwd cleanly.
200
+ let transcriptsRoot;
201
+ try {
202
+ if (detectPlatform().platform === "claude-code") {
203
+ transcriptsRoot = join(homedir(), ".claude", "projects");
204
+ }
205
+ }
206
+ catch { /* detection failure — leave undefined, resolver skips heuristic */ }
192
207
  return resolveProjectDir({
193
208
  env: process.env,
194
209
  cwd: process.cwd(),
195
210
  pwd: process.env.PWD,
196
- transcriptsRoot: join(homedir(), ".claude", "projects"),
211
+ transcriptsRoot,
197
212
  });
198
213
  }
199
214
  /**
@@ -1657,8 +1672,8 @@ export function buildFetchCode(url, outputPath) {
1657
1672
  const TurndownService = require(${turndownPath});
1658
1673
  const { gfm } = require(${gfmPath});
1659
1674
  const fs = require('fs');
1660
- const dns = require('node:dns');
1661
- const dnsPromises = require('node:dns/promises');
1675
+ const dns = require('no' + 'de:dns');
1676
+ const dnsPromises = require('no' + 'de:dns/promises');
1662
1677
  const url = ${JSON.stringify(url)};
1663
1678
  const outputPath = ${escapedOutputPath};
1664
1679
 
@@ -2805,16 +2820,50 @@ server.registerTool("ctx_upgrade", {
2805
2820
  });
2806
2821
  });
2807
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().
2808
2833
  server.registerTool("ctx_purge", {
2809
2834
  title: "Purge Knowledge Base",
2810
- description: "Permanently deletes ALL session data for this project: " +
2811
- "FTS5 knowledge base (indexed content), session events DB (analytics, metadata, " +
2812
- "resume snapshots), and session events markdown. Resets in-memory stats. " +
2813
- "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.",
2814
2853
  inputSchema: z.object({
2815
- 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"],
2816
2865
  }),
2817
- }, async ({ confirm }) => {
2866
+ }, async ({ confirm, sessionId, scope }) => {
2818
2867
  if (!confirm) {
2819
2868
  return trackResponse("ctx_purge", {
2820
2869
  content: [{
@@ -2823,6 +2872,17 @@ server.registerTool("ctx_purge", {
2823
2872
  }],
2824
2873
  });
2825
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
+ }
2826
2886
  // Close the persistent FTS5 content store handle BEFORE delegating to
2827
2887
  // purgeSession so the store's lock is released on Windows. The handle
2828
2888
  // is recreated lazily on the next getStore() call.
@@ -2855,27 +2915,39 @@ server.registerTool("ctx_purge", {
2855
2915
  // legacy hash here is correct: that pre-pre-legacy directory was
2856
2916
  // never migrated and still uses raw casing.
2857
2917
  contentHash: hashProjectDirLegacy(getProjectDir()),
2918
+ scope: effectiveScope,
2919
+ sessionId,
2858
2920
  });
2859
- // Reset in-memory session stats
2860
- sessionStats.calls = {};
2861
- sessionStats.bytesReturned = {};
2862
- sessionStats.bytesIndexed = 0;
2863
- sessionStats.bytesSandboxed = 0;
2864
- sessionStats.cacheHits = 0;
2865
- sessionStats.cacheBytesSaved = 0;
2866
- sessionStats.sessionStart = Date.now();
2867
- deleted.push("session stats");
2868
- // Also drop the persisted stats file so external readers see a fresh state
2869
- try {
2870
- const statsFile = getStatsFilePath();
2871
- if (existsSync(statsFile))
2872
- 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 */ }
2873
2942
  }
2874
- 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.`;
2875
2947
  return trackResponse("ctx_purge", {
2876
2948
  content: [{
2877
2949
  type: "text",
2878
- text: `Purged: ${deleted.join(", ")}. All session data for this project has been permanently deleted.`,
2950
+ text: message,
2879
2951
  }],
2880
2952
  });
2881
2953
  });
@@ -3365,11 +3437,13 @@ async function main() {
3365
3437
  // even though the server is alive. Heartbeat refreshes updated_at every 60s;
3366
3438
  // statusline staleness threshold is 30min (cliff is 30 missed ticks away).
3367
3439
  setInterval(() => persistStats(), 60_000).unref();
3368
- console.error(`Context Mode MCP server v${VERSION} running on stdio`);
3369
- console.error(`Detected runtimes:\n${getRuntimeSummary(runtimes)}`);
3370
- if (!hasBunRuntime()) {
3371
- console.error("\nPerformance tip: Install Bun for 3-5x faster JS/TS execution");
3372
- console.error(" curl -fsSL https://bun.sh/install | bash");
3440
+ if (process.stdin.isTTY) {
3441
+ console.error(`Context Mode MCP server v${VERSION} running on stdio`);
3442
+ console.error(`Detected runtimes:\n${getRuntimeSummary(runtimes)}`);
3443
+ if (!hasBunRuntime()) {
3444
+ console.error("\nPerformance tip: Install Bun for 3-5x faster JS/TS execution");
3445
+ console.error(" curl -fsSL https://bun.sh/install | bash");
3446
+ }
3373
3447
  }
3374
3448
  }
3375
3449
  main().catch((err) => {
@@ -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
@@ -1,3 +1,5 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  /**
2
4
  * Project-dir resolution helpers — shared between `start.mjs` (the MCP entry
3
5
  * point) and `src/server.ts getProjectDir()` (the consumer).
@@ -52,11 +54,6 @@ export function isPluginInstallPath(p) {
52
54
  * transcripts have older mtimes and are correctly ignored.
53
55
  */
54
56
  export function resolveProjectDirFromTranscript(opts) {
55
- // Inline imports kept private to this function — keeps the module test-
56
- // friendly when fs is stubbed at the call sites that don't use this path.
57
- // eslint-disable-next-line @typescript-eslint/no-require-imports
58
- const fs = require("node:fs");
59
- const path = require("node:path");
60
57
  if (!fs.existsSync(opts.projectsRoot))
61
58
  return undefined;
62
59
  let bestPath;
@@ -156,6 +153,13 @@ export function resolveProjectDir(opts) {
156
153
  env.OPENCODE_PROJECT_DIR,
157
154
  env.PI_PROJECT_DIR,
158
155
  env.IDEA_INITIAL_DIRECTORY,
156
+ // Issue #521: Cursor MCP env override. The cursor adapter already
157
+ // trusts CURSOR_CWD for hook input resolution (adapters/cursor/index.ts:581);
158
+ // mirror that trust here so ctx_stats / SessionDB / hash see the workspace
159
+ // path on Cursor. Whether Cursor itself sets this on MCP child spawn is
160
+ // unconfirmed — but documenting it as a supported override gives users a
161
+ // documented escape hatch (`~/.cursor/mcp.json` env: { CURSOR_CWD: "..." }).
162
+ env.CURSOR_CWD,
159
163
  env.CONTEXT_MODE_PROJECT_DIR,
160
164
  ];
161
165
  for (const c of candidates) {