dsh-loci 2.2.0 → 2.3.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,53 @@ 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.3.1] — 2026-11-15
8
+
9
+ ### Fixed
10
+
11
+ - **A corrupted line in the config schema.** `2.3.0` failed in CI at
12
+ `.number().n .step(1)` — a patch script wrote a literal `.n` where a newline was
13
+ meant. It passed every local gate, because `buildSchema()` only runs when
14
+ `@deepseek-ai/schemastery` resolves, and a checkout without dependencies never
15
+ executes that line.
16
+
17
+ ### Added
18
+
19
+ - `test/unit/config-schema.test.mjs` builds the schema whenever the peer dependency
20
+ is present, and constructs it with the shipped defaults. A broken chain, an
21
+ out-of-range default or a wrong union member now fails a test instead of a tag.
22
+ The corruption above was reintroduced on purpose to confirm this test catches it.
23
+
24
+ ### Note
25
+
26
+ `2.3.0` was tagged but **never published** (the CI job stopped before the publish
27
+ step). This is the first version carrying the session digest.
28
+
29
+ ## [2.3.0] — 2026-11-15
30
+
31
+ The last open item from the blind review, and the one a heavy daily user called the
32
+ biggest product hole: between "deterministic rules" and "ask the model every turn"
33
+ there was no middle. Per-turn extraction asks for one review decision per turn until
34
+ the queue is abandoned; rules alone miss anything stated in passing.
35
+
36
+ ### Added
37
+
38
+ - **`extractMode: 'session'`** — one digest per session, run when the session ends,
39
+ proposing at most `extractSessionMaxCandidates` (5) items. Same rules as before:
40
+ candidates only, verbatim-quote grounding, hourly budget, timeout, silent failure.
41
+ `'on'`/`'turn'` keep the per-turn behaviour for anyone who wants it.
42
+
43
+ - **Review groups a digest.** List rows now carry `sourceSession`, and the review tab
44
+ shows "N proposed together" with one button to verify that batch — the shape the
45
+ reviewer asked for, instead of five identical rows and five decisions.
46
+
47
+ ### Notes
48
+
49
+ - The digest runs independently of the episodic summary, so a user can want one
50
+ without the other, and a failing model cannot break the session release.
51
+ - The buffer is copied before the asynchronous call: releasing it at dispose must
52
+ not race with the model reading it.
53
+
7
54
  ## [2.2.0] — 2026-11-15
8
55
 
9
56
  Four reviewers of an independent blind review named the same risk: `engines.dsh`
package/README.md CHANGED
@@ -108,7 +108,7 @@ The settings page writes the fields marked *live* without a restart.
108
108
  | `rulesMaxBytes` | `1024` | ceiling for the rules block inside that budget |
109
109
  | `defaultScope` | `workspace` | scope a write lands in when it does not say |
110
110
  | `captureMode` | `rules` | deterministic capture of explicit durable statements |
111
- | `extractMode` | `off` | `on` asks the configured model, at turn end, what is worth remembering — always as candidates, always budgeted |
111
+ | `extractMode` | `off` | `session` asks the model once, when a session ends, for up to `extractSessionMaxCandidates` proposals — one sitting to review, not one per turn. `turn`/`on` keep the older per-turn behaviour. Always candidates, always budgeted |
112
112
  | `recallAssist` | `off` | one bounded first-turn recall block per session |
113
113
  | `maintenanceMode` | `sweep` | TTL, index repair, and embedding backfill every 6h |
114
114
  | `secretPolicy` | `redact` | redact high-confidence secrets, or refuse the write |
package/README.zh.md CHANGED
@@ -78,7 +78,7 @@ node "$HOME/.dsh/profiles/<profile>/node_modules/dsh-loci/scripts/cli.mjs" docto
78
78
  | `rulesMaxBytes` | `1024` | 段内规则块的字节上限 |
79
79
  | `defaultScope` | `workspace` | 未显式指定时的写入作用域 |
80
80
  | `captureMode` | `rules` | 规则型捕获用户的明确约定 |
