dsh-loci 2.1.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,85 @@ 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
+
54
+ ## [2.2.0] — 2026-11-15
55
+
56
+ Four reviewers of an independent blind review named the same risk: `engines.dsh`
57
+ admits the whole `>=0.2.2-rc.1 <0.3.0-0` range, but the only version ever booted was
58
+ `0.2.2-rc.1`. A range is a promise nobody tested.
59
+
60
+ ### Added
61
+
62
+ - **`scripts/compat-probe.mjs`** — reads the *installed* Host out of its `app.asar`
63
+ and asserts that every API this plugin touches still exists in the expected shape:
64
+ the section contract (`interpolate: false`, `order`), the `tools`, `commands` and
65
+ `connection` services with their registration calls, and the `turn/end` event.
66
+ Run against the installed **0.2.2-rc.3**: **5/5 passed**. It is not a boot test,
67
+ and the docs now say exactly which question it answers and which it does not.
68
+
69
+ - **The store records the Host that opened it.** `DSH_INSTALL_DIR` is read at
70
+ activation, the version lands in `meta.host_version`, `stats` exposes it, the panel
71
+ shows it, and `loci doctor` prints
72
+ `[ok] host: last opened by DeepSeek Harness 0.2.2-rc.3`. Every user install now
73
+ produces the evidence the next compatibility table needs, instead of leaving the
74
+ answer to a range in a manifest.
75
+
76
+ - CI gained a conditional probe step: it runs where a Host is installed and prints a
77
+ skip notice otherwise (the Host is not published to npm, so a stock runner has
78
+ nothing to read).
79
+
80
+ ### Changed
81
+
82
+ - `dshReleases` gained a `0.2.2-rc.3` row with status `api-surface-verified` — a
83
+ deliberate third status between "verified" (booted) and "untested", so a range
84
+ entry can never again imply more than was actually checked.
85
+
7
86
  ## [2.1.0] — 2026-11-15
8
87
 
9
88
  Two findings from an independent blind review, where seven reviewers compared this
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) => [
@@ -1076,6 +1109,7 @@ window.__ModuleLoader__.load({
1076
1109
  { className: 'loci-kv' },
1077
1110
  h('dt', null, 'path'), h('dd', null, data.memoryRoot ?? '—'),
1078
1111
  h('dt', null, 'schema'), h('dd', null, `v${data.schemaVersion}`),
1112
+ h('dt', null, 'host'), h('dd', null, data.hostVersion ?? 'unknown'),
1079
1113
  h('dt', null, 'vector'), h('dd', null, `${vs.mode ?? 'off'}${vs.healthy ? ' · healthy' : ''}${vs.lastError ? ` · ${vs.lastError}` : ''}`),
1080
1114
  h('dt', null, 'last injection'),
1081
1115
  h('dd', null, data.lastInjection ? `${data.lastInjection.bytes} B · ${data.lastInjection.listed} listed · ${data.lastInjection.hidden} hidden` : '—'),
@@ -8,8 +8,39 @@
8
8
  | 0.2.2-rc.1 (host) | **24.22.3** | Ubuntu 24.04 (WSL2) | **verified** — manifest gate, 38/38 tests, coverage 82.93%, benchmark p95 5.28 ms at 2k | `scripts/run-linux-ci.sh` (fetches Node 24, runs the same commands as CI) |
9
9
  | 0.2.2-rc.1 (host) | 22.23.0 | Ubuntu 24.04 (WSL2) | **verified** — same suite × 3 consecutive runs | recorded in `docs/ACCEPTANCE.md` §6 |
10
10
  | 0.2.2-rc.1 | 24 | Ubuntu (GitHub Actions) | the identical step list runs in `.github/workflows/ci.yml`; the host is not published, so Actions has not run it | `.github/workflows/ci.yml` |
11
+ | **0.2.2-rc.3** | 24.21.0 | Windows 11 | **API surface verified** — the source-signature probe passes 5/5 against the installed `app.asar`; not a boot test | `node scripts/compat-probe.mjs --asar <install>/resources/app.asar` |
11
12
  | other 0.2.x | — | — | not verified | — |
12
13
 
14
+ ### What "API surface verified" does and does not mean
15
+
16
+ A range in `engines.dsh` is a promise nobody tested. `scripts/compat-probe.mjs`
17
+ reads the **installed** host package out of the shipped `app.asar` and asserts that
18
+ every Host API this plugin touches is still present in the shape it expects:
19
+
20
+ ```text
21
+ host : 0.2.2-rc.3
22
+ [ok ] systemPrompt sections interpolate: false, order, section(
23
+ [ok ] tools service + register super(ctx, "tools"), register(
24
+ [ok ] session turn/end event "turn/end"
25
+ [ok ] commands service + register super(ctx, "commands"), register(
26
+ [ok ] connection service super(ctx, "connection")
27
+ 5/5 checks passed
28
+ ```
29
+
30
+ It catches a renamed service, a changed section contract, or a moved event — the
31
+ failures that would otherwise show up as "the plugin silently stopped injecting" on
32
+ a version we never booted. It does **not** prove activation, tool registration at
33
+ runtime, or injection on a live session; those still require a real host, and rc.3
34
+ has not had one in this repository yet.
35
+
36
+ The plugin also records the host version it meets: `DSH_INSTALL_DIR` is read at
37
+ activation, the value lands in `meta.host_version`, and `loci doctor` prints
38
+ `[ok] host: last opened by DeepSeek Harness 0.2.2-rc.3`. That turns every user
39
+ install into evidence for the next compatibility table.
40
+
41
+ CI runs the probe **when a host is present** and prints a skip notice otherwise
42
+ (the host is not published to npm, so a stock runner has nothing to read).
43
+
13
44
  `scripts/run-linux-ci.sh` exists so the Linux claim is reproducible on any machine
14
45
  with WSL: it downloads the newest Node 24 tarball to `/tmp` (nothing is installed
15
46
  system-wide), then runs `check-manifest`, the unit+integration suite, the coverage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-loci",
3
- "version": "2.1.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",
@@ -64,6 +64,15 @@
64
64
  ],
65
65
  "status": "verified",
66
66
  "evidence": "docs/COMPATIBILITY.md#022-rc1"
67
+ },
68
+ {
69
+ "dsh": "0.2.2-rc.3",
70
+ "node": "24.21.0",
71
+ "os": [
72
+ "win32"
73
+ ],
74
+ "status": "api-surface-verified",
75
+ "evidence": "docs/COMPATIBILITY.md#what-api-surface-verified-does-and-does-not-mean"
67
76
  }
68
77
  ],
