dsh-loci 2.8.0 → 2.9.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,42 @@ 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.9.0] — 2026-11-15
8
+
9
+ R3 was the last rule the product stated and did not keep.
10
+
11
+ ### Fixed
12
+
13
+ - **The recall assist was not reproducible.** R3 requires the conditional path to evaluate
14
+ over a reproducible snapshot: the same session replayed must produce the same result, and
15
+ the outcome must not drift with a moving clock. `search` already accepted a `now` for its
16
+ recency term and its time-window filter — but `renderAssistBlock` never passed one, so it
17
+ silently took `Date.now()` and the same store ranked differently on replay. The sentence
18
+ in the document was not a property of the code.
19
+
20
+ `renderAssistBlock` now takes `now` and threads it through, and the caller reads the clock
21
+ **once**: that single instant is used for the evaluation *and* for
22
+ `recordInjection({ ts })`. Measured:
23
+
24
+ | | Result |
25
+ |---|---|
26
+ | same snapshot, same `now`, twice | identical ids, identical order |
27
+ | same snapshot, `now` pushed 400 days | same set, order swapped |
28
+
29
+ That second row is the whole argument for passing the instant in: the dependency exists,
30
+ so it has to be an explicit input rather than a hidden `Date.now()`.
31
+
32
+ - **The record now names its snapshot.** The `inject` audit line carries `ids`,
33
+ `source`, and the store's `indexRev` alongside the timestamp, so "why these entries"
34
+ is answerable after the fact instead of being asserted.
35
+
36
+ ### Note
37
+
38
+ All five rules in §2.1 of `docs/PRIOR-ART.md` are now marked against the code — R1 and R2 as
39
+ written-down exceptions, R3 and R4 as enforced since 2.9.0 and 2.8.0, R5 as always true.
40
+ Two of the five were violations when the audit found them, and both were fixed rather than
41
+ re-worded.
42
+
7
43
  ## [2.8.0] — 2026-11-15
8
44
 
9
45
  Two real defects, both found by measuring rather than reading: search returned nothing for
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-loci",
3
- "version": "2.8.0",
3
+ "version": "2.9.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",
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.9.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 = [];
@@ -1123,7 +1123,7 @@ export class Store {
1123
1123
 
1124
1124
  /**
1125
1125
  * Record one injected section, so the cost of each request is queryable.
1126
- * @param row - `sessionId`, `scopeKey`, `bytes`, `listed`, `hidden`.
1126
+ * @param row - `sessionId`, `scopeKey`, `bytes`, `listed`, `hidden`, `ids`, `source`, `ts`.
1127
1127
  */
1128
1128
  recordInjection(row) {
1129
1129
  // `source` separates the two injection surfaces. The index runs every request; the
@@ -1135,7 +1135,7 @@ export class Store {
1135
1135
  .prepare(
1136
1136
  'INSERT INTO request_stat (ts, session_id, scope_key, section_bytes, entries_listed, entries_hidden, source) VALUES (?, ?, ?, ?, ?, ?, ?)',
1137
1137
  )
1138
- .run(this.now(), row.sessionId ?? null, row.scopeKey ?? null, row.bytes, row.listed, row.hidden, source);
1138
+ .run(row.ts ?? this.now(), row.sessionId ?? null, row.scopeKey ?? null, row.bytes, row.listed, row.hidden, source);
1139
1139
  // Per-entry counters, so "which line earns its bytes" stays answerable after the
1140
1140
  // aggregate rows rotate out. A listed entry never fetched by id is the dead
1141
1141
  // weight this product exists to find.
@@ -1161,6 +1161,10 @@ export class Store {
1161
1161
  // The ids are what makes the line answerable. Without them an audit record says
1162
1162
  // "3 entries were injected" and nothing about which three.
1163
1163
  ids: Array.isArray(row.ids) ? row.ids : undefined,
1164
+ // Which store state produced this. Together with `ts` and the ids it is enough to
1165
+ // replay the decision and confirm the snapshot, which is what R3 means by
1166
+ // reproducible — recording only the outcome would make the claim unfalsifiable.
1167
+ indexRev: Number.isFinite(row.indexRev) ? row.indexRev : this.indexRev(),
1164
1168
  });
1165
1169
  }
1166
1170
 
@@ -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.9.0',
40
40
  readOnly: options.readOnly === true,
41
41
  });
42
42
  return { store, paths };