context-mode 1.0.162 → 1.0.164

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 (149) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  5. package/.openclaw-plugin/package.json +1 -1
  6. package/README.md +149 -30
  7. package/bin/statusline.mjs +24 -4
  8. package/build/adapters/antigravity/index.d.ts +1 -1
  9. package/build/adapters/antigravity-cli/index.d.ts +51 -0
  10. package/build/adapters/antigravity-cli/index.js +342 -0
  11. package/build/adapters/claude-code/hooks.d.ts +1 -0
  12. package/build/adapters/claude-code/hooks.js +3 -0
  13. package/build/adapters/claude-code/index.js +24 -5
  14. package/build/adapters/client-map.js +5 -0
  15. package/build/adapters/codex/hooks.d.ts +5 -1
  16. package/build/adapters/codex/hooks.js +5 -1
  17. package/build/adapters/codex/index.d.ts +9 -1
  18. package/build/adapters/codex/index.js +87 -5
  19. package/build/adapters/copilot-cli/hooks.d.ts +33 -0
  20. package/build/adapters/copilot-cli/hooks.js +64 -0
  21. package/build/adapters/copilot-cli/index.d.ts +48 -0
  22. package/build/adapters/copilot-cli/index.js +341 -0
  23. package/build/adapters/detect.d.ts +1 -1
  24. package/build/adapters/detect.js +71 -3
  25. package/build/adapters/openclaw/mcp-tools.js +1 -1
  26. package/build/adapters/opencode/index.js +31 -17
  27. package/build/adapters/opencode/zod3tov4.js +27 -6
  28. package/build/adapters/pi/extension.d.ts +2 -12
  29. package/build/adapters/pi/extension.js +128 -109
  30. package/build/adapters/types.d.ts +5 -4
  31. package/build/adapters/types.js +4 -3
  32. package/build/cache-heal.d.ts +48 -0
  33. package/build/cache-heal.js +150 -0
  34. package/build/cli.js +37 -97
  35. package/build/executor.d.ts +25 -0
  36. package/build/executor.js +143 -22
  37. package/build/lifecycle.d.ts +48 -0
  38. package/build/lifecycle.js +111 -0
  39. package/build/opencode-plugin.js +5 -2
  40. package/build/routing-block.d.ts +8 -0
  41. package/build/routing-block.js +86 -0
  42. package/build/runtime.d.ts +0 -36
  43. package/build/runtime.js +107 -27
  44. package/build/search/flood-guard.d.ts +57 -0
  45. package/build/search/flood-guard.js +80 -0
  46. package/build/security.d.ts +73 -3
  47. package/build/security.js +293 -33
  48. package/build/server.d.ts +14 -0
  49. package/build/server.js +441 -354
  50. package/build/session/analytics.d.ts +1 -1
  51. package/build/session/analytics.js +5 -1
  52. package/build/session/db.js +23 -3
  53. package/build/session/extract.js +78 -0
  54. package/build/store.d.ts +1 -1
  55. package/build/store.js +139 -25
  56. package/build/tool-naming.d.ts +4 -0
  57. package/build/tool-naming.js +24 -0
  58. package/build/util/jsonc.d.ts +14 -0
  59. package/build/util/jsonc.js +104 -0
  60. package/cli.bundle.mjs +253 -250
  61. package/configs/antigravity/GEMINI.md +2 -2
  62. package/configs/antigravity-cli/hooks/hooks.json +37 -0
  63. package/configs/antigravity-cli/hooks.json +37 -0
  64. package/configs/antigravity-cli/mcp_config.json +10 -0
  65. package/configs/antigravity-cli/plugin.json +14 -0
  66. package/configs/antigravity-cli/rules/context-mode.md +77 -0
  67. package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
  68. package/configs/claude-code/CLAUDE.md +2 -2
  69. package/configs/codex/AGENTS.md +2 -2
  70. package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
  71. package/configs/copilot-cli/.mcp.json +12 -0
  72. package/configs/copilot-cli/README.md +47 -0
  73. package/configs/copilot-cli/hooks.json +41 -0
  74. package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
  75. package/configs/gemini-cli/GEMINI.md +2 -2
  76. package/configs/jetbrains-copilot/copilot-instructions.md +2 -2
  77. package/configs/kilo/AGENTS.md +2 -2
  78. package/configs/kiro/KIRO.md +2 -2
  79. package/configs/omp/SYSTEM.md +2 -2
  80. package/configs/openclaw/AGENTS.md +2 -2
  81. package/configs/opencode/AGENTS.md +2 -2
  82. package/configs/qwen-code/QWEN.md +2 -2
  83. package/configs/vscode-copilot/copilot-instructions.md +2 -2
  84. package/configs/zed/AGENTS.md +2 -2
  85. package/hooks/antigravity-cli/payload.mjs +98 -0
  86. package/hooks/antigravity-cli/posttooluse.mjs +138 -0
  87. package/hooks/antigravity-cli/pretooluse.mjs +78 -0
  88. package/hooks/antigravity-cli/stop.mjs +58 -0
  89. package/hooks/codex/pretooluse.mjs +14 -4
  90. package/hooks/codex/stop.mjs +12 -4
  91. package/hooks/copilot-cli/posttooluse.mjs +79 -0
  92. package/hooks/copilot-cli/precompact.mjs +66 -0
  93. package/hooks/copilot-cli/pretooluse.mjs +41 -0
  94. package/hooks/copilot-cli/sessionstart.mjs +121 -0
  95. package/hooks/copilot-cli/stop.mjs +59 -0
  96. package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
  97. package/hooks/core/codex-caps.mjs +112 -0
  98. package/hooks/core/formatters.mjs +158 -7
  99. package/hooks/core/mcp-ready.mjs +37 -8
  100. package/hooks/core/routing.mjs +94 -8
  101. package/hooks/core/tool-naming.mjs +3 -0
  102. package/hooks/hooks.json +12 -1
  103. package/hooks/pretooluse.mjs +6 -2
  104. package/hooks/routing-block.mjs +3 -4
  105. package/hooks/security.bundle.mjs +2 -1
  106. package/hooks/session-db.bundle.mjs +5 -5
  107. package/hooks/session-directive.mjs +88 -20
  108. package/hooks/session-extract.bundle.mjs +2 -2
  109. package/hooks/session-helpers.mjs +21 -0
  110. package/hooks/sessionstart.mjs +37 -5
  111. package/hooks/stop.mjs +49 -0
  112. package/openclaw.plugin.json +1 -1
  113. package/package.json +2 -10
  114. package/server.bundle.mjs +206 -200
  115. package/skills/ctx-insight/SKILL.md +12 -17
  116. package/build/util/db-lock.d.ts +0 -65
  117. package/build/util/db-lock.js +0 -166
  118. package/insight/index.html +0 -13
  119. package/insight/package.json +0 -55
  120. package/insight/server.mjs +0 -1265
  121. package/insight/src/components/analytics.tsx +0 -112
  122. package/insight/src/components/ui/badge.tsx +0 -52
  123. package/insight/src/components/ui/button.tsx +0 -58
  124. package/insight/src/components/ui/card.tsx +0 -103
  125. package/insight/src/components/ui/chart.tsx +0 -371
  126. package/insight/src/components/ui/collapsible.tsx +0 -19
  127. package/insight/src/components/ui/input.tsx +0 -20
  128. package/insight/src/components/ui/progress.tsx +0 -83
  129. package/insight/src/components/ui/scroll-area.tsx +0 -55
  130. package/insight/src/components/ui/separator.tsx +0 -23
  131. package/insight/src/components/ui/table.tsx +0 -114
  132. package/insight/src/components/ui/tabs.tsx +0 -82
  133. package/insight/src/components/ui/tooltip.tsx +0 -64
  134. package/insight/src/lib/api.ts +0 -144
  135. package/insight/src/lib/utils.ts +0 -6
  136. package/insight/src/main.tsx +0 -22
  137. package/insight/src/routeTree.gen.ts +0 -189
  138. package/insight/src/router.tsx +0 -19
  139. package/insight/src/routes/__root.tsx +0 -55
  140. package/insight/src/routes/enterprise.tsx +0 -316
  141. package/insight/src/routes/index.tsx +0 -1482
  142. package/insight/src/routes/knowledge.tsx +0 -221
  143. package/insight/src/routes/knowledge_.$dbHash.$sourceId.tsx +0 -137
  144. package/insight/src/routes/search.tsx +0 -97
  145. package/insight/src/routes/sessions.tsx +0 -179
  146. package/insight/src/routes/sessions_.$dbHash.$sessionId.tsx +0 -181
  147. package/insight/src/styles.css +0 -104
  148. package/insight/tsconfig.json +0 -29
  149. package/insight/vite.config.ts +0 -19
