dsh-loci 2.8.0 → 2.10.0

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,84 @@ 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.0] — 2026-11-15
8
+
9
+ The last four findings from the audit. All four are the same shape: an operation that
10
+ trusted an input it should have checked.
11
+
12
+ ### Fixed
13
+
14
+ - **`snapshot --label` could write outside the memory root.** The label came from the
15
+ command line and was joined straight into a path, so `--label ../../tmp/x` produced a
16
+ database outside `$DSH_HOME/memory/loci/`. `SECURITY.md` said `assertInside` guarded
17
+ every generated path; this one was not guarded. The label is now reduced to a filename
18
+ *and* the resolved path is asserted.
19
+
20
+ - **`loci_get` and `loci_forget` ignored the scope set.** Every read in the product binds
21
+ an explicit scope-key set except these two, which took an id and used it — so an id
22
+ pasted from another project's chat returned that entry's body, or soft-deleted it. Both
23
+ now refuse an entry outside the session's read set. A miss and a foreign hit answer the
24
+ same way, so the refusal does not confirm what exists elsewhere.
25
+
26
+ - **An MCP write with no scope pooled in `session:unknown`.** `readScopeKeys(scopes, {
27
+ scope: undefined })[0]` is the *session* key, and a facade has no session — so every MCP
28
+ server on the machine shared that one key while no Host session ever read it. Proposals
29
+ were invisible to the review tab. A scope-less write now lands in the workspace the
30
+ facade is serving.
31
+
32
+ - **Capture turned ordinary sentences into verified rules.** Bare `别` matched inside
33
+ 级别, 特别, 识别, 区别 and 分别, and `要不要…?` matched the 不要 rule. Measured before
34
+ the change: *"我特别想看 diff"*, *"两者区别不大,随便选"*, *"我们分别试了两种方案"*,
35
+ *"识别这类错误要靠日志"*, *"这个 bug 的级别是 P1,先处理它"* — five false positives, each
36
+ of which becomes a `verified` line in `Rules (must follow)` with the default
37
+ `autoVerifyCaptured`. An imperative must now open a clause, and a sentence ending in `?`
38
+ is never a statement.
39
+
40
+ Verified against 8 negatives (all rejected) and 10 positives (all captured with the right
41
+ kind: 以后都用…, 不要再改…, 别把密钥…, 禁止…, 必须先…, 我们的约定是…, 记住…, 发布流程是…, 以后一律…, 下次别…).
42
+
43
+ ### Docs
44
+
45
+ - `DESIGN.md` said "every captured row is a `candidate`". That stopped being true when
46
+ `autoVerifyCaptured` shipped and the sentence survived several releases; it now states the
47
+ two doors into the prompt and what the flag controls.
48
+
49
+ ## [2.9.0] — 2026-11-15
50
+
51
+ R3 was the last rule the product stated and did not keep.
52
+
53
+ ### Fixed
54
+
55
+ - **The recall assist was not reproducible.** R3 requires the conditional path to evaluate
56
+ over a reproducible snapshot: the same session replayed must produce the same result, and
57
+ the outcome must not drift with a moving clock. `search` already accepted a `now` for its
58
+ recency term and its time-window filter — but `renderAssistBlock` never passed one, so it
59
+ silently took `Date.now()` and the same store ranked differently on replay. The sentence
60
+ in the document was not a property of the code.
61
+
62
+ `renderAssistBlock` now takes `now` and threads it through, and the caller reads the clock
63
+ **once**: that single instant is used for the evaluation *and* for
64
+ `recordInjection({ ts })`. Measured:
65
+
66
+ | | Result |
67
+ |---|---|
68
+ | same snapshot, same `now`, twice | identical ids, identical order |
69
+ | same snapshot, `now` pushed 400 days | same set, order swapped |
70
+
71
+ That second row is the whole argument for passing the instant in: the dependency exists,
72
+ so it has to be an explicit input rather than a hidden `Date.now()`.
73
+
74
+ - **The record now names its snapshot.** The `inject` audit line carries `ids`,
75
+ `source`, and the store's `indexRev` alongside the timestamp, so "why these entries"
76
+ is answerable after the fact instead of being asserted.
77
+
78
+ ### Note
79
+
80
+ All five rules in §2.1 of `docs/PRIOR-ART.md` are now marked against the code — R1 and R2 as
81
+ written-down exceptions, R3 and R4 as enforced since 2.9.0 and 2.8.0, R5 as always true.
82
+ Two of the five were violations when the audit found them, and both were fixed rather than
83
+ re-worded.
84
+
7
85
  ## [2.8.0] — 2026-11-15
