dsh-loci 2.9.0 → 2.10.1

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,79 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project follows
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html) from 1.0.0.
6
6
 
7
+ ## [2.10.1] — 2026-11-15
8
+
9
+ Three more ways a scope mistake could have answered with the wrong data. Found by checking
10
+ the last unverified claims from the audit instead of trusting the previous pass.
11
+
12
+ ### Fixed
13
+
14
+ - **`/api/loci/preview?cwd=…` returned 400.** The route read `resolved.readKeys` and
15
+ `resolved.label`, neither of which `resolveScopes` returns, so any caller that supplied a
16
+ `cwd` or `session` got `Cannot read properties of undefined (reading 'length')`. The panel
17
+ never sends those parameters, which is why it survived: a latent failure on the one
18
+ endpoint that answers "what does *this* conversation get". `/api/loci/knowledge` had the
19
+ same defect.
20
+
21
+ - **An explicit empty scope set meant "the whole store".** `stats({ scopeKeys: [] })` and
22
+ `usageReport({ scopeKeys: [] })` dropped the filter, while `list([])` and
23
+ `countPending([])` returned nothing. That asymmetry turns a scope-resolution failure into
24
+ a confident answer about every project on the machine. Now: omitted means the whole
25
+ database (an admin question), `[]` means nothing.
26
+
27
+ - **`update`, `pin`, `unpin`, `restore` and `merge` ignored the read set.** Only `loci_get`
28
+ and `loci_forget` were guarded in 2.10.0. `update` moved a foreign entry into the calling
29
+ session's scope, `pin` changed how it sorts in another project's index, and `restore`
30
+ could un-delete an entry a different project had removed.
31
+
32
+ ### Note
33
+
34
+ `test/unit/scope-semantics.test.mjs` pins all three. The empty-versus-absent distinction is
35
+ asserted in both directions, because the failure mode is a silent widening rather than a
36
+ crash.
37
+
38
+ ## [2.10.0] — 2026-11-15
39
+
40
+ The last four findings from the audit. All four are the same shape: an operation that
41
+ trusted an input it should have checked.
42
+
43
+ ### Fixed
44
+
45
+ - **`snapshot --label` could write outside the memory root.** The label came from the
46
+ command line and was joined straight into a path, so `--label ../../tmp/x` produced a
47
+ database outside `$DSH_HOME/memory/loci/`. `SECURITY.md` said `assertInside` guarded
48
+ every generated path; this one was not guarded. The label is now reduced to a filename
49
+ *and* the resolved path is asserted.
50
+
51
+ - **`loci_get` and `loci_forget` ignored the scope set.** Every read in the product binds
52
+ an explicit scope-key set except these two, which took an id and used it — so an id
53
+ pasted from another project's chat returned that entry's body, or soft-deleted it. Both
54
+ now refuse an entry outside the session's read set. A miss and a foreign hit answer the
55
+ same way, so the refusal does not confirm what exists elsewhere.
56
+
57
+ - **An MCP write with no scope pooled in `session:unknown`.** `readScopeKeys(scopes, {
58
+ scope: undefined })[0]` is the *session* key, and a facade has no session — so every MCP
59
+ server on the machine shared that one key while no Host session ever read it. Proposals
60
+ were invisible to the review tab. A scope-less write now lands in the workspace the
61
+ facade is serving.
62
+
63
+ - **Capture turned ordinary sentences into verified rules.** Bare `别` matched inside
64
+ 级别, 特别, 识别, 区别 and 分别, and `要不要…?` matched the 不要 rule. Measured before
65
+ the change: *"我特别想看 diff"*, *"两者区别不大,随便选"*, *"我们分别试了两种方案"*,
66
+ *"识别这类错误要靠日志"*, *"这个 bug 的级别是 P1,先处理它"* — five false positives, each
67
+ of which becomes a `verified` line in `Rules (must follow)` with the default
68
+ `autoVerifyCaptured`. An imperative must now open a clause, and a sentence ending in `?`
69
+ is never a statement.
70
+
71
+ Verified against 8 negatives (all rejected) and 10 positives (all captured with the right
72
+ kind: 以后都用…, 不要再改…, 别把密钥…, 禁止…, 必须先…, 我们的约定是…, 记住…, 发布流程是…, 以后一律…, 下次别…).
73
+
74
+ ### Docs
75
+
76
+ - `DESIGN.md` said "every captured row is a `candidate`". That stopped being true when
77
+ `autoVerifyCaptured` shipped and the sentence survived several releases; it now states the
78
+ two doors into the prompt and what the flag controls.
79
+
7
80
  ## [2.9.0] — 2026-11-15