@@ -278,7 +278,7 @@ export interface AdapterDirEntry {
278
278
  * so a single call surfaces "your work everywhere on this machine across
279
279
  * all AI tools" (the marketing line).
280
280
  *
281
- * Returns ALL 15 adapters even when the dir doesn't exist on disk — the
281
+ * Returns ALL 17 adapters even when the dir doesn't exist on disk — the
282
282
  * scanner functions filter to existing dirs. That keeps the enumeration
283
283
  * pure / testable without filesystem dependencies.
284
284
  */
@@ -359,7 +359,7 @@ export class AnalyticsEngine {
359
359
  * so a single call surfaces "your work everywhere on this machine across
360
360
  * all AI tools" (the marketing line).
361
361
  *
362
- * Returns ALL 15 adapters even when the dir doesn't exist on disk — the
362
+ * Returns ALL 17 adapters even when the dir doesn't exist on disk — the
363
363
  * scanner functions filter to existing dirs. That keeps the enumeration
364
364
  * pure / testable without filesystem dependencies.
365
365
  */
@@ -370,10 +370,12 @@ export function enumerateAdapterDirs(opts) {
370
370
  ["claude-code", [".claude"]],
371
371
  ["gemini-cli", [".gemini"]],
372
372
  ["antigravity", [".gemini"]],
373
+ ["antigravity-cli", [".gemini"]],
373
374
  ["openclaw", [".openclaw"]],
374
375
  ["codex", [".codex"]],
375
376
  ["cursor", [".cursor"]],
376
377
  ["vscode-copilot", [".vscode"]],
378
+ ["copilot-cli", [".copilot"]],
377
379
  ["kiro", [".kiro"]],
378
380
  ["pi", [".pi"]],
379
381
  ["omp", [".omp"]],
@@ -1188,10 +1190,12 @@ export const adapterLabels = {
1188
1190
  "claude-code": "Claude Code",
1189
1191
  "gemini-cli": "Gemini CLI",
1190
1192
  "antigravity": "Antigravity",
1193
+ "antigravity-cli": "Antigravity CLI",
1191
1194
  "openclaw": "Openclaw",
1192
1195
  "codex": "Codex CLI",
1193
1196
  "cursor": "Cursor",
1194
1197
  "vscode-copilot": "VS Code Copilot",
1198
+ "copilot-cli": "GitHub Copilot CLI",
1195
1199
  "kiro": "Kiro",
1196
1200
  "pi": "Pi",
1197
1201
  "omp": "OMP",
@@ -899,7 +899,12 @@ export class SessionDB extends SQLiteBase {
899
899
  .slice(0, 16)
900
900
  .toUpperCase();
901
901
  const attribution = attributions?.[i];
902
- const projectDir = String(attribution?.projectDir ?? event.project_dir ?? this._getSessionProjectDir(sessionId) ?? "").trim();
902
+ // #827: store project_dir in canonical path shape so the search-time
903
+ // allow-set lookup (getSessionIdsForProject) matches regardless of the
904
+ // separator / trailing-slash form the host adapter happened to emit.
905
+ // normalizeWorktreePath is the same rule used for project-hash stability.
906
+ const rawProjectDir = String(attribution?.projectDir ?? event.project_dir ?? this._getSessionProjectDir(sessionId) ?? "").trim();
907
+ const projectDir = rawProjectDir === "" ? "" : normalizeWorktreePath(rawProjectDir);
903
908
  const attributionSource = String(attribution?.source ?? event.attribution_source ?? "unknown");
904
909
  const rawConfidence = Number(attribution?.confidence ?? event.attribution_confidence ?? 0);
905
910
  const attributionConfidence = Number.isFinite(rawConfidence)
@@ -1031,11 +1036,26 @@ export class SessionDB extends SQLiteBase {
1031
1036
  */
1032
1037
  getSessionIdsForProject(projectDir) {
1033
1038
  try {
1039
+ // #827: match by canonical path shape, not raw bytes. The host adapter
1040
+ // may store `project_dir` in a different separator / trailing-slash
1041
+ // shape than the search path resolves the scope in — most visibly on
1042
+ // Windows, where attribution often carries `C:\Users\me\proj` while the
1043
+ // server resolves `C:/Users/me/proj`. An exact `project_dir = ?` match
1044
+ // then returned an EMPTY allow-set and ctx_search reported "No results
1045
+ // found" even though the content was present. We fold BOTH sides through
1046
+ // the same canonical rule used for project-hash stability
1047
+ // (normalizeWorktreePath): backslash → forward slash, then strip the
1048
+ // trailing slash. Normalizing in SQL (RTRIM(REPLACE(...))) covers rows
1049
+ // already written un-normalized without a migration, while the JS-side
1050
+ // normalize keeps the bound parameter in the identical shape. This
1051
+ // preserves the #737 project scope — distinct directories still differ
1052
+ // after normalization, so cross-project isolation is intact.
1053
+ const normalized = normalizeWorktreePath(projectDir);
1034
1054
  const rows = this.db
1035
1055
  .prepare(`SELECT DISTINCT session_id
1036
1056
  FROM session_events
1037
- WHERE project_dir = ?`)
1038
- .all(projectDir);
1057
+ WHERE RTRIM(REPLACE(project_dir, '\\', '/'), '/') = ?`)
1058
+ .all(normalized);
1039
1059
  return rows.map((r) => r.session_id);
1040
1060
  }
1041
1061
  catch {
@@ -1439,6 +1439,72 @@ const ROLE_MIN_CHARS = 8;
1439
1439
  const ROLE_MAX_CHARS = 120;
1440
1440
  const TWO_LEXICAL_TOKENS_PATTERN = /\p{L}+\s+\p{L}+/u;
1441
1441
  const CONTINUOUS_LETTER_RUN_PATTERN = /\p{L}{6,}/u;
1442
+ // Issue #856 — persona / standing-directive cue gate.
1443
+ //
1444
+ // The structural test below ("two lexical tokens OR a 6-codepoint letter run,
1445
+ // 8..120 chars, no '?', no clause separator") is intentionally coarse and
1446
+ // matches ANY short declarative sentence. That let casual conversational
1447
+ // acknowledgements ("that's fine for now", "go with the second option") freeze
1448
+ // as a priority-3 `role`, which the Pi adapter then re-injected as a standing
1449
+ // behavioral_directive every turn → do-nothing loop.
1450
+ //
1451
+ // A genuine role/behavioral prompt always LEADS with a persona declaration
1452
+ // ("You are X", "Tu es X", "あなたは…", "你是…") or a standing-directive verb
1453
+ // ("always respond…", "act as…"). Casual phrases never do, so we require that
1454
+ // cue as a NECESSARY condition. This preserves legitimate role persistence
1455
+ // (issue #535 multilingual corpus) while killing the casual-phrase loop.
1456
+ //
1457
+ // ALGORITHMIC ONLY — pure lowercase + prefix membership, no regex (project
1458
+ // hard rule). Multilingual openers are matched by `startsWith` on the
1459
+ // normalized first clause; leading conversational filler tokens are stripped
1460
+ // by array operations before the check.
1461
+ const ROLE_FILLER_TOKENS = new Set([
1462
+ "ok", "okay", "sure", "yeah", "yep", "yup", "alright", "fine",
1463
+ "well", "so", "hmm", "right", "please",
1464
+ ]);
1465
+ // Second-person persona openers across the supported-language corpus
1466
+ // (issue #535 multilingual role test set) plus common English persona framings.
1467
+ const ROLE_PERSONA_PREFIXES = [
1468
+ "you are", "you're", "your role", "you will be", "you act", "you will act",
1469
+ "act as", "act like", "behave as", "behave like", "imagine you", "pretend you",
1470
+ "assume the role", "take the role", "play the role", "respond as",
1471
+ "tu es", "tu est", "vous etes", "vous êtes", // French
1472
+ "sen ", "siz ", // Turkish (Sen kıdemli…)
1473
+ "eres ", "tú eres", "usted es", // Spanish (Eres…)
1474
+ "ты ", "вы ", // Russian (Ты опытный…)
1475
+ "あなたは", "君は", "お前は", "あなたが", // Japanese (あなたは…)
1476
+ "你是", "您是", // Chinese (你是…)
1477
+ "तुम ", "आप ", "तू ", // Hindi (तुम…)
1478
+ "أنت ", "انت ", "أنتَ ", // Arabic (أنت…)
1479
+ ];
1480
+ // Standing-directive verb openers — imperative behavioral rules that should
1481
+ // persist ("always respond in TypeScript", "never use emojis").
1482
+ const ROLE_DIRECTIVE_PREFIXES = [
1483
+ "always ", "never ", "respond ", "reply ", "answer ", "speak ",
1484
+ "write ", "prefer ", "format ", "output ", "communicate ", "use only ",
1485
+ ];
1486
+ function hasRoleCue(firstClause) {
1487
+ const lower = firstClause.toLowerCase().trim();
1488
+ if (!lower)
1489
+ return false;
1490
+ // Strip leading conversational filler tokens via array ops (no regex).
1491
+ const tokens = lower.split(" ").filter((t) => t.length > 0);
1492
+ while (tokens.length > 0 && ROLE_FILLER_TOKENS.has(tokens[0])) {
1493
+ tokens.shift();
1494
+ }
1495
+ const normalized = tokens.join(" ");
1496
+ if (!normalized)
1497
+ return false;
1498
+ for (const prefix of ROLE_PERSONA_PREFIXES) {
1499
+ if (normalized.startsWith(prefix))
1500
+ return true;
1501
+ }
1502
+ for (const prefix of ROLE_DIRECTIVE_PREFIXES) {
1503
+ if (normalized.startsWith(prefix))
1504
+ return true;
1505
+ }
1506
+ return false;
1507
+ }
1442
1508
  function looksLikeRole(trimmed) {
1443
1509
  // Role prompts are persona-prefix shaped: the FIRST SENTENCE declares the
1444
1510
  // role (e.g. "You are a senior backend engineer. <long context...>").
@@ -1457,6 +1523,10 @@ function looksLikeRole(trimmed) {
1457
1523
  const codepointLength = [...firstClause].length;
1458
1524
  if (codepointLength < ROLE_MIN_CHARS || codepointLength > ROLE_MAX_CHARS)
1459
1525
  return false;
1526
+ // Issue #856 — require a persona / standing-directive cue so casual
1527
+ // conversational acknowledgements do not freeze as a role directive.
1528
+ if (!hasRoleCue(firstClause))
1529
+ return false;
1460
1530
  return (TWO_LEXICAL_TOKENS_PATTERN.test(firstClause) ||
1461
1531
  CONTINUOUS_LETTER_RUN_PATTERN.test(firstClause));
1462
1532
  }
@@ -1749,6 +1819,14 @@ const TOOL_NAME_NORMALIZE = {
1749
1819
  "container.exec": "Bash",
1750
1820
  local_shell: "Bash",
1751
1821
  grep_files: "Grep",
1822
+ // Antigravity CLI (`agy`) native names. Keep in sync with the two other agy
1823
+ // maps: hooks/antigravity-cli/payload.mjs (normalizeAgyToolName) and
1824
+ // hooks/core/routing.mjs (TOOL_ALIASES).
1825
+ run_command: "Bash",
1826
+ view_file: "Read",
1827
+ read_url_content: "WebFetch",
1828
+ list_dir: "LS",
1829
+ search_web: "WebSearch",
1752
1830
  };
1753
1831
  function normalizeHookInput(input) {
1754
1832
  const normalized = TOOL_NAME_NORMALIZE[input.tool_name];
package/build/store.d.ts CHANGED
@@ -88,7 +88,7 @@ export declare class ContentStore {
88
88
  indexPlainText(content: string, source: string, linesPerChunk?: number, attribution?: {
89
89
  sessionId?: string;
90
90
  eventId?: string;
91
- }): IndexResult;
91
+ }, maxChunkBytes?: number): IndexResult;
92
92
  /**
93
93
  * Index JSON content by walking the object tree and using key paths
94
94
  * as chunk titles (analogous to heading hierarchy in markdown). Objects
package/build/store.js CHANGED
@@ -108,6 +108,20 @@ function maxEditDistance(wordLength) {
108
108
  // length normalization and produce unwieldy search results. Split at paragraph
109
109
  // boundaries when a chunk exceeds this cap.
110
110
  const MAX_CHUNK_BYTES = 4096;
111
+ // Blank-line sectioning is used only for output that is *naturally* sectioned:
112
+ // at least a few sections, not an unbounded explosion, and no single section so
113
+ // large that the split is clearly not the real structure (those fall back to
114
+ // line-grouping). Sections that pass the heuristic but still exceed
115
+ // MAX_CHUNK_BYTES are sub-split so no persisted chunk breaks the cap.
116
+ const MIN_BLANK_LINE_SECTIONS = 3;
117
+ const MAX_BLANK_LINE_SECTIONS = 200;
118
+ const BLANK_SECTION_STRATEGY_MAX_BYTES = 5000;
119
+ // Number of leading characters of a chunk's first line used as its title.
120
+ const CHUNK_TITLE_MAX_CHARS = 80;
121
+ // When byte-splitting an oversized single line, prefer to break at a whitespace
122
+ // boundary for readability — but only if that boundary is past this fraction of
123
+ // the slice, otherwise we'd waste too much of the byte budget.
124
+ const WHITESPACE_BREAK_RATIO = 0.5;
111
125
  // ─────────────────────────────────────────────────────────
112
126
  // ContentStore
113
127
  // ─────────────────────────────────────────────────────────
@@ -820,11 +834,11 @@ export class ContentStore {
820
834
  * into fixed-size line groups. Unlike markdown indexing, this does not
821
835
  * look for headings — it chunks by line count with overlap.
822
836
  */
823
- indexPlainText(content, source, linesPerChunk = 20, attribution) {
837
+ indexPlainText(content, source, linesPerChunk = 20, attribution, maxChunkBytes = MAX_CHUNK_BYTES) {
824
838
  if (!content || content.trim().length === 0) {
825
839
  return this.#insertChunks([], source, "", undefined, undefined, attribution);
826
840
  }
827
- const chunks = this.#chunkPlainText(content, linesPerChunk);
841
+ const chunks = this.#chunkPlainText(content, linesPerChunk, maxChunkBytes);
828
842
  return withRetry(() => this.#insertChunks(chunks.map((c) => ({ ...c, hasCode: false })), source, content, undefined, undefined, attribution));
829
843
  }
830
844
  // ── Index JSON ──
@@ -837,19 +851,19 @@ export class ContentStore {
837
851
  */
838
852
  indexJSON(content, source, maxChunkBytes = MAX_CHUNK_BYTES, attribution) {
839
853
  if (!content || content.trim().length === 0) {
840
- return this.indexPlainText("", source, undefined, attribution);
854
+ return this.indexPlainText("", source, undefined, attribution, maxChunkBytes);
841
855
  }
842
856
  let parsed;
843
857
  try {
844
858
  parsed = JSON.parse(content);
845
859
  }
846
860
  catch {
847
- return this.indexPlainText(content, source, undefined, attribution);
861
+ return this.indexPlainText(content, source, undefined, attribution, maxChunkBytes);
848
862
  }
849
863
  const chunks = [];
850
864
  this.#walkJSON(parsed, [], chunks, maxChunkBytes);
851
865
  if (chunks.length === 0) {
852
- return this.indexPlainText(content, source, undefined, attribution);
866
+ return this.indexPlainText(content, source, undefined, attribution, maxChunkBytes);
853
867
  }
854
868
  return withRetry(() => this.#insertChunks(chunks, source, content, undefined, undefined, attribution));
855
869
  }
@@ -1448,27 +1462,119 @@ export class ContentStore {
1448
1462
  flush();
1449
1463
  return chunks;
1450
1464
  }
1451
- #chunkPlainText(text, linesPerChunk) {
1465
+ /**
1466
+ * Return the largest prefix of `str` whose UTF-8 byte length does not exceed
1467
+ * `maxBytes`, walking by Unicode code point so multibyte sequences (CJK) and
1468
+ * surrogate pairs (emoji) are never cut mid-character. Guarantees forward
1469
+ * progress: if even the first code point exceeds `maxBytes`, it is still
1470
+ * returned whole (a 1-4 byte overshoot beats an infinite loop).
1471
+ */
1472
+ #byteCappedPrefix(str, maxBytes) {
1473
+ if (Buffer.byteLength(str) <= maxBytes)
1474
+ return str;
1475
+ let prefix = "";
1476
+ let bytes = 0;
1477
+ for (const char of str) {
1478
+ const charBytes = Buffer.byteLength(char);
1479
+ if (bytes + charBytes > maxBytes)
1480
+ break;
1481
+ prefix += char;
1482
+ bytes += charBytes;
1483
+ }
1484
+ // Defensive: a single code point wider than the cap (only possible with a
1485
+ // pathologically small maxBytes) still advances by one character.
1486
+ if (prefix.length === 0)
1487
+ return [...str][0] ?? "";
1488
+ return prefix;
1489
+ }
1490
+ /**
1491
+ * Split a single oversized plain-text chunk into byte-capped sub-chunks
1492
+ * by accumulating lines until the byte count would exceed maxChunkBytes.
1493
+ * Falls back to byte-accurate splitting for extremely long single lines.
1494
+ */
1495
+ #splitOversizedPlainChunk(lines, titlePrefix, maxChunkBytes) {
1496
+ const subChunks = [];
1497
+ let accumulator = [];
1498
+ let partIndex = 1;
1499
+ const flushAccumulator = () => {
1500
+ if (accumulator.length === 0)
1501
+ return;
1502
+ const content = accumulator.join("\n");
1503
+ const partTitle = partIndex === 1 ? titlePrefix : `${titlePrefix} (${partIndex})`;
1504
+ subChunks.push({ title: partTitle, content });
1505
+ partIndex++;
1506
+ accumulator = [];
1507
+ };
1508
+ for (const line of lines) {
1509
+ // If a single line itself exceeds the cap (even as first line),
1510
+ // split it by character before accumulating
1511
+ if (Buffer.byteLength(line) > maxChunkBytes) {
1512
+ flushAccumulator();
1513
+ // Split the long line into byte-capped pieces
1514
+ let remaining = line;
1515
+ let linePart = 1;
1516
+ while (remaining.length > 0) {
1517
+ // Byte-accurate slice: never exceeds the cap, never cuts a multibyte
1518
+ // character (CJK) or surrogate pair (emoji) in half.
1519
+ let slice = this.#byteCappedPrefix(remaining, maxChunkBytes);
1520
+ // Try to break at a whitespace boundary near the end for readability,
1521
+ // but only when text remains after this slice.
1522
+ if (slice.length < remaining.length) {
1523
+ const lastSpace = slice.lastIndexOf(" ");
1524
+ const lastNewline = slice.lastIndexOf("\n");
1525
+ const breakPoint = Math.max(lastSpace, lastNewline);
1526
+ if (breakPoint > slice.length * WHITESPACE_BREAK_RATIO) {
1527
+ slice = slice.slice(0, breakPoint);
1528
+ }
1529
+ }
1530
+ const linePartTitle = partIndex === 1 && linePart === 1
1531
+ ? titlePrefix
1532
+ : `${titlePrefix} (${partIndex}.${linePart})`;
1533
+ subChunks.push({ title: linePartTitle, content: slice });
1534
+ remaining = remaining.slice(slice.length);
1535
+ linePart++;
1536
+ partIndex++;
1537
+ }
1538
+ continue;
1539
+ }
1540
+ const candidate = accumulator.length > 0
1541
+ ? accumulator.join("\n") + "\n" + line
1542
+ : line;
1543
+ // If adding this line would exceed the cap, flush accumulator first
1544
+ if (Buffer.byteLength(candidate) > maxChunkBytes && accumulator.length > 0) {
1545
+ flushAccumulator();
1546
+ }
1547
+ accumulator.push(line);
1548
+ }
1549
+ flushAccumulator();
1550
+ return subChunks;
1551
+ }
1552
+ #chunkPlainText(text, linesPerChunk, maxChunkBytes = MAX_CHUNK_BYTES) {
1452
1553
  // Try blank-line splitting first for naturally-sectioned output
1453
1554
  const sections = text.split(/\n\s*\n/);
1454
- if (sections.length >= 3 &&
1455
- sections.length <= 200 &&
1456
- sections.every((s) => Buffer.byteLength(s) < 5000)) {
1457
- return sections
1458
- .map((section, i) => {
1555
+ if (sections.length >= MIN_BLANK_LINE_SECTIONS &&
1556
+ sections.length <= MAX_BLANK_LINE_SECTIONS &&
1557
+ sections.every((s) => Buffer.byteLength(s) < BLANK_SECTION_STRATEGY_MAX_BYTES)) {
1558
+ return sections.flatMap((section, i) => {
1459
1559
  const trimmed = section.trim();
1460
- const firstLine = trimmed.split("\n")[0].slice(0, 80);
1461
- return {
1462
- title: firstLine || `Section ${i + 1}`,
1463
- content: trimmed,
1464
- };
1465
- })
1466
- .filter((s) => s.content.length > 0);
1560
+ if (trimmed.length === 0)
1561
+ return [];
1562
+ const title = trimmed.split("\n")[0].slice(0, CHUNK_TITLE_MAX_CHARS) || `Section ${i + 1}`;
1563
+ // A section may pass the strategy guard yet still exceed the byte cap
1564
+ // (4097–4999B band): sub-split it so no stored chunk breaks the cap.
1565
+ if (Buffer.byteLength(trimmed) <= maxChunkBytes) {
1566
+ return [{ title, content: trimmed }];
1567
+ }
1568
+ return this.#splitOversizedPlainChunk(trimmed.split("\n"), title, maxChunkBytes);
1569
+ });
1467
1570
  }
1468
1571
  const lines = text.split("\n");
1469
- // Small enough for a single chunk
1572
+ // Small enough for a single chunk — but still enforce byte cap
1470
1573
  if (lines.length <= linesPerChunk) {
1471
- return [{ title: "Output", content: text }];
1574
+ if (Buffer.byteLength(text) <= maxChunkBytes) {
1575
+ return [{ title: "Output", content: text }];
1576
+ }
1577
+ return this.#splitOversizedPlainChunk(lines, "Output", maxChunkBytes);
1472
1578
  }
1473
1579
  // Fixed-size line groups with 2-line overlap
1474
1580
  const chunks = [];
@@ -1480,11 +1586,19 @@ export class ContentStore {
1480
1586
  break;
1481
1587
  const startLine = i + 1;
1482
1588
  const endLine = Math.min(i + slice.length, lines.length);
1483
- const firstLine = slice[0]?.trim().slice(0, 80);
1484
- chunks.push({
1485
- title: firstLine || `Lines ${startLine}-${endLine}`,
1486
- content: slice.join("\n"),
1487
- });
1589
+ const firstLine = slice[0]?.trim().slice(0, CHUNK_TITLE_MAX_CHARS);
1590
+ const joined = slice.join("\n");
1591
+ // Enforce byte cap: sub-split oversized line-group chunks
1592
+ if (Buffer.byteLength(joined) <= maxChunkBytes) {
1593
+ chunks.push({
1594
+ title: firstLine || `Lines ${startLine}-${endLine}`,
1595
+ content: joined,
1596
+ });
1597
+ }
1598
+ else {
1599
+ const subChunks = this.#splitOversizedPlainChunk(slice, firstLine || `Lines ${startLine}-${endLine}`, maxChunkBytes);
1600
+ chunks.push(...subChunks);
1601
+ }
1488
1602
  }
1489
1603
  return chunks;
1490
1604
  }
@@ -0,0 +1,4 @@
1
+ export declare function getToolName(platform: string, bareTool: string): string;
2
+ export type ToolNamer = (bareTool: string) => string;
3
+ export declare function createToolNamer(platform: string): ToolNamer;
4
+ export declare const KNOWN_PLATFORMS: string[];
@@ -0,0 +1,24 @@
1
+ const TOOL_PREFIXES = {
2
+ "claude-code": (tool) => `mcp__plugin_context-mode_context-mode__${tool}`,
3
+ "gemini-cli": (tool) => `mcp__context-mode__${tool}`,
4
+ "antigravity": (tool) => `mcp__context-mode__${tool}`,
5
+ "opencode": (tool) => `context-mode_${tool}`,
6
+ "kilo": (tool) => `context-mode_${tool}`,
7
+ "vscode-copilot": (tool) => `context-mode_${tool}`,
8
+ "jetbrains-copilot": (tool) => `context-mode_${tool}`,
9
+ "kiro": (tool) => `@context-mode/${tool}`,
10
+ "zed": (tool) => `mcp:context-mode:${tool}`,
11
+ "cursor": (tool) => tool,
12
+ "codex": (tool) => tool,
13
+ "openclaw": (tool) => tool,
14
+ "pi": (tool) => tool,
15
+ "qwen-code": (tool) => `mcp__context-mode__${tool}`,
16
+ };
17
+ export function getToolName(platform, bareTool) {
18
+ const fn = TOOL_PREFIXES[platform] || TOOL_PREFIXES["claude-code"];
19
+ return fn(bareTool);
20
+ }
21
+ export function createToolNamer(platform) {
22
+ return (bareTool) => getToolName(platform, bareTool);
23
+ }
24
+ export const KNOWN_PLATFORMS = Object.keys(TOOL_PREFIXES);
@@ -0,0 +1,14 @@
1
+ /**
2
+ * util/jsonc — string-aware JSONC comment + trailing-comma stripping and a
3
+ * tolerant parse. Several agent CLIs ship config files as JSONC (VS Code
4
+ * `mcp.json`, Zed `settings.json`), so a strict `JSON.parse` false-fails on a
5
+ * perfectly valid commented file. Use `parseJsonc` whenever reading a
6
+ * platform config we did not write ourselves.
7
+ */
8
+ /** Strip `//` line + `/* *​/` block comments and trailing commas, string-aware. */
9
+ export declare function stripJsonComments(str: string): string;
10
+ /**
11
+ * Parse JSON or JSONC. Tries strict `JSON.parse` first (fast, exact), then a
12
+ * comment/trailing-comma-stripped parse. Returns `undefined` when both fail.
13
+ */
14
+ export declare function parseJsonc<T = unknown>(raw: string): T | undefined;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * util/jsonc — string-aware JSONC comment + trailing-comma stripping and a
3
+ * tolerant parse. Several agent CLIs ship config files as JSONC (VS Code
4
+ * `mcp.json`, Zed `settings.json`), so a strict `JSON.parse` false-fails on a
5
+ * perfectly valid commented file. Use `parseJsonc` whenever reading a
6
+ * platform config we did not write ourselves.
7
+ */
8
+ /** Strip `//` line + `/* *​/` block comments and trailing commas, string-aware. */
9
+ export function stripJsonComments(str) {
10
+ let out = "";
11
+ let inString = false;
12
+ let escaped = false;
13
+ let inBlockComment = false;
14
+ for (let i = 0; i < str.length; i++) {
15
+ const c = str[i];
16
+ const next = str[i + 1];
17
+ if (inBlockComment) {
18
+ if (c === "*" && next === "/") {
19
+ inBlockComment = false;
20
+ i++;
21
+ }
22
+ continue;
23
+ }
24
+ if (escaped) {
25
+ out += c;
26
+ escaped = false;
27
+ continue;
28
+ }
29
+ if (c === "\\") {
30
+ out += c;
31
+ escaped = inString;
32
+ continue;
33
+ }
34
+ if (c === '"') {
35
+ inString = !inString;
36
+ out += c;
37
+ continue;
38
+ }
39
+ if (!inString && c === "/" && next === "/") {
40
+ while (i < str.length && str[i] !== "\n")
41
+ i++;
42
+ if (i < str.length)
43
+ out += "\n";
44
+ continue;
45
+ }
46
+ if (!inString && c === "/" && next === "*") {
47
+ inBlockComment = true;
48
+ i++;
49
+ continue;
50
+ }
51
+ out += c;
52
+ }
53
+ // Trailing-comma removal, string-aware. The scan above already removed
54
+ // comments, so this second pass over `out` only needs to track string state:
55
+ // a comma is "trailing" when the next significant char is `}` or `]`. Doing
56
+ // it here — instead of a post-hoc trailing-comma regex over the whole string —
57
+ // preserves commas inside string literals (e.g. "[1, ]"), which that regex
58
+ // silently corrupted to "[1 ]". See #787 review.
59
+ let result = "";
60
+ let inStr = false;
61
+ let esc = false;
62
+ for (let i = 0; i < out.length; i++) {
63
+ const c = out[i];
64
+ if (esc) {
65
+ result += c;
66
+ esc = false;
67
+ continue;
68
+ }
69
+ if (c === "\\") {
70
+ result += c;
71
+ esc = inStr;
72
+ continue;
73
+ }
74
+ if (c === '"') {
75
+ inStr = !inStr;
76
+ result += c;
77
+ continue;
78
+ }
79
+ if (!inStr && c === ",") {
80
+ let j = i + 1;
81
+ while (j < out.length && (out[j] === " " || out[j] === "\t" || out[j] === "\r" || out[j] === "\n"))
82
+ j++;
83
+ if (out[j] === "}" || out[j] === "]")
84
+ continue;
85
+ }
86
+ result += c;
87
+ }
88
+ return result;
89
+ }
90
+ /**
91
+ * Parse JSON or JSONC. Tries strict `JSON.parse` first (fast, exact), then a
92
+ * comment/trailing-comma-stripped parse. Returns `undefined` when both fail.
93
+ */
94
+ export function parseJsonc(raw) {
95
+ for (const candidate of [raw, stripJsonComments(raw)]) {
96
+ try {
97
+ return JSON.parse(candidate);
98
+ }
99
+ catch {
100
+ /* try next */
101
+ }
102
+ }
103
+ return undefined;
104
+ }