8
86
 
9
87
  Two real defects, both found by measuring rather than reading: search returned nothing for
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/PRIOR-ART.md CHANGED
@@ -136,7 +136,7 @@ R-b 只在**正文参与判定**时成立。由人工核实过的结构化字段
136
136
 
137
137
  1. **R1 正文不参与注入选取**(限定为索引段):**索引段**的选取不读正文 —— SELECT 里没有 body,只有 id/kind/date/title。`recallAssist` 是**明确的例外**:按正文检索、每会话一次、注入为 user message、有字节上限、默认关闭。例外不算违规,前提是它被写下来且被单独约束(R3′/R4/R5)。将来任何按正文选取注入集的提案,仍然按拒绝处理。
138
138
  2. **R2 提前一个边界,而非禁止改变**:条件求值不得发生在**步内**;允许发生在会话/日边界。已发货的待审计数是**边界例外**:它随写入变化,代价是前缀可能失效,这是知情选择(§2.1 R-a 记为未测量的经济赌注),不是疏忽。
139
- 3. **R3 只允许在会话边界求值**,且输入必须是可复现快照。**当前 `recallAssist` 只满足前半句**:它读实时库并用墙钟做时效加权,同一库重放可能得到不同排序。所以 R3 目前是一条**待实现**的规则,不是已满足的事实。
139
+ 3. **R3 只允许在会话边界求值**,且输入必须是可复现快照。**2.9.0 起两条都满足**:边界一直是 `payload.step === 1`;快照现在也是真的 —— `renderAssistBlock` 接收 `now` 并传给 `search`(时效项与时间窗过滤都依赖它),此前它没传、于是默默用 `Date.now()`,同一个库重放就会得到不同排序。实测:同一 `now` 两次 `ids` 完全一致且顺序一致;把 `now` 推后 400 天则同集合、顺序对调 —— 这正是必须把时刻作为输入的理由。记录行同时写下求值时刻与 `indexRev`,所以「为什么是这批」事后可核对。
140
140
  4. **R4 可解释**:每条被条件选中的条目,必须在面板或审计里回答"为什么这次出现"。**索引段一直满足;`recallAssist` 在 2.8.0 起满足** —— 它此前不写 `request_stat`、不写 `memory_usage`,审计行里既没有 id 也没有 session,等于这个产品唯一的条件注入路径无法自证。现在 `request_stat.source` 区分 `section`/`assist`,审计行带 id,per-entry 计数照常累加,`renderAssistBlock` 返回 `{text, ids, bytes}` 而不是一段裸文本。
141
141
  5. **R5 可选且有界**:默认关闭、有字节上限、可随时关掉。**已满足**(`recallAssist` 默认 `off`,`recallAssistBytes` 默认 512、上限 2048,每会话一次)。
142
142
 
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.8.0",
3
+ "version": "2.10.0",
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",
@@ -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.8.0';
26
+ export const VERSION = '2.10.0';
27
27
 
28
28
  /** Optional collaborators; without them the plugin still serves tools. */
29
29
  export const inject = ['systemPrompt', 'tools'];