8
81
 
9
82
  R3 was the last rule the product stated and did not keep.
package/docs/DESIGN.md CHANGED
@@ -294,8 +294,13 @@ guess.
294
294
  | plugin unload | flush summaries, close the database | one write set |
295
295
  | maintenance (6 h) | close stale working/candidate rows, repair a drifted FTS index, `PRAGMA optimize`, backfill embeddings | bounded, no model |
296
296
 
297
- Nothing here can grow the prompt: every captured row is a `candidate`, and candidates
298
- are not injected.
297
+ Nothing here can grow the prompt **by itself**: candidates are not injected, and a captured
298
+ statement only becomes injectable through one of two doors — a person approving it, or the
299
+ quote gate, which requires the sentence to be present verbatim in the user's own messages
300
+ *and* related to the claim. `autoVerifyCaptured` (default on) is what makes the second door
301
+ apply to capture; turn it off and every captured row waits for a click. This paragraph used
302
+ to say "every captured row is a `candidate`", which stopped being true when that default
303
+ landed and was left standing for several releases.
299
304
 
300
305
  ---
301
306
 
package/docs/SECURITY.md CHANGED
@@ -13,7 +13,7 @@ file sandbox, approval policy) are the Harness's and are assumed to work.
13
13
  | T2 | Stored text breaks prompt assembly | Every later request throws | `interpolate:false` plus `{{`/`}}` neutralization at render time; `loci doctor` runs a live probe that renders hostile text and fails if any of it survives |
14
14
  | T3 | A credential is written into memory | Secret in a plaintext database and in every future prompt | nine high-confidence patterns + entropy check; default `redact`, optional `reject` |
15
15
  | T4 | An external agent or page writes memory | Poisoned long-term memory | tools are model-facing only through the Host; the MCP facade is read-only by default and can only create candidates; imported entries are candidates |
16
- | T5 | Cross-project leakage | Project A's facts answer in project B | explicit scope-key sets on every read; `global` requires an explicit scope; tested |
16
+ | T5 | Cross-project leakage | Project A's facts answer in project B | explicit scope-key sets on every read, **including the two id-addressed tools** — `loci_get` and `loci_forget` used to take an id and use it, so an id pasted from another project's chat worked; both now refuse an entry outside the session's read set (`test/unit/containment.test.mjs`). `global` is read by default (`includeGlobal`) but written only on request; the MCP facade's scope-less write lands in the workspace, never in the shared `session:unknown` |
17
17
  | T6 | The panel API is reachable from another origin | Local admin surface exposed to a web page | exact `connection.fetch` route (owner Peer, same-origin fence), JSON-only writes, 256 KB body cap, no CORS headers |
18
18
  | T7 | Data written outside the Harness home | Un-backed-up, un-scoped secrets | single root `$DSH_HOME/memory/loci/`, every path asserted with `assertInside` |
19
19
  | T8 | A write blocks the agent loop | Turn hangs | WAL + bounded busy timeout + six retries; transactions are single-digit milliseconds; capture is debounced and never awaited by the turn |
@@ -54,7 +54,10 @@ schema and re-derived by the store. The MCP facade cannot either.
54
54
  ## 4. Filesystem
55
55
 
56
56
  - One root: `$DSH_HOME/memory/loci/`. Resolved as config → `$DSH_HOME` → `~/.dsh`.
57
- - `assertInside` guards export, import and every generated path.
57
+ - `assertInside` guards export, import, `snapshot` and every generated path. The snapshot
58
+ label comes from a command line and used to be joined straight into a path, so
59
+ `--label ../../tmp/x` wrote a database outside the memory root; the label is now reduced
60
+ to a filename **and** the resolved path is asserted (`test/unit/containment.test.mjs`).
58
61
  - File permissions are the OS default; Loci does not implement encryption. Treat the
59
62
  directory like any other plaintext store of notes (BitLocker/FileVault, user ACLs).
60
63
  - Uninstall leaves the data behind by design (see OPERATIONS §7).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-loci",
3
- "version": "2.9.0",
3
+ "version": "2.10.1",
4
4
  "description": "Layered long-term memory for DeepSeek Harness: the prompt sees only a stable index, full text is one lookup away. Bi-temporal facts, strict scope isolation, verified-only injection.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -116,8 +116,15 @@ export function registerApiRoutes(ctx, deps) {
