context-mode 1.0.146 → 1.0.147

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.146"
9
+ "version": "1.0.147"
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.146",
16
+ "version": "1.0.147",
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.146",
3
+ "version": "1.0.147",
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",
@@ -27,5 +27,5 @@
27
27
  ]
28
28
  }
29
29
  },
30
- "skills": "./.claude/skills/"
30
+ "skills": "./skills/"
31
31
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.146",
3
+ "version": "1.0.147",
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.146",
6
+ "version": "1.0.147",
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.146",
3
+ "version": "1.0.147",
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",
package/README.md CHANGED
@@ -410,13 +410,7 @@ Full configs: [`configs/cursor/hooks.json`](configs/cursor/hooks.json) | [`confi
410
410
 
411
411
  **Install:**
412
412
 
413
- 1. Install context-mode globally:
414
-
415
- ```bash
416
- npm install -g context-mode
417
- ```
418
-
419
- 2. Add to `opencode.json` in your project root (or `~/.config/opencode/opencode.json` for global):
413
+ 1. Add to `opencode.json` in your project root (or `~/.config/opencode/opencode.json` for global):
420
414
 
421
415
  ```json
422
416
  {
@@ -427,7 +421,7 @@ Full configs: [`configs/cursor/hooks.json`](configs/cursor/hooks.json) | [`confi
427
421
 
428
422
  The `plugin` entry registers all 11 `ctx_*` tools natively and enables hooks — OpenCode calls context-mode's TypeScript plugin in-process, so there is no redundant stdio MCP child per session.
429
423
 
430
- 3. *(Optional)* Copy the routing rules file. The model needs an `AGENTS.md` file for routing awareness:
424
+ 2. *(Optional)* Copy the routing rules file. The model needs an `AGENTS.md` file for routing awareness:
431
425
 
432
426
  ```bash
433
427
  cp node_modules/context-mode/configs/opencode/AGENTS.md AGENTS.md
@@ -435,7 +429,7 @@ Full configs: [`configs/cursor/hooks.json`](configs/cursor/hooks.json) | [`confi
435
429
 
436
430
  This tells the model which tools to use and which commands are blocked. Without it, hooks still enforce routing — but the model won't know *why* a command was denied.
437
431
 
438
- 4. Restart OpenCode.
432
+ 3. Restart OpenCode.
439
433
 
440
434
  **Verify:** In the OpenCode session, type `ctx stats`. Context-mode tools should appear and respond.
441
435
 
@@ -456,13 +450,7 @@ Full configs: [`configs/opencode/opencode.json`](configs/opencode/opencode.json)
456
450
 
457
451
  **Install:**
458
452
 
459
- 1. Install context-mode globally:
460
-
461
- ```bash
462
- npm install -g context-mode
463
- ```
464
-
465
- 2. Add to `kilo.json` in your project root (or `~/.config/kilo/kilo.json` for global):
453
+ 1. Add to `kilo.json` in your project root (or `~/.config/kilo/kilo.json` for global):
466
454
 
467
455
  ```json
468
456
  {
@@ -473,13 +461,13 @@ Full configs: [`configs/opencode/opencode.json`](configs/opencode/opencode.json)
473
461
 
474
462
  The `plugin` entry registers all 11 `ctx_*` tools natively and enables hooks — KiloCode calls context-mode's TypeScript plugin in-process, so there is no redundant stdio MCP child per session.
475
463
 
476
- 3. *(Optional)* Copy the routing rules file. KiloCode shares the OpenCode plugin architecture, so the model needs an `AGENTS.md` file for routing awareness:
464
+ 2. *(Optional)* Copy the routing rules file. KiloCode shares the OpenCode plugin architecture, so the model needs an `AGENTS.md` file for routing awareness:
477
465
 
478
466
  ```bash
479
467
  cp node_modules/context-mode/configs/opencode/AGENTS.md AGENTS.md
480
468
  ```
481
469
 
482
- 4. Restart KiloCode.
470
+ 3. Restart KiloCode.
483
471
 
484
472
  **Verify:** In the KiloCode session, type `ctx stats`. Context-mode tools should appear and respond.
485
473
 
@@ -556,6 +544,14 @@ Full documentation: [`docs/adapters/openclaw.md`](docs/adapters/openclaw.md)
556
544
  > remains accepted as a legacy alias in current Codex builds. Bundled plugin hooks
557
545
  > additionally require `plugin_hooks` until Codex enables plugin hooks by default.
558
546
 
547
+ **Custom storage location:** if Codex cannot write the adapter default storage directory, set
548
+ `CONTEXT_MODE_DIR` to an absolute writable root in the environment that launches Codex. Sessions
549
+ and stats use `<root>/sessions`; indexed content uses `<root>/content`.
550
+
551
+ ```bash
552
+ CONTEXT_MODE_DIR="$HOME/.codex-context-mode" codex
553
+ ```
554
+
559
555
  3. Restart Codex CLI and verify MCP with `ctx stats`.
560
556
 
561
557
  `ctx stats` proves the plugin MCP server is installed and reachable; it does
@@ -995,7 +991,7 @@ npm install -g context-mode
995
991
  | `ctx_execute_file` | Process files in sandbox. Raw content never leaves. | 45 KB → 155 B |
996
992
  | `ctx_index` | Chunk markdown into FTS5 with BM25 ranking. | 60 KB → 40 B |
997
993
  | `ctx_search` | Query indexed content with multiple queries in one call. | On-demand retrieval |
998
- | `ctx_fetch_and_index` | Fetch URL, chunk and index. 24h TTL cache repeat calls skip network. `force: true` to bypass. Pass `requests: [{url, source}, ...]` + `concurrency: 1-8` for parallel multi-URL. | 60 KB → 40 B |
994
+ | `ctx_fetch_and_index` | Fetch URL, chunk and index. Cache reuses content within TTL (default 24h, override per-call with `ttl: <ms>`). `ttl: 0` or `force: true` to bypass. Pass `requests: [{url, source}, ...]` + `concurrency: 1-8` for parallel multi-URL. | 60 KB → 40 B |
999
995
  | `ctx_stats` | Show context savings, call counts, and session statistics. | — |
1000
996
  | `ctx_doctor` | Diagnose installation: runtimes, hooks, FTS5, versions. | — |
1001
997
  | `ctx_upgrade` | Upgrade to latest version from GitHub, rebuild, reconfigure hooks. | — |
@@ -1040,11 +1036,12 @@ Search results use intelligent extraction instead of truncation. Instead of retu
1040
1036
 
1041
1037
  ### TTL Cache
1042
1038
 
1043
- Indexed content persists in a per-project SQLite database at `~/.context-mode/content/`. When `ctx_fetch_and_index` is called for a URL that was already indexed within the last 24 hours, the fetch is skipped entirely. The model searches the existing index directly.
1039
+ Indexed content persists in a per-project SQLite database at `~/.context-mode/content/`. When `ctx_fetch_and_index` is called for a URL that was already indexed within its TTL window, the fetch is skipped entirely and the model searches the existing index directly.
1044
1040
 
1045
- - **Fresh (<24h):** Returns a cache hint (0.3KB) instead of re-fetching (48KB+). Model proceeds to `ctx_search`.
1046
- - **Stale (>24h):** Re-fetches silently. No user action needed.
1047
- - **`force: true`:** Bypasses cache and re-fetches regardless of TTL.
1041
+ - **Default TTL:** 24 hours. Override per-call with `ttl: <milliseconds>` (PR #666). Longer for stable specs, shorter for changelogs you want re-checked often.
1042
+ - **Cache hit (within TTL):** Returns a cache hint (~0.3KB) instead of re-fetching (48KB+). Model proceeds to `ctx_search`.
1043
+ - **Cache miss (TTL expired):** Re-fetches silently. No user action needed.
1044
+ - **`ttl: 0`** or **`force: true`:** Bypasses cache and re-fetches regardless of freshness.
1048
1045
  - **14-day cleanup:** Content databases and sources older than 14 days are removed on startup.
1049
1046
 
1050
1047
  This means `--continue` sessions preserve indexed docs across restarts. No re-fetching, no wasted context tokens.
@@ -1389,6 +1386,12 @@ That blocks loopback + RFC1918 + ULA in addition to the always-blocked ranges. U
1389
1386
 
1390
1387
  `tool_input` for any `mcp__*` tool call is also redacted before persistence — the regex matcher in `hooks/posttooluse.mjs` masks `authorization`, `auth_token`, `access_token`, `refresh_token`, `bearer`, `token`, `secret`, `password`, `passwd`, `pwd`, `api_key` / `apikey` / `x_api_key`, `cookie` / `set-cookie`, `signature`, `private_key`, and `client_secret` (case-insensitive, hyphen/underscore-insensitive) to `[REDACTED]` so credentials in MCP arguments don't end up in the session DB.
1391
1388
 
1389
+ ### Storage environment variables
1390
+
1391
+ | Variable | Default | Purpose |
1392
+ |---|---|---|
1393
+ | `CONTEXT_MODE_DIR` | Adapter default, for example `~/.codex/context-mode` or `~/.claude/context-mode` | Since v1.0.147. Absolute writable root for context-mode storage. Sessions and stats use `<root>/sessions`; indexed content uses `<root>/content`. Empty or whitespace-only values are treated as unset and shown by `ctx_doctor`; non-empty values must be absolute. `~` is not expanded. |
1394
+
1392
1395
  ### Routing-guidance environment variables
1393
1396
 
1394
1397
  | Variable | Default | Purpose |
@@ -26,10 +26,14 @@
26
26
  */
27
27
 
28
28
  import { existsSync, readFileSync } from "node:fs";
29
- import { join, dirname, resolve } from "node:path";
29
+ import { dirname, resolve } from "node:path";
30
30
  import { fileURLToPath, pathToFileURL } from "node:url";
31
- import { homedir } from "node:os";
32
31
  import { execFileSync } from "node:child_process";
32
+ import {
33
+ ensureWritableStorageDir,
34
+ resolveDefaultSessionDir,
35
+ resolveSessionStorageDir,
36
+ } from "../hooks/session-db.bundle.mjs";
33
37
 
34
38
  // ── Analytics import — resolved relative to this script ─────────────────
35
39
  // statusline.mjs ships in `bin/`; the compiled analytics module lives in
@@ -68,10 +72,10 @@ function platform() {
68
72
 
69
73
  // Single-shot stderr warning latch — keep noise out of Claude Code's
70
74
  // statusline output even when our parent runs us repeatedly per session.
71
- let __winWarned = false;
75
+ const __warnedKeys = new Set();
72
76
  function warnOnce(key, msg) {
73
- if (key === "win" && __winWarned) return;
74
- if (key === "win") __winWarned = true;
77
+ if (__warnedKeys.has(key)) return;
78
+ __warnedKeys.add(key);
75
79
  try { process.stderr.write(`context-mode statusline: ${msg}\n`); } catch { /* ignore */ }
76
80
  }
77
81
 
@@ -98,10 +102,19 @@ function readStdinJson() {
98
102
  }
99
103
 
100
104
  function resolveSessionDir() {
101
- if (process.env.CONTEXT_MODE_SESSION_DIR) {
102
- return process.env.CONTEXT_MODE_SESSION_DIR;
103
- }
104
- return join(homedir(), ".claude", "context-mode", "sessions");
105
+ return ensureWritableStorageDir(
106
+ resolveSessionStorageDir(() => resolveDefaultSessionDir({
107
+ configDir: ".claude",
108
+ configDirEnv: "CLAUDE_CONFIG_DIR",
109
+ legacySessionDirEnv: "CONTEXT_MODE_SESSION_DIR",
110
+ onLegacySessionDir: () => {
111
+ warnOnce(
112
+ "legacy-session-dir",
113
+ "CONTEXT_MODE_SESSION_DIR is deprecated; set CONTEXT_MODE_DIR to the parent context-mode root.",
114
+ );
115
+ },
116
+ })),
117
+ );
105
118
  }
106
119
 
107
120
  /**
@@ -73,15 +73,20 @@ export declare abstract class BaseAdapter {
73
73
  */
74
74
  getInstructionFiles(): string[];
75
75
  /**
76
- * Default: <configDir>/memory. Always absolute (configDir is absolute by
77
- * contract). Adapters with a different memory dir name (e.g., codex uses
78
- * "memories" plural) override this.
76
+ * Default: <configDir>/memory/<projectHash>. Always absolute (configDir is
77
+ * absolute by contract). Adapters with a different memory dir name (e.g.,
78
+ * codex uses "memories" plural) override this.
79
79
  *
80
80
  * Issue #649: when `CONTEXT_MODE_DATA_DIR` is set, memory follows storage
81
81
  * to `<DATA_DIR>/context-mode/memory/` since persistent memory is
82
82
  * context-mode-owned state, not platform-native config.
83
+ *
84
+ * Issue #663: when `projectDir` is supplied the path is scoped via
85
+ * `hashProjectDirCanonical(projectDir)` so two projects running in
86
+ * parallel never share auto-memory contents. When omitted (legacy
87
+ * callers), the unscoped path is returned for backwards compatibility.
83
88
  */
84
- getMemoryDir(): string;
89
+ getMemoryDir(projectDir?: string): string;
85
90
  backupSettings(): string | null;
86
91
  abstract getSettingsPath(): string;
87
92
  }
@@ -37,6 +37,7 @@
37
37
  import { join, resolve } from "node:path";
38
38
  import { accessSync, copyFileSync, constants, mkdirSync } from "node:fs";
39
39
  import { homedir } from "node:os";
40
+ import { hashProjectDirCanonical } from "../session/db.js";
40
41
  /**
41
42
  * Universal storage-root override. Returns the resolved absolute path when
42
43
  * `CONTEXT_MODE_DATA_DIR` is set to a non-blank value, otherwise `null` so
@@ -97,19 +98,27 @@ export class BaseAdapter {
97
98
  return ["CLAUDE.md"];
98
99
  }
99
100
  /**
100
- * Default: <configDir>/memory. Always absolute (configDir is absolute by
101
- * contract). Adapters with a different memory dir name (e.g., codex uses
102
- * "memories" plural) override this.
101
+ * Default: <configDir>/memory/<projectHash>. Always absolute (configDir is
102
+ * absolute by contract). Adapters with a different memory dir name (e.g.,
103
+ * codex uses "memories" plural) override this.
103
104
  *
104
105
  * Issue #649: when `CONTEXT_MODE_DATA_DIR` is set, memory follows storage
105
106
  * to `<DATA_DIR>/context-mode/memory/` since persistent memory is
106
107
  * context-mode-owned state, not platform-native config.
108
+ *
109
+ * Issue #663: when `projectDir` is supplied the path is scoped via
110
+ * `hashProjectDirCanonical(projectDir)` so two projects running in
111
+ * parallel never share auto-memory contents. When omitted (legacy
112
+ * callers), the unscoped path is returned for backwards compatibility.
107
113
  */
108
- getMemoryDir() {
114
+ getMemoryDir(projectDir) {
109
115
  const override = resolveContextModeDataRoot();
110
- if (override)
111
- return join(override, "context-mode", "memory");
112
- return join(this.getConfigDir(), "memory");
116
+ const base = override
117
+ ? join(override, "context-mode", "memory")
118
+ : join(this.getConfigDir(), "memory");
119
+ if (!projectDir)
120
+ return base;
121
+ return join(base, hashProjectDirCanonical(projectDir));
113
122
  }
114
123
  backupSettings() {
115
124
  const settingsPath = this.getSettingsPath();
@@ -32,7 +32,7 @@ export declare class CodexAdapter extends BaseAdapter implements HookAdapter {
32
32
  getSettingsPath(): string;
33
33
  getSessionDir(): string;
34
34
  getInstructionFiles(): string[];
35
- getMemoryDir(): string;
35
+ getMemoryDir(projectDir?: string): string;
36
36
  generateHookConfig(_pluginRoot: string): HookRegistration;
37
37
  readSettings(): Record<string, unknown> | null;
38
38
  writeSettings(_settings: Record<string, unknown>): void;
@@ -17,6 +17,7 @@ import { readFileSync, writeFileSync, accessSync, copyFileSync, constants, mkdir
17
17
  import { resolve, dirname, join } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { BaseAdapter, resolveContextModeDataRoot } from "../base.js";
20
+ import { hashProjectDirCanonical } from "../../session/db.js";
20
21
  import { resolveCodexConfigDir } from "./paths.js";
21
22
  // PreToolUse matcher: canonical Codex tool names + context-mode bare MCP tool
22
23
  // names + external MCP catch-all literal (#529, #547 hotfix).
@@ -257,16 +258,21 @@ export class CodexAdapter extends BaseAdapter {
257
258
  // Codex CLI honors AGENTS.md plus an optional override file.
258
259
  return ["AGENTS.md", "AGENTS.override.md"];
259
260
  }
260
- getMemoryDir() {
261
+ getMemoryDir(projectDir) {
261
262
  // Codex uses "memories" (plural), not the default "memory".
262
263
  // Issue #649: honor CONTEXT_MODE_DATA_DIR for context-mode-owned
263
264
  // persistent memory while preserving the platform-native plural folder
264
265
  // name so legacy Codex tooling continues to find it when DATA_DIR is
265
266
  // unset. Under the override, layout is `<DATA_DIR>/context-mode/memories`.
267
+ // Issue #663: scope by projectDir hash so parallel projects can't
268
+ // read each other's memory.
266
269
  const override = resolveContextModeDataRoot();
267
- if (override)
268
- return join(override, "context-mode", "memories");
269
- return join(this.getConfigDir(), "memories");
270
+ const base = override
271
+ ? join(override, "context-mode", "memories")
272
+ : join(this.getConfigDir(), "memories");
273
+ if (!projectDir)
274
+ return base;
275
+ return join(base, hashProjectDirCanonical(projectDir));
270
276
  }
271
277
  generateHookConfig(_pluginRoot) {
272
278
  return {
@@ -38,8 +38,17 @@ export declare class OpenClawAdapter extends BaseAdapter implements HookAdapter
38
38
  */
39
39
  getConfigDir(projectDir?: string): string;
40
40
  getInstructionFiles(): string[];
41
- /** Absolute <projectRoot>/memory directory. */
42
- getMemoryDir(): string;
41
+ /**
42
+ * Absolute <projectRoot>/memory directory.
43
+ *
44
+ * OpenClaw's `getConfigDir(projectDir)` already returns the project root,
45
+ * so the memory dir is naturally project-scoped per the OpenClaw
46
+ * convention. The `projectDir` parameter is honored for explicit
47
+ * resolution; without it, falls back to the implicit `process.cwd()`
48
+ * inside `getConfigDir`. Either way, two projects never share a path
49
+ * — no hash suffix needed (issue #663).
50
+ */
51
+ getMemoryDir(projectDir?: string): string;
43
52
  generateHookConfig(_pluginRoot: string): HookRegistration;
44
53
  readSettings(): Record<string, unknown> | null;
45
54
  writeSettings(settings: Record<string, unknown>): void;
@@ -154,9 +154,18 @@ export class OpenClawAdapter extends BaseAdapter {
154
154
  getInstructionFiles() {
155
155
  return ["AGENTS.md"];
156
156
  }
157
- /** Absolute <projectRoot>/memory directory. */
158
- getMemoryDir() {
159
- return join(this.getConfigDir(), "memory");
157
+ /**
158
+ * Absolute <projectRoot>/memory directory.
159
+ *
160
+ * OpenClaw's `getConfigDir(projectDir)` already returns the project root,
161
+ * so the memory dir is naturally project-scoped per the OpenClaw
162
+ * convention. The `projectDir` parameter is honored for explicit
163
+ * resolution; without it, falls back to the implicit `process.cwd()`
164
+ * inside `getConfigDir`. Either way, two projects never share a path
165
+ * — no hash suffix needed (issue #663).
166
+ */
167
+ getMemoryDir(projectDir) {
168
+ return join(this.getConfigDir(projectDir), "memory");
160
169
  }
161
170
  generateHookConfig(_pluginRoot) {
162
171
  // OpenClaw uses TS plugin paradigm — hooks are registered via
@@ -47,6 +47,14 @@ export interface MCPCallResult {
47
47
  }>;
48
48
  isError?: boolean;
49
49
  }
50
+ export declare class PiTextComponent {
51
+ private text;
52
+ constructor(text?: string);
53
+ setText(text: string): void;
54
+ invalidate(): void;
55
+ render(width: number): string[];
56
+ }
57
+ export declare function truncateAnsiLine(line: string, maxWidth: number): string;
50
58
  interface PiRenderTheme {
51
59
  bold(text: string): string;
52
60
  fg(color: string, text: string): string;
@@ -121,7 +121,7 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
121
121
  // self-healing the transient warm-up case.
122
122
  const MAX_INIT_RETRIES = 2;
123
123
  const INIT_RETRY_DELAY_MS = 1_000;
124
- class PiTextComponent {
124
+ export class PiTextComponent {
125
125
  text;
126
126
  constructor(text = "") {
127
127
  this.text = text;
@@ -141,29 +141,132 @@ class PiTextComponent {
141
141
  .map((line) => truncateAnsiLine(line, Math.max(1, width)));
142
142
  }
143
143
  }
144
- const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07]*(?:\x07|\x1b\\)/g;
145
- function truncateAnsiLine(line, maxWidth) {
144
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
145
+ function extractTerminalEscape(str, pos) {
146
+ if (pos >= str.length || str[pos] !== "\x1b")
147
+ return null;
148
+ const next = str[pos + 1];
149
+ // CSI sequence: ESC [ ... final-byte. Covers SGR plus cursor/control codes.
150
+ if (next === "[") {
151
+ let j = pos + 2;
152
+ while (j < str.length) {
153
+ const code = str.charCodeAt(j);
154
+ if (code >= 0x40 && code <= 0x7e) {
155
+ return { code: str.slice(pos, j + 1), length: j + 1 - pos };
156
+ }
157
+ j++;
158
+ }
159
+ return null;
160
+ }
161
+ // OSC/APC sequence: ESC ]/_ ... BEL or ST (ESC \). Stop at the FIRST
162
+ // terminator so OSC 8 hyperlinks don't swallow visible link text.
163
+ if (next === "]" || next === "_") {
164
+ let j = pos + 2;
165
+ while (j < str.length) {
166
+ if (str[j] === "\x07")
167
+ return { code: str.slice(pos, j + 1), length: j + 1 - pos };
168
+ if (str[j] === "\x1b" && str[j + 1] === "\\") {
169
+ return { code: str.slice(pos, j + 2), length: j + 2 - pos };
170
+ }
171
+ j++;
172
+ }
173
+ return null;
174
+ }
175
+ return null;
176
+ }
177
+ function couldBeEmoji(segment) {
178
+ const cp = segment.codePointAt(0) ?? 0;
179
+ return ((cp >= 0x1f000 && cp <= 0x1fbff) ||
180
+ (cp >= 0x2300 && cp <= 0x23ff) ||
181
+ (cp >= 0x2600 && cp <= 0x27bf) ||
182
+ (cp >= 0x2b50 && cp <= 0x2b55) ||
183
+ segment.includes("\uFE0F") ||
184
+ segment.includes("\u200D"));
185
+ }
186
+ function isZeroWidthCodePoint(cp) {
187
+ return (cp < 0x20 ||
188
+ (cp >= 0x7f && cp <= 0x9f) ||
189
+ (cp >= 0x300 && cp <= 0x36f) || // Combining Diacritical Marks
190
+ (cp >= 0x1ab0 && cp <= 0x1aff) || // Combining Diacritical Marks Extended
191
+ (cp >= 0x1dc0 && cp <= 0x1dff) || // Combining Diacritical Marks Supplement
192
+ (cp >= 0x20d0 && cp <= 0x20ff) || // Combining Diacritical Marks for Symbols
193
+ (cp >= 0xfe00 && cp <= 0xfe0f) || // Variation Selectors
194
+ (cp >= 0xfe20 && cp <= 0xfe2f) || // Combining Half Marks
195
+ cp === 0x200b ||
196
+ cp === 0x200c ||
197
+ cp === 0x200d ||
198
+ cp === 0xfeff);
199
+ }
200
+ function isZeroWidthGrapheme(segment) {
201
+ if (segment.length === 0)
202
+ return true;
203
+ for (const char of segment) {
204
+ if (!isZeroWidthCodePoint(char.codePointAt(0) ?? 0))
205
+ return false;
206
+ }
207
+ return true;
208
+ }
209
+ /**
210
+ * Returns the terminal display width of a code point.
211
+ * CJK ideographs, Hangul, fullwidth forms, etc. → 2; everything else → 1.
212
+ * Mirrors the Unicode east-asian-width "W"/"F" categories.
213
+ */
214
+ function charWidth(cp) {
215
+ return cp >= 0x1100 && (cp <= 0x115f || // Hangul Jamo
216
+ (cp >= 0xa960 && cp <= 0xa97c) || // Hangul Jamo Extended-A
217
+ cp === 0x2329 || cp === 0x232a ||
218
+ (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || // CJK
219
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
220
+ (cp >= 0xd7b0 && cp <= 0xd7fb) || // Hangul Jamo Extended-B
221
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compat
222
+ (cp >= 0xfe10 && cp <= 0xfe19) || // Vertical forms
223
+ (cp >= 0xfe30 && cp <= 0xfe6f) || // CJK compat forms
224
+ (cp >= 0xff01 && cp <= 0xff60) || // Fullwidth forms
225
+ (cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
226
+ (cp >= 0x20000 && cp <= 0x2fffd) || // CJK extensions
227
+ (cp >= 0x30000 && cp <= 0x3fffd) // CJK extensions B+
228
+ ) ? 2 : 1;
229
+ }
230
+ function graphemeWidth(segment) {
231
+ const cp = segment.codePointAt(0);
232
+ if (cp === undefined)
233
+ return 0;
234
+ if (isZeroWidthGrapheme(segment))
235
+ return 0;
236
+ if (couldBeEmoji(segment))
237
+ return 2;
238
+ // Regional indicator symbols render as wide emoji flags in Pi's TUI.
239
+ if (cp >= 0x1f1e6 && cp <= 0x1f1ff)
240
+ return 2;
241
+ return charWidth(cp);
242
+ }
243
+ export function truncateAnsiLine(line, maxWidth) {
146
244
  if (maxWidth <= 0)
147
245
  return "";
148
246
  let output = "";
149
247
  let visible = 0;
150
248
  let index = 0;
151
- ANSI_PATTERN.lastIndex = 0;
152
- for (;;) {
153
- const match = ANSI_PATTERN.exec(line);
154
- const end = match?.index ?? line.length;
249
+ while (index < line.length) {
250
+ const escape = extractTerminalEscape(line, index);
251
+ if (escape) {
252
+ output += escape.code;
253
+ index += escape.length;
254
+ continue;
255
+ }
256
+ let end = index + 1;
257
+ while (end < line.length && !extractTerminalEscape(line, end))
258
+ end++;
155
259
  const chunk = line.slice(index, end);
156
- for (const char of chunk) {
157
- if (visible >= maxWidth)
260
+ for (const { segment } of GRAPHEME_SEGMENTER.segment(chunk)) {
261
+ const w = graphemeWidth(segment);
262
+ if (visible + w > maxWidth)
158
263
  return output;
159
- output += char;
160
- visible++;
264
+ output += segment;
265
+ visible += w;
161
266
  }
162
- if (!match)
163
- return output;
164
- output += match[0];
165
- index = ANSI_PATTERN.lastIndex;
267
+ index = end;
166
268
  }
269
+ return output;
167
270
  }
168
271
  function createContextModeCallRenderer(toolName) {
169
272
  return (_args, theme, context) => {
@@ -196,8 +196,14 @@ export interface HookAdapter {
196
196
  * Directory where persistent per-user memory is stored
197
197
  * (e.g., ~/.claude/memory, ~/.codex/memories). Auto-memory scans
198
198
  * *.md files in this directory.
199
+ *
200
+ * When `projectDir` is supplied, the path MUST be project-scoped (issue
201
+ * #663) so two projects running in parallel cannot read each other's
202
+ * memory. Adapters scope via `hashProjectDirCanonical(projectDir)`.
203
+ * Callers that pre-date this contract may omit `projectDir`; in that
204
+ * case the unscoped legacy path is returned.
199
205
  */
200
- getMemoryDir(): string;
206
+ getMemoryDir(projectDir?: string): string;
201
207
  /** Generate hook registration config for this platform. */
202
208
  generateHookConfig(pluginRoot: string): HookRegistration;
203
209
  /** Read current platform settings. */
package/build/cli.d.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  * context-mode doctor → Diagnose runtime issues, hooks, FTS5, version
8
8
  * context-mode upgrade → Fix hooks, permissions, and settings
9
9
  * context-mode hook <platform> <event> → Dispatch a hook script (used by platform hook configs)
10
+ * CONTEXT_MODE_DIR=/abs/path context-mode → Override sessions/content storage root
11
+ * Empty/whitespace is ignored; non-empty values must be absolute.
10
12
  *
11
13
  * Platform auto-detection: CLI detects which platform is running
12
14
  * (Claude Code, Gemini CLI, OpenCode, etc.) and uses the appropriate adapter.