@@ -231,6 +231,12 @@ export function apply(ctx, rawConfig = {}) {
231
231
  if (query.length < 2) return decision;
232
232
  const scopes = resolveScopes({ sessionId: session.id, cwd: session.header?.cwd });
233
233
  const assistScopeKeys = readScopeKeys(scopes, { includeGlobal: read('includeGlobal') });
234
+ // One instant, used for the evaluation *and* the record. Reading the clock
235
+ // twice would make the recorded time disagree with the time the ranking saw,
236
+ // and R3 asks that a replay of (snapshot, query, scopes, now) reproduce the
237
+ // same ids — which only holds if both halves use the same value.
238
+ const evaluatedAt = Date.now();
239
+ const snapshotRev = store.indexRev?.() ?? null;
234
240
  const assist = await renderAssistBlock({
235
241
  store,
236
242
  scopeKeys: assistScopeKeys,
@@ -238,6 +244,7 @@ export function apply(ctx, rawConfig = {}) {
238
244
  maxBytes: read('recallAssistBytes'),
239
245
  sessionId: session.id,
240
246
  scopeKey: scopes.workspace,
247
+ now: evaluatedAt,
241
248
  });
242
249
  assisted.add(session.id);
243
250
  if (assisted.size > ASSIST_MEMORY) assisted.clear();
@@ -254,6 +261,8 @@ export function apply(ctx, rawConfig = {}) {
254
261
  hidden: 0,
255
262
  ids: assist.ids,
256
263
  source: 'assist',
264
+ ts: evaluatedAt,
265
+ indexRev: snapshotRev,
257
266
  });
258
267
  } catch (error) {
259
268
  // Recording must never break the turn it is recording.
@@ -279,13 +279,18 @@ export function createIndexSection(options) {
279
279
  * @returns `{ text, ids, bytes }`; `text` is `''` when nothing was rendered.
280
280
  */
281
281
  export async function renderAssistBlock(options) {
282
- const { store, scopeKeys, query, maxBytes = 512, limit = 3, sessionId, scopeKey } = options;
282
+ const { store, scopeKeys, query, maxBytes = 512, limit = 3, sessionId, scopeKey, now } = options;
283
283
  const empty = { text: '', ids: [], bytes: 0 };
284
284
  if (typeof query !== 'string' || query.trim().length < 2) return empty;
285
285
  // `sessionId`/`scopeKey` go into the search call so its own audit row can say which
286
286
  // session and scope asked. They used to be omitted, which made the recall audit line
287
287
  // unattributable even when it was written.
288
- const result = await search(store, { query, scopeKeys, statuses: ['verified'], limit, detail: 'snippet', sessionId, scopeKey });
288
+ //
289
+ // `now` is threaded through rather than read here: search's recency term and its
290
+ // time-window filter both key off it, so leaving it undefined makes the same store rank
291
+ // differently on replay. Passed explicitly, this evaluation is a pure function of
292
+ // (store snapshot, query, scope keys, now) — which is what R3 asks for.
293
+ const result = await search(store, { query, scopeKeys, statuses: ['verified'], limit, detail: 'snippet', sessionId, scopeKey, now });
289
294
  if (result.hits.length === 0) return empty;
290
295
  const lines = ['<loci-recall note="possibly relevant; confirm with loci_get before relying on it">'];
291
296
  const ids = [];
@@ -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.
@@ -1123,7 +1124,7 @@ export class Store {
1123
1124
 
1124
1125
  /**
1125
1126
  * Record one injected section, so the cost of each request is queryable.
1126
- * @param row - `sessionId`, `scopeKey`, `bytes`, `listed`, `hidden`.
1127
+ * @param row - `sessionId`, `scopeKey`, `bytes`, `listed`, `hidden`, `ids`, `source`, `ts`.
1127
1128
  */
1128
1129
  recordInjection(row) {
1129
1130
  // `source` separates the two injection surfaces. The index runs every request; the
@@ -1135,7 +1136,7 @@ export class Store {
1135
1136
  .prepare(
1136
1137
  'INSERT INTO request_stat (ts, session_id, scope_key, section_bytes, entries_listed, entries_hidden, source) VALUES (?, ?, ?, ?, ?, ?, ?)',
1137
1138
  )
1138
- .run(this.now(), row.sessionId ?? null, row.scopeKey ?? null, row.bytes, row.listed, row.hidden, source);
1139
+ .run(row.ts ?? this.now(), row.sessionId ?? null, row.scopeKey ?? null, row.bytes, row.listed, row.hidden, source);
1139
1140
  // Per-entry counters, so "which line earns its bytes" stays answerable after the
1140
1141
  // aggregate rows rotate out. A listed entry never fetched by id is the dead
1141
1142
  // weight this product exists to find.
@@ -1161,6 +1162,10 @@ export class Store {
1161
1162
  // The ids are what makes the line answerable. Without them an audit record says
1162
1163
  // "3 entries were injected" and nothing about which three.
1163
1164
  ids: Array.isArray(row.ids) ? row.ids : undefined,
1165
+ // Which store state produced this. Together with `ts` and the ids it is enough to
1166
+ // replay the decision and confirm the snapshot, which is what R3 means by
1167
+ // reproducible — recording only the outcome would make the claim unfalsifiable.
1168
+ indexRev: Number.isFinite(row.indexRev) ? row.indexRev : this.indexRev(),
1164
1169
  });
1165
1170
  }
1166
1171
 
@@ -1419,7 +1424,14 @@ export class Store {
1419
1424
  */
1420
1425
  snapshot(label = 'manual') {
1421
1426
  this.#assertWritable();
1422
- return backupTo(this.db, join(this.paths.backups, `${label}-${this.now()}.db`));
1427
+ // The label comes from a command line and was joined straight into a path, so
1428
+ // `--label ../../tmp/x` wrote a database outside the memory root. SECURITY §4 said
1429
+ // `assertInside` guarded every generated path; this one was not guarded. Sanitise the
1430
+ // label to a filename, then assert the resolved path anyway — belt and braces, because
1431
+ // the assertion is the part that would survive a future change to the sanitiser.
1432
+ const safeLabel = String(label).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 64) || 'manual';
1433
+ const target = join(this.paths.backups, `${safeLabel}-${this.now()}.db`);
1434
+ return backupTo(this.db, assertInside(this.paths.backups, target));
1423
1435
  }
1424
1436
 
1425
1437
  /** 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;
@@ -366,10 +388,12 @@ export function createTools(deps) {
366
388
  },
367
389
  timeoutMs: 10_000,
368
390
  isConcurrencySafe: () => true,
369
- execute: async (args) => {
391
+ execute: async (args, exec) => {
370
392
  const id = str(args, 'id', { required: true, maxChars: 64 });
371
393
  const revision = int(args, 'revision', { min: 1 });
372
394
  const includeHistory = bool(args, 'includeHistory', true);
395
+ const context = execContext(exec);
396
+ assertInScope(store, id, readScopeKeys(context.scopes, { includeGlobal: config('includeGlobal') }), 'loci_get');
373
397
  const result = store.get(id, { revision, includeHistory });
374
398
  // A read counter is what separates "the index line was enough" from "the model
375
399
  // had to open it". It must never fail the read itself.
@@ -406,10 +430,14 @@ export function createTools(deps) {
406
430
  },
407
431
  timeoutMs: 10_000,
408
432
  isConcurrencySafe: () => false,
409
- execute: async (args) => {
433
+ execute: async (args, exec) => {
410
434
  const id = str(args, 'id', { required: true, maxChars: 64 });
411
435
  const mode = oneOf(args, 'mode', ['soft', 'restore'], 'soft');
412
436
  const reason = str(args, 'reason', { fallback: undefined, maxChars: 200 });
437
+ const context = execContext(exec);
438
+ // Without this, a model that had seen an id from another project could soft-delete
439
+ // it. Deletion is the one operation where an unscoped id is worse than a bad read.
440
+ assertInScope(store, id, readScopeKeys(context.scopes, { includeGlobal: config('includeGlobal') }), 'loci_forget');
413
441
  return store.forget(id, { mode, reason, actor: 'model' });
414
442
  },
415
443
  };
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.8.0',
39
+ pluginVersion: '2.10.0',
40
40
  readOnly: options.readOnly === true,
41
41
  });
42
42
  return { store, paths };