81
- | `extractMode` | `off` | 设为 `on` 时,在回合结束让模型提一遍「这轮有什么值得记」——一律落为候选,且有预算上限 |
81
+ | `extractMode` | `off` | 设为 `session` 时,在**会话结束**让模型提一次、最多 `extractSessionMaxCandidates` 条——一次坐下审完,而不是每轮审一条。`turn`/`on` 保留旧的逐轮行为。一律落为候选,且有预算上限 |
82
82
  | `recallAssist` | `off` | 每会话一次、有上限的首轮辅助召回块 |
83
83
  | `maintenanceMode` | `sweep` | 每 6 小时做 TTL、索引修复、向量补齐 |
84
84
  | `secretPolicy` | `redact` | 高置信密钥脱敏,或直接拒绝写入 |
package/client.js CHANGED
@@ -136,6 +136,8 @@ window.__ModuleLoader__.load({
136
136
  'panel.noCandidates': 'Nothing waiting for review.',
137
137
  'panel.reviewHint': 'Only entries that could be injected are listed. A summary of a past session is history, not a claim.',
138
138
  'panel.reject': 'Reject',
139
+ 'panel.digestOf': '{n} proposed together',
140
+ 'panel.verifyGroup': 'Verify these {n}',
139
141
  'panel.verifyAll': 'Verify all {n}',
140
142
  'panel.actionFailed': 'Action failed',
141
143
  'panel.injections': 'Recent injections',
@@ -257,6 +259,8 @@ window.__ModuleLoader__.load({
257
259
  'panel.noCandidates': '没有待审条目。',
258
260
  'panel.reviewHint': '这里只列可能被注入的条目;过去会话的摘要属于历史,不是主张。',
259
261
  'panel.reject': '驳回',
262
+ 'panel.digestOf': '同一批次提出 {n} 条',
263
+ 'panel.verifyGroup': '核实这 {n} 条',
260
264
  'panel.verifyAll': '全部核实({n})',
261
265
  'panel.actionFailed': '操作失败',
262
266
  'panel.injections': '最近注入',
@@ -985,6 +989,35 @@ window.__ModuleLoader__.load({
985
989
  ),
986
990
  h('div', { className: 'loci-actions' }, actions, h('button', { type: 'button', className: 'loci-btn is-quiet', onClick: () => onOpen(item.id) }, '→')),
987
991
  );
992
+ // A session digest arrives as a batch. Showing five identical-shaped rows with
993
+ // no grouping is what turns one sitting into five decisions; grouping lets the
994
+ // reviewer confirm the batch that was proposed together.
995
+ const bySession = (items) => {
996
+ const groups = new Map();
997
+ for (const item of items) {
998
+ const key = item.sourceSession ?? '';
999
+ const list = groups.get(key);
1000
+ if (list === undefined) groups.set(key, [item]);
1001
+ else list.push(item);
1002
+ }
1003
+ return groups;
1004
+ };
1005
+ const sessionGroups = (items) =>
1006
+ [...bySession(items).entries()]
1007
+ .filter(([key, list]) => key !== '' && list.length > 1)
1008
+ .map(([key, list]) =>
1009
+ h(
1010
+ 'div',
1011
+ { key, className: 'loci-meta', style: { margin: '10px 0 4px', alignItems: 'center' } },
1012
+ h('span', null, `${t('panel.digestOf').replace('{n}', String(list.length))} · ${key.slice(0, 12)}`),
1013
+ h('span', { className: 'loci-grow' }),
1014
+ h(
1015
+ 'button',
1016
+ { type: 'button', className: 'loci-btn is-primary', disabled: busy !== undefined, onClick: () => verifyMany(list) },
1017
+ t('panel.verifyGroup').replace('{n}', String(list.length)),
1018
+ ),
1019
+ ),
1020
+ );
988
1021
  const block = (title, state, actions) =>
989
1022
  h(
990
1023
  'div',
@@ -1008,7 +1041,7 @@ window.__ModuleLoader__.load({
1008
1041
  : null,
1009
1042
  ),
1010
1043
  state.data?.items?.length
1011
- ? state.data.items.map((item) => row(item, actions(item)))
1044
+ ? [sessionGroups(state.data.items), ...state.data.items.map((item) => row(item, actions(item)))]
1012
1045
  : h('div', { className: 'loci-muted' }, t('panel.noCandidates')),
1013
1046
  );
1014
1047
  const candidateActions = (item) => [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-loci",
3
- "version": "2.2.0",
3
+ "version": "2.3.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",
@@ -185,7 +185,10 @@ export class Capture {
185
185
  * slow model must not delay the rule capture that costs nothing.
186
186
  */
187
187
  #scheduleExtraction(sessionId, buffer) {
188
- if (this.config('extractMode') !== 'on' || this.extractor === undefined) return;
188
+ const mode = this.config('extractMode');
189
+ // 'session' deliberately does not schedule per turn: that is the whole point of
190
+ // the mode. It runs once, in summarize(), when the session ends.
191
+ if ((mode !== 'on' && mode !== 'turn') || this.extractor === undefined) return;
189
192
  if (buffer.extractTimer !== undefined) return;
190
193
  const delay = Math.max(0, Number(this.config('captureDebounceMs') ?? 30000));
191
194
  buffer.extractTimer = setTimeout(() => {
@@ -196,6 +199,7 @@ export class Capture {
196
199
  .then(() =>
197
200
  extractor.run({
198
201
  sessionId,
202
+ mode: 'turn',
199
203
  messages: buffer.messages,
200
204
  scopeKey: this.#workspaceKey(buffer.cwd, sessionId),
201
205
  scopeLabel: this.#workspaceLabel(buffer.cwd),
@@ -299,6 +303,39 @@ export class Capture {
299
303
  );
300
304
  }
301
305
 
306
+ /**
307
+ * One extraction pass for a whole session, run as it ends.
308
+ *
309
+ * This is the middle ground the review asked for: deterministic rules catch
310
+ * statements that announce themselves, per-turn extraction catches everything but
311
+ * costs one review decision per turn. A session digest reads the whole
312
+ * conversation once and proposes at most a handful of items in one sitting.
313
+ *
314
+ * Fire-and-forget by design: the buffer is copied before the asynchronous call so
315
+ * releasing it cannot race with the model.
316
+ *
317
+ * @param sessionId - session id.
318
+ * @param buffer - the buffered messages.
319
+ */
320
+ #runSessionDigest(sessionId, buffer) {
321
+ const extractor = this.extractor;
322
+ if (extractor === undefined || buffer === undefined || buffer.messages.length < 4) return;
323
+ const messages = [...buffer.messages];
324
+ const scopeKey = this.#workspaceKey(buffer.cwd, sessionId);
325
+ const scopeLabel = this.#workspaceLabel(buffer.cwd);
326
+ Promise.resolve()
327
+ .then(() => extractor.run({ sessionId, mode: 'session', messages, scopeKey, scopeLabel }))
328
+ .then((result) => {
329
+ if (result?.written > 0) {
330
+ this.statistics.extracted += result.written;
331
+ this.log?.debug?.(`loci: session digest proposed ${result.written} item(s)`);
332
+ }
333
+ })
334
+ .catch((error) => {
335
+ this.log?.debug?.(`loci: session digest failed: ${String(error?.message ?? error)}`);
336
+ });
337
+ }
338
+
302
339
  /** Write the episodic summary for one session and drop its buffer. */
303
340
  summarize(sessionId, reason = 'dispose') {
304
341
  const buffer = this.sessions.get(sessionId);
@@ -311,6 +348,11 @@ export class Capture {
311
348
  if (buffer.extractTimer !== undefined) clearTimeout(buffer.extractTimer);
312
349
  this.sessions.delete(sessionId);
313
350
  };
351
+ // The digest is independent of the episodic summary: a user can want one and not
352
+ // the other, and the digest must still run when summaries are off.
353
+ if (this.config('extractMode') === 'session' && buffer !== undefined) {
354
+ this.#runSessionDigest(sessionId, buffer);
355
+ }
314
356
  if (this.config('summaryOnDispose') !== true) {
315
357
  release();
316
358
  return 0;
@@ -61,8 +61,15 @@ export const DEFAULTS = Object.freeze({
61
61
  captureMaxPerStatementChars: 200,
62
62
  /** Write one episodic session summary when a root agent is disposed. */
63
63
  summaryOnDispose: true,
64
- /** 'off' | 'on' — background extraction of preferences stated in passing. */
64
+ /**
65
+ * 'off' | 'on' | 'turn' | 'session' — background extraction of preferences stated
66
+ * in passing. 'on' and 'turn' mean one pass per turn (noisy: one proposal per
67
+ * turn becomes a review chore). 'session' means one digest when the session ends,
68
+ * which is the mode a heavy user asked for: five proposals, one sitting.
69
+ */
65
70
  extractMode: 'off',
71
+ /** How many candidates one session-end digest may propose. */
72
+ extractSessionMaxCandidates: 5,
66
73
  /** Hard cap on extraction calls per hour; exhausting it is silent. */
67
74
  extractMaxCallsPerHour: 6,
68
75
  /** How many candidates one extraction run may write. */
@@ -123,7 +130,7 @@ export const ENUMS = Object.freeze({
123
130
  defaultScope: ['workspace', 'project', 'global'],
124
131
  recallAssist: ['off', 'session-start'],
125
132
  captureMode: ['rules', 'off'],
126
- extractMode: ['off', 'on'],
133
+ extractMode: ['off', 'on', 'turn', 'session'],
127
134
  maintenanceMode: ['sweep', 'off'],
128
135
  secretPolicy: ['redact', 'reject'],
129
136
  vectorMode: ['off', 'http'],
@@ -149,6 +156,13 @@ function buildSchema() {
149
156
  extractMode: z.union(ENUMS.extractMode).default(DEFAULTS.extractMode).volatile(),
150
157
  extractMaxCallsPerHour: z.number().step(1).min(0).max(240).default(DEFAULTS.extractMaxCallsPerHour).volatile(),
151
158
  extractMaxCandidates: z.number().step(1).min(0).max(20).default(DEFAULTS.extractMaxCandidates).volatile(),
159
+ extractSessionMaxCandidates: z
160
+ .number()
161
+ .step(1)
162
+ .min(0)
163
+ .max(20)
164
+ .default(DEFAULTS.extractSessionMaxCandidates)
165
+ .volatile(),
152
166
  extractTimeoutMs: z.number().step(500).min(1000).max(120000).default(DEFAULTS.extractTimeoutMs).volatile(),
153
167
  maintenanceMode: z.union(ENUMS.maintenanceMode).default(DEFAULTS.maintenanceMode).volatile(),
154
168
  maintenanceIntervalHours: z.number().step(0.5).min(0.5).max(168).default(DEFAULTS.maintenanceIntervalHours).volatile(),
@@ -7,9 +7,9 @@
7
7
  * lost. This module asks a model to read the recent conversation and propose what
8
8
  * is worth remembering.
9
9
  *
10
- * Four rules keep it from becoming the thing users turn off:
10
+ * Five rules keep it from becoming the thing users turn off:
11
11
  *
12
- * 1. **Off by default.** `extractMode` must be set to `on` explicitly.
12
+ * 1. **Off by default.** `extractMode` must be set explicitly.
13
13
  * 2. **Candidates only.** Everything it writes has status `candidate`; a person
14
14
  * decides. Nothing here can reach the prompt by itself.
15
15
  * 3. **Budgeted.** A call cap per hour, a cap on candidates per run, an input cap
@@ -17,6 +17,11 @@
17
17
  * 4. **It can always fail.** No model configured, timeout, unparseable answer,
18
18
  * store error — every path returns `{written: 0, reason}` and the turn is never
19
19
  * affected.
20
+ * 5. **Batch, don't drip.** `'session'` mode runs once, when a session ends, and
21
+ * proposes at most a handful of items in one go. `'turn'` mode still exists for
22
+ * people who want it, but reviewing one proposal per turn is how a review queue
23
+ * turns into a chore — the failure a heavy user described as "either it
24
+ * remembers too little or it makes me triage".
20
25
  *
21
26
  * @module dsh-loci/extract
22
27
  */
@@ -104,7 +109,8 @@ export class Extractor {
104
109
 
105
110
  /** Whether the feature is switched on *and* a model route exists. */
106
111
  enabled() {
107
- return this.config('extractMode') === 'on' && typeof this.complete === 'function';
112
+ const mode = this.config('extractMode');
113
+ return (mode === 'on' || mode === 'turn' || mode === 'session') && typeof this.complete === 'function';
108
114
  }
109
115
 
110
116
  /**
@@ -129,7 +135,8 @@ export class Extractor {
129
135
  this.lastReason = reason;
130
136
  return { written: 0, reason, ids: [] };
131
137
  };
132
- if (this.config('extractMode') !== 'on') return fail('disabled');
138
+ const mode = this.config('extractMode');
139
+ if (mode !== 'on' && mode !== 'turn' && mode !== 'session') return fail('disabled');
133
140
  if (typeof this.complete !== 'function') return fail('no-model');
134
141
  if (this.remainingCalls() <= 0) return fail('budget');
135
142
 
@@ -148,7 +155,14 @@ export class Extractor {
148
155
  return fail('model-error');
149
156
  }
150
157
 
151
- const items = parseExtraction(answer).slice(0, Number(this.config('extractMaxCandidates') ?? 3));
158
+ // A turn-sized batch and a session-sized digest are different products: three
159
+ // proposals per turn is a queue nobody drains, five at the end of a session is
160
+ // one sitting.
161
+ const cap =
162
+ request?.mode === 'session'
163
+ ? Number(this.config('extractSessionMaxCandidates') ?? 5)
164
+ : Number(this.config('extractMaxCandidates') ?? 3);
165
+ const items = parseExtraction(answer).slice(0, cap);
152
166
  if (items.length === 0) return fail('nothing');
153
167
 
154
168
  // A quote that is not in the transcript means the model invented it; those
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.2.0';
26
+ export const VERSION = '2.3.1';
27
27
 
28
28
  /** Optional collaborators; without them the plugin still serves tools. */
29
29
  export const inject = ['systemPrompt', 'tools'];
@@ -976,7 +976,7 @@ export class Store {
976
976
  const items = this.db
977
977
  .prepare(
978
978
  `SELECT m.id, m.revision, m.kind, m.scope, m.scope_label, m.title, m.status, m.pinned, m.tags,
979
- m.valid_from, m.valid_to, m.updated_at, m.created_at,
979
+ m.valid_from, m.valid_to, m.updated_at, m.created_at, m.source_session,
980
980
  substr(m.body, 1, 240) AS snippet
981
981
  FROM memory m
982
982
  WHERE (m.valid_to IS NULL OR m.valid_to > ?) AND m.scope_key IN (${keyMarks})
@@ -1000,6 +1000,9 @@ export class Store {
1000
1000
  updatedAt: Number(row.updated_at),
1001
1001
  createdAt: Number(row.created_at),
1002
1002
  snippet: row.snippet ?? '',
1003
+ // Which session proposed this row. A session digest is reviewed as one
1004
+ // batch, so the reviewer needs to see that these items belong together.
1005
+ sourceSession: row.source_session ?? null,
1003
1006
  }));
1004
1007
  return { items, total };
1005
1008
  }
@@ -36,7 +36,7 @@ export function openFacadeStore(options = {}) {
36
36
  memoryRoot: root,
37
37
  paths,
38
38
  config,
39
- pluginVersion: '2.2.0',
39
+ pluginVersion: '2.3.1',
40
40
  readOnly: options.readOnly === true,
41
41
  });
42
42
  return { store, paths };