69
78
  "dependencies": {
package/scripts/cli.mjs CHANGED
@@ -39,6 +39,9 @@ const PLUGIN_VERSION = JSON.parse(
39
39
  readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
40
40
  ).version;
41
41
 
42
+ /** The shipped manifest, read once: version for reports, engines for the host check. */
43
+ const MANIFEST = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
44
+
42
45
  const rawArgv = process.argv.slice(2);
43
46
 
44
47
  /**
@@ -129,7 +132,9 @@ const diagnosticScope = () => {
129
132
 
130
133
  switch (command) {
131
134
  case 'doctor': {
132
- const result = await withStore((store) => runDoctor({ store, paths, config: effectiveConfig({ home }) }));
135
+ const result = await withStore((store) =>
136
+ runDoctor({ store, paths, config: effectiveConfig({ home }), engines: MANIFEST.engines }),
137
+ );
133
138
  if (json) console.log(JSON.stringify(result, null, 2));
134
139
  else console.log(formatDoctor(result));
135
140
  process.exitCode = result.status === 'fail' ? 1 : 0;
@@ -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(),
@@ -21,7 +21,7 @@ function check(name, status, detail) {
21
21
  * @returns `{status, checks, summary}` where status is the worst check status.
22
22
  */
23
23
  export function runDoctor(options) {
24
- const { store, paths, config } = options;
24
+ const { store, paths, config, engines } = options;
25
25
  const checks = [];
26
26
 
27
27
  const health = (() => {
@@ -63,6 +63,19 @@ export function runDoctor(options) {
63
63
  const lastMaintenance = Number(health.migration?.to === undefined ? 0 : 0);
64
64
  checks.push(check('rows', 'ok', `${health.counts.memory} revisions · ${health.counts.memory_head} entries · ${health.counts.entity} entities`));
65
65
 
66
+ // Which Harness opened this store. A range in engines.dsh is a promise; this row
67
+ // is what actually happened, and it is the first thing to ask for in a bug report.
68
+ try {
69
+ const hostVersion = store.hostVersion ?? null;
70
+ checks.push(
71
+ hostVersion === null
72
+ ? check('host', 'warn', `not recorded yet; this build declares engines.dsh ${engines?.dsh ?? 'unknown'}`)
73
+ : check('host', 'ok', `last opened by DeepSeek Harness ${hostVersion}`),
74
+ );
75
+ } catch (error) {
76
+ checks.push(check('host', 'warn', `could not read: ${String(error?.message ?? error)}`));
77
+ }
78
+
66
79
  try {
67
80
  accessSync(paths.root, constants.W_OK);
68
81
  checks.push(check('data directory', 'ok', paths.root));
@@ -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
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Which Harness version are we actually running on?
3
+ *
4
+ * `engines.dsh` is a range; a range is a promise nobody tested. This module resolves
5
+ * the *installed* host package at activation time and records it, so the panel and
6
+ * `doctor` can say "running on 0.2.2-rc.3" instead of leaving the user to guess
7
+ * which point release the plugin met. It is best-effort by design: a plugin that
8
+ * refuses to boot because it cannot read someone else's package.json is worse than
9
+ * one that says "unknown".
10
+ *
11
+ * @module dsh-loci/host-version
12
+ */
13
+ import { createRequire } from 'node:module';
14
+ import { readFileSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+
17
+ const CANDIDATE_PATHS = [
18
+ // The Host normally resolves as a peer dependency of the profile.
19
+ '@deepseek-ai/dsh/package.json',
20
+ // Desktop bundles keep it one level down.
21
+ 'dsh/package.json',
22
+ ];
23
+
24
+ /**
25
+ * Resolve the installed host version.
26
+ *
27
+ * @param from - module URL to resolve relative to (defaults to this module).
28
+ * @returns `{version, path}` when found, `undefined` otherwise.
29
+ */
30
+ export function detectHostVersion(from = import.meta.url) {
31
+ // The Host sets this to its own package directory, and the plugin runs inside the
32
+ // Host process, where reading an `app.asar/...` path works.
33
+ const installDir = process.env.DSH_INSTALL_DIR;
34
+ if (typeof installDir === 'string' && installDir.length > 0) {
35
+ try {
36
+ const manifest = JSON.parse(readFileSync(join(installDir, 'package.json'), 'utf8'));
37
+ if (typeof manifest?.version === 'string' && manifest.version.length > 0) {
38
+ return { version: manifest.version, path: join(installDir, 'package.json') };
39
+ }
40
+ } catch {
41
+ /* fall through to resolution */
42
+ }
43
+ }
44
+
45
+ let require;
46
+ try {
47
+ require = createRequire(from);
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ for (const candidate of CANDIDATE_PATHS) {
52
+ try {
53
+ const resolved = require.resolve(candidate);
54
+ const manifest = JSON.parse(readFileSync(resolved, 'utf8'));
55
+ if (typeof manifest?.version === 'string' && manifest.version.length > 0) {
56
+ return { version: manifest.version, path: resolved };
57
+ }
58
+ } catch {
59
+ /* try the next candidate */
60
+ }
61
+ }
62
+ // Last resort: a sibling directory named `dsh` next to a cordis install.
63
+ try {
64
+ const resolved = require.resolve('@deepseek-ai/cordis/package.json');
65
+ const sibling = join(dirname(dirname(resolved)), 'dsh', 'package.json');
66
+ const manifest = JSON.parse(readFileSync(sibling, 'utf8'));
67
+ if (typeof manifest?.version === 'string') return { version: manifest.version, path: sibling };
68
+ } catch {
69
+ /* nothing else to try */
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ /**
75
+ * Compare a version against the `engines.dsh` range in one coarse way: does the
76
+ * major/minor line match what this build declares it supports?
77
+ *
78
+ * Deliberately not a semver implementation — the plugin has no dependency on one.
79
+ * It answers the only question that matters for a support message: "is this an
80
+ * untested point release inside our declared range, or a different line entirely?"
81
+ *
82
+ * @param version - the detected host version.
83
+ * @returns 'same-line' | 'other-line' | 'unknown'.
84
+ */
85
+ export function hostLineOf(version) {
86
+ const match = /^(\d+)\.(\d+)\./.exec(String(version ?? ''));
87
+ if (match === null) return 'unknown';
88
+ return `${match[1]}.${match[2]}` === '0.2' ? 'same-line' : 'other-line';
89
+ }
package/src/host/index.js CHANGED
@@ -18,11 +18,12 @@ import { registerTools } from './tools/tools.js';
18
18
  import { registerApiRoutes } from './api/routes.js';
19
19
  import { UpdateChecker } from './update.js';
20
20
  import { Extractor, completeViaHost } from './extract/extractor.js';
21
+ import { detectHostVersion, hostLineOf } from './host-version.js';
21
22
  import { registerCommands } from './commands.js';
22
23
  import { runDoctor } from './doctor.js';
23
24
 
24
25
  /** Version stamped into the database and diagnostics. */
25
- export const VERSION = '2.1.0';
26
+ export const VERSION = '2.3.1';
26
27
 
27
28
  /** Optional collaborators; without them the plugin still serves tools. */
28
29
  export const inject = ['systemPrompt', 'tools'];
@@ -98,15 +99,30 @@ export function apply(ctx, rawConfig = {}) {
98
99
  log.info(`loci: using ${memoryRoot}`);
99
100
 
100
101
  let capture;
102
+ // Record which Harness this store is actually running on. `engines.dsh` is a
103
+ // range; this field is the only one that says which point release met the file,
104
+ // so a support report can be checked instead of guessed at.
105
+ const detectedHost = detectHostVersion();
101
106
  const store = new Store({
102
107
  memoryRoot,
103
108
  paths,
104
109
  config,
105
110
  log,
106
111
  pluginVersion: VERSION,
112
+ hostVersion: detectedHost?.version,
107
113
  live: read,
108
114
  quoteVerifier: (quote, options) => capture?.verifyQuote(quote, options) ?? false,
109
115
  });
116
+ if (detectedHost !== undefined) {
117
+ try {
118
+ store.db
119
+ .prepare("INSERT INTO meta (key, value) VALUES ('host_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
120
+ .run(detectedHost.version);
121
+ } catch {
122
+ /* recording is best effort */
123
+ }
124
+ log?.info?.(`loci: host ${detectedHost.version} (${hostLineOf(detectedHost.version)})`);
125
+ }
110
126
 
111
127
  const updates = new UpdateChecker({
112
128
  currentVersion: VERSION,
@@ -160,6 +160,8 @@ export const META_KEYS = Object.freeze({
160
160
  createdAt: 'created_at',
161
161
  indexRev: 'index_rev',
162
162
  lastMaintenance: 'last_maintenance',
163
+ /** The Harness version this store has been used with, recorded at activation. */
164
+ hostVersion: 'host_version',
163
165
  });
164
166
 
165
167
  /**
@@ -123,6 +123,8 @@ export class Store {
123
123
  this.now = options.now ?? (() => Date.now());
124
124
  this.quoteVerifier = options.quoteVerifier ?? (() => false);
125
125
  this.pluginVersion = options.pluginVersion ?? '0.0.0';
126
+ /** Recorded host version, so support can see which point release met this store. */
127
+ this.hostVersion = options.hostVersion ?? undefined;
126
128
  this.readOnly = options.readOnly === true;
127
129
  this.db = openDatabase({ file: this.paths.db, readOnly: this.readOnly });
128
130
  this.audit = this.readOnly
@@ -974,7 +976,7 @@ export class Store {
974
976
  const items = this.db
975
977
  .prepare(
976
978
  `SELECT m.id, m.revision, m.kind, m.scope, m.scope_label, m.title, m.status, m.pinned, m.tags,
977
- 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,
978
980
  substr(m.body, 1, 240) AS snippet
979
981
  FROM memory m
980
982
  WHERE (m.valid_to IS NULL OR m.valid_to > ?) AND m.scope_key IN (${keyMarks})
@@ -998,6 +1000,9 @@ export class Store {
998
1000
  updatedAt: Number(row.updated_at),
999
1001
  createdAt: Number(row.created_at),
1000
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,
1001
1006
  }));
1002
1007
  return { items, total };
1003
1008
  }
@@ -1059,6 +1064,9 @@ export class Store {
1059
1064
  byStatus,
1060
1065
  byKind,
1061
1066
  byScope,
1067
+ // Which Harness point release this store has met. A range in engines.dsh is a
1068
+ // promise; this is the record of what actually opened the file.
1069
+ hostVersion: this.hostVersion ?? readMeta(this.db, META_KEYS.hostVersion) ?? null,
1062
1070
  pendingCandidates: pending,
1063
1071
  disputed,
1064
1072
  lastInjection:
@@ -36,7 +36,7 @@ export function openFacadeStore(options = {}) {
36
36
  memoryRoot: root,
37
37
  paths,
38
38
  config,
39
- pluginVersion: '2.1.0',
39
+ pluginVersion: '2.3.1',
40
40
  readOnly: options.readOnly === true,
41
41
  });
42
42
  return { store, paths };