116
116
  const sessionId = url.searchParams.get('session') ?? undefined;
117
117
  const scoped = cwd !== undefined || sessionId !== undefined;
118
118
  const resolved = scoped ? resolveScopes({ cwd, sessionId }) : undefined;
119
- const scopeKeys = scoped ? resolved.readKeys : store.allScopeKeys();
120
- const scopeLabel = scoped ? resolved.label : `all ${scopeKeys.length} stored scope(s)`;
119
+ // `resolveScopes` returns `{session, workspace, project, global, labels, cwd,
120
+ // projectRoot}` — there is no `readKeys` and no `label`, so this route threw
121
+ // "Cannot read properties of undefined (reading 'length')" whenever a caller supplied
122
+ // `?cwd=`. The panel never does, which is why it survived: a latent 400 on the one
123
+ // endpoint that answers "what does this conversation get".
124
+ const scopeKeys = scoped ? readScopeKeys(resolved, { includeGlobal: config.includeGlobal !== false }) : store.allScopeKeys();
125
+ const scopeLabel = scoped
126
+ ? [resolved.labels.workspace, resolved.labels.project].filter((value, index, all) => all.indexOf(value) === index).join(' / ')
127
+ : `all ${scopeKeys.length} stored scope(s)`;
121
128
  return json({
122
129
  ...renderIndexBody({
123
130
  store,
@@ -153,7 +160,7 @@ export function registerApiRoutes(ctx, deps) {
153
160
  const scopeKeys =
154
161
  cwd === undefined && sessionId === undefined
155
162
  ? store.allScopeKeys()
156
- : resolveScopes({ cwd, sessionId }).readKeys;
163
+ : readScopeKeys(resolveScopes({ cwd, sessionId }), { includeGlobal: config.includeGlobal !== false });
157
164
  return json(buildKnowledge(store, { limit, scopeKeys }));
158
165
  });
159
166
 
@@ -23,20 +23,45 @@ const MESSAGE_COUNT_CAP = 40;
23
23
  /** Buffered bytes per session. */
24
24
  const BUFFER_BYTE_CAP = 24 * 1024;
25
25
 
26
- /** Sentence patterns that mark a durable statement, with the kind they imply. */
26
+ /**
27
+ * Sentence patterns that mark a durable statement, with the kind they imply.
28
+ *
29
+ * These run on every user message by default, so a false positive does not stay a
30
+ * candidate — with `autoVerifyCaptured` on it becomes a `verified` rule in the
31
+ * `Rules (must follow)` block. Bare substring matching was therefore expensive: `别`
32
+ * matched inside 级别, 特别, 识别, 区别 and 分别, and five ordinary sentences were measured
33
+ * turning into rules ("我特别想看 diff", "两者区别不大,随便选"). An imperative is now
34
+ * required to open a clause, and `不要` cannot be the tail of `要不要`.
35
+ */
36
+ const CLAUSE_START = String.raw`(?:^|[,,、。;;::!?!?\s]|但是|然后|所以|而且|并且|另外)`;
27
37
  const DURABLE_PATTERNS = Object.freeze([
28
- { re: /(?:以后|今后|下次)(?:都|一律|必须|要|不要|别)/, kind: 'rule' },
29
- { re: /(?:不要|别|禁止|不得|不允许|不能再|不可以)/, kind: 'rule' },
38
+ // A forward-looking commitment. The time word is what makes it strong regardless of
39
+ // where the clause begins.
40
+ { re: /(?:以后|今后|下次)(?:都|一律|必须|要|不要|别再?|禁止)/, kind: 'rule' },
41
+ {
42
+ re: new RegExp(`${CLAUSE_START}(?:不要|别再?|禁止|不得|不允许|不可以)`, 'u'),
43
+ kind: 'rule',
44
+ },
30
45
  { re: /(?:必须先|一定要|务必|记得先|一定要先)/, kind: 'procedural' },
31
46
  { re: /(?:流程是|步骤是|做法是|顺序是)/, kind: 'procedural' },
32
47
  { re: /(?:记住|记一下|记下来|帮我记住)/, kind: 'semantic' },
33
48
  { re: /(?:我的|我们的)(?:偏好|习惯|约定|规则|要求|标准)/, kind: 'semantic' },
34
- { re: /(?:约定|规则是|标准是|原则是)/, kind: 'semantic' },
49
+ // `约定` on its own reads as a reference ("按约定处理"), not an announcement, so it
50
+ // needs the copula the way the others already had it.
51
+ { re: /(?:约定是|规则是|标准是|原则是)/, kind: 'semantic' },
35
52
  { re: /\b(?:always|never|from now on|remember that|we agreed|our rule)\b/i, kind: 'rule' },
36
53
  ]);
37
54
 
38
55
  /** Sentence separators for the extractor. */
39
56
  const SENTENCE_SPLIT = /[。!?!?;;\n]+/;
57
+ /**
58
+ * A sentence *plus* the punctuation that ended it.
59
+ *
60
+ * `split()` throws the terminator away, and whether a segment ended in `?` is the
61
+ * difference between a statement and a question — `要不要一起吃饭?` matched the 不要
62
+ * pattern and became a rule because that information was gone.
63
+ */
64
+ const SENTENCE_WITH_END = /([^。!?!?;;\n]+)([。!?!?;;\n]*)/g;
40
65
 
41
66
  /**
42
67
  * Flatten message content into plain text.
@@ -483,8 +508,13 @@ export function extractDurableStatements(userMessages, maxChars = 200) {
483
508
  const out = [];
484
509
  const seen = new Set();
485
510
  for (const message of userMessages) {
486
- for (const raw of String(message.text ?? '').split(SENTENCE_SPLIT)) {
487
- const sentence = raw.replace(/\s+/g, ' ').trim();
511
+ // Keep the terminator: whether a segment ended in a question mark decides whether it
512
+ // can be a statement at all. `要不要一起吃饭?` used to match the 不要 pattern and
513
+ // become a rule; split() discards the separator, so the distinction was unavailable.
514
+ for (const match of String(message.text ?? '').matchAll(SENTENCE_WITH_END)) {
515
+ const terminator = match[2] ?? '';
516
+ if (terminator === '?' || terminator === '?') continue;
517
+ const sentence = String(match[1] ?? '').replace(/\s+/g, ' ').trim();
488
518
  if (sentence.length < 6 || sentence.length > maxChars) continue;
489
519
  const matched = DURABLE_PATTERNS.find((pattern) => pattern.re.test(sentence));
490
520
  if (matched === undefined) continue;
package/src/host/index.js CHANGED
@@ -23,7 +23,7 @@ import { registerCommands } from './commands.js';
23
23
  import { runDoctor } from './doctor.js';
24
24
 
25
25
  /** Version stamped into the database and diagnostics. */
26
- export const VERSION = '2.9.0';
26
+ export const VERSION = '2.10.1';
27
27
 
28
28
  /** Optional collaborators; without them the plugin still serves tools. */
29
29
  export const inject = ['systemPrompt', 'tools'];
@@ -23,6 +23,7 @@ import { EmbeddingChannel, vectorToBlob } from './embedding.js';
23
23
  import { META_KEYS, SCHEMA_VERSION, databaseVersion, readMeta, writeMeta } from './schema.js';
24
24
  import { normalizeText, tokenStream, tokenize, buildQueryPlan } from './tokenize.js';
25
25
  import { cleanText, normalizeTitle, prepareForStorage } from '../inject/sanitize.js';
26
+ import { assertInside } from '../paths.js';
26
27
 
27
28
  /**
28
29
  * Relations a caller may declare between two facts.
@@ -1047,8 +1048,29 @@ export class Store {
1047
1048
  */
1048
1049
  stats(options = {}) {
1049
1050
  const keys = options.scopeKeys ?? [];
1050
- const filtered = keys.length > 0;
1051
- const marks = filtered ? ` AND m.scope_key IN (${keys.map(() => '?').join(', ')})` : '';
1051
+ // undefined means nobody asked for a scope (an admin whole-store view); [] means
1052
+ // someone asked for a scope and got no keys — which must answer *nothing*, not
1053
+ // *everything*. It used to drop the filter in both cases, so a scope-resolution
1054
+ // failure turned into a confident answer about every project on the machine.
1055
+ if (Array.isArray(options.scopeKeys) && options.scopeKeys.length === 0) {
1056
+ return {
1057
+ schemaVersion: this.schemaVersion(),
1058
+ indexRev: this.indexRev(),
1059
+ revisions: 0,
1060
+ heads: 0,
1061
+ byStatus: {},
1062
+ byKind: {},
1063
+ byScope: {},
1064
+ hostVersion: this.hostVersion ?? readMeta(this.db, META_KEYS.hostVersion) ?? null,
1065
+ pendingCandidates: 0,
1066
+ disputed: 0,
1067
+ lastInjection: null,
1068
+ dbBytes: existsSync(this.paths.db) ? statSync(this.paths.db).size : 0,
1069
+ vector: { ...this.vector, ...this.embedding.health() },
1070
+ memoryRoot: this.memoryRoot,
1071
+ };
1072
+ }
1073
+ const marks = keys.length > 0 ? ` AND m.scope_key IN (${keys.map(() => '?').join(', ')})` : '';
1052
1074
  const byStatus = {};
1053
1075
  for (const row of this.db
1054
1076
  .prepare(
@@ -1199,6 +1221,9 @@ export class Store {
1199
1221
  usageReport(options = {}) {
1200
1222
  const keys = options.scopeKeys ?? [];
1201
1223
  const limit = Math.max(1, Math.min(200, options.limit ?? 20));
1224
+ // Same rule as `stats`: an explicit empty list means no rows; an absent one means the
1225
+ // whole store, which is what the panel's admin report asks for.
1226
+ if (Array.isArray(options.scopeKeys) && options.scopeKeys.length === 0) return [];
1202
1227
  const marks = keys.length > 0 ? ` AND m.scope_key IN (${keys.map(() => '?').join(', ')})` : '';
1203
1228
  const rows = this.db
1204
1229
  .prepare(
@@ -1423,7 +1448,14 @@ export class Store {
1423
1448
  */
1424
1449
  snapshot(label = 'manual') {
1425
1450
  this.#assertWritable();
1426
- return backupTo(this.db, join(this.paths.backups, `${label}-${this.now()}.db`));
1451
+ // The label comes from a command line and was joined straight into a path, so
1452
+ // `--label ../../tmp/x` wrote a database outside the memory root. SECURITY §4 said
1453
+ // `assertInside` guarded every generated path; this one was not guarded. Sanitise the
1454
+ // label to a filename, then assert the resolved path anyway — belt and braces, because
1455
+ // the assertion is the part that would survive a future change to the sanitiser.
1456
+ const safeLabel = String(label).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 64) || 'manual';
1457
+ const target = join(this.paths.backups, `${safeLabel}-${this.now()}.db`);
1458
+ return backupTo(this.db, assertInside(this.paths.backups, target));
1427
1459
  }
1428
1460
 
1429
1461
  /** Health checks used by `doctor`. */
@@ -82,6 +82,28 @@ function strList(args, key, options = {}) {
82
82
  * @param exec - tool execution context.
83
83
  * @returns `{sessionId, scopes, header}`.
84
84
  */
85
+ /**
86
+ * Refuse an id-addressed operation that points outside this session's scopes.
87
+ *
88
+ * Reads were scoped everywhere except here: `loci_get` and `loci_forget` took an id and
89
+ * used it, so a model that had seen an id from another project could read its body or
90
+ * soft-delete it. SECURITY T5 says every read binds an explicit scope-key set; by id it
91
+ * did not. The check runs after the lookup, so a miss and a foreign hit answer the same
92
+ * way — the caller learns nothing about what exists elsewhere.
93
+ *
94
+ * @param store - open store.
95
+ * @param id - the id the caller supplied.
96
+ * @param scopeKeys - this session's explicit read set.
97
+ * @param operation - name used in the error message.
98
+ * @throws when the entry exists but is outside the scope set.
99
+ */
100
+ function assertInScope(store, id, scopeKeys, operation) {
101
+ const found = store.get(id);
102
+ if (found?.entry === undefined) return;
103
+ if (scopeKeys.includes(found.entry.scopeKey)) return;
104
+ throw new Error(`${operation}: ${id} is not in this session's scope`);
105
+ }
106
+
85
107
  function execContext(exec) {
86
108
  const session = exec?.agent?.session;
87
109
  const sessionId = session?.id;
@@ -187,11 +209,18 @@ export function createTools(deps) {
187
209
  const context = execContext(exec);
188
210
  const op = oneOf(args, 'op', ['create', 'update', 'merge', 'pin', 'unpin', 'restore'], 'create');
189
211
  const id = str(args, 'id', { fallback: undefined });
212
+ // Every id-addressed op has to respect the read set, not just the two literally named
213
+ // get/forget. `update` moved a foreign entry into this session's scope, `pin` changed
214
+ // how it sorts in another project's index, and `restore` could un-delete something a
215
+ // different project had removed.
216
+ const idScopeKeys = () => readScopeKeys(context.scopes, { includeGlobal: config('includeGlobal') });
217
+ if (id !== undefined && op !== 'create') assertInScope(store, id, idScopeKeys(), `loci_write ${op}`);
190
218
 
191
219
  if (op === 'merge') {
192
220
  if (id === undefined) throw new Error('id is required for merge (the entry to keep)');
193
221
  const sourceIds = Array.isArray(args?.sourceIds) ? args.sourceIds.filter((value) => typeof value === 'string') : [];
194
222
  if (sourceIds.length === 0) throw new Error('sourceIds is required for merge (at least one id to fold in)');
223
+ for (const sourceId of sourceIds) assertInScope(store, sourceId, idScopeKeys(), 'loci_write merge source');
195
224
  const result = store.merge({
196
225
  targetId: id,
197
226
  sourceIds,
@@ -366,10 +395,12 @@ export function createTools(deps) {
366
395
  },
367
396
  timeoutMs: 10_000,
368
397
  isConcurrencySafe: () => true,
369
- execute: async (args) => {
398
+ execute: async (args, exec) => {
370
399
  const id = str(args, 'id', { required: true, maxChars: 64 });
371
400
  const revision = int(args, 'revision', { min: 1 });
372
401
  const includeHistory = bool(args, 'includeHistory', true);
402
+ const context = execContext(exec);
403
+ assertInScope(store, id, readScopeKeys(context.scopes, { includeGlobal: config('includeGlobal') }), 'loci_get');
373
404
  const result = store.get(id, { revision, includeHistory });
374
405
  // A read counter is what separates "the index line was enough" from "the model
375
406
  // had to open it". It must never fail the read itself.
@@ -406,10 +437,14 @@ export function createTools(deps) {
406
437
  },
407
438
  timeoutMs: 10_000,
408
439
  isConcurrencySafe: () => false,
409
- execute: async (args) => {
440
+ execute: async (args, exec) => {
410
441
  const id = str(args, 'id', { required: true, maxChars: 64 });
411
442
  const mode = oneOf(args, 'mode', ['soft', 'restore'], 'soft');
412
443
  const reason = str(args, 'reason', { fallback: undefined, maxChars: 200 });
444
+ const context = execContext(exec);
445
+ // Without this, a model that had seen an id from another project could soft-delete
446
+ // it. Deletion is the one operation where an unscoped id is worse than a bad read.
447
+ assertInScope(store, id, readScopeKeys(context.scopes, { includeGlobal: config('includeGlobal') }), 'loci_forget');
413
448
  return store.forget(id, { mode, reason, actor: 'model' });
414
449
  },
415
450
  };
package/src/mcp/server.js CHANGED
@@ -162,11 +162,17 @@ async function callTool(name, args) {
162
162
  }
163
163
  case 'loci_write': {
164
164
  if (!allowWrite) throw new Error('this server is read-only; start it with --allow-write to propose memories');
165
- const scopeKeys = readScopeKeys(scopes, { scope: requestedScope(args) });
166
- const scopeKey = scopeKeys[0] ?? scopes.workspace;
165
+ // Never fall back to `scopeKeys[0]`. With no scope requested that is
166
+ // `session:unknown` — a key every MCP server on the machine shares and no Host
167
+ // session ever reads, so proposals pooled in a scope the user could not see in the
168
+ // panel's review tab. A facade has no session, so the honest default is the
169
+ // workspace it is serving.
170
+ const requested = requestedScope(args);
171
+ const writeScope = requested === undefined || requested === 'session' ? 'workspace' : requested;
172
+ const scopeKey = readScopeKeys(scopes, { scope: writeScope, includeGlobal: false })[0] ?? scopes.workspace;
167
173
  const result = store.write({
168
174
  kind: ['semantic', 'procedural', 'rule', 'episodic'].includes(args?.kind) ? args.kind : 'semantic',
169
- scope: ['workspace', 'project', 'global'].includes(args?.scope) ? args.scope : 'workspace',
175
+ scope: writeScope,
170
176
  scopeKey,
171
177
  scopeLabel: 'mcp',
172
178
  title: String(args?.title ?? ''),
@@ -36,7 +36,7 @@ export function openFacadeStore(options = {}) {
36
36
  memoryRoot: root,
37
37
  paths,
38
38
  config,
39
- pluginVersion: '2.9.0',
39
+ pluginVersion: '2.10.1',
40
40
  readOnly: options.readOnly === true,
41
41
  });
42
42
  return { store, paths };