dsh-loci 2.0.3 → 2.2.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,83 @@ 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.2.0] — 2026-11-15
8
+
9
+ Four reviewers of an independent blind review named the same risk: `engines.dsh`
10
+ admits the whole `>=0.2.2-rc.1 <0.3.0-0` range, but the only version ever booted was
11
+ `0.2.2-rc.1`. A range is a promise nobody tested.
12
+
13
+ ### Added
14
+
15
+ - **`scripts/compat-probe.mjs`** — reads the *installed* Host out of its `app.asar`
16
+ and asserts that every API this plugin touches still exists in the expected shape:
17
+ the section contract (`interpolate: false`, `order`), the `tools`, `commands` and
18
+ `connection` services with their registration calls, and the `turn/end` event.
19
+ Run against the installed **0.2.2-rc.3**: **5/5 passed**. It is not a boot test,
20
+ and the docs now say exactly which question it answers and which it does not.
21
+
22
+ - **The store records the Host that opened it.** `DSH_INSTALL_DIR` is read at
23
+ activation, the version lands in `meta.host_version`, `stats` exposes it, the panel
24
+ shows it, and `loci doctor` prints
25
+ `[ok] host: last opened by DeepSeek Harness 0.2.2-rc.3`. Every user install now
26
+ produces the evidence the next compatibility table needs, instead of leaving the
27
+ answer to a range in a manifest.
28
+
29
+ - CI gained a conditional probe step: it runs where a Host is installed and prints a
30
+ skip notice otherwise (the Host is not published to npm, so a stock runner has
31
+ nothing to read).
32
+
33
+ ### Changed
34
+
35
+ - `dshReleases` gained a `0.2.2-rc.3` row with status `api-surface-verified` — a
36
+ deliberate third status between "verified" (booted) and "untested", so a range
37
+ entry can never again imply more than was actually checked.
38
+
39
+ ## [2.1.0] — 2026-11-15
40
+
41
+ Two findings from an independent blind review, where seven reviewers compared this
42
+ plugin against the rest of the field. Two of them caught the same false claim; the
43
+ other had the sharpest product objection, and both are addressed here.
44
+
45
+ ### Fixed
46
+
47
+ - **The "no network" claim was false.** `README` and the threat-model row both said
48
+ "no network unless `vectorMode` is http", while `updateCheck` defaulted to `true`
49
+ and the panel's update route fetched the registry. Rather than reword the claim,
50
+ the default is now **false**, so the sentence is true: turning the check on is one
51
+ click, and a disabled check reads "Update check off" instead of looking like a
52
+ dead button. `test/unit/network-claims.test.mjs` also greps the source, so a new
53
+ `fetch` outside the two opt-in modules fails CI.
54
+
55
+ ### Added
56
+
57
+ - **A pending-review line in the injected section.** Verified-only injection has an
58
+ invisible failure mode: a fact written at midnight is absent at 9 a.m., and "never
59
+ saved" looks exactly like "saved but gated". When — and only when — something is
60
+ waiting, the footer now carries one line with the count and says those items are
61
+ deliberately absent. An empty queue still costs exactly 0 bytes, and the count is
62
+ part of the render cache key so a new candidate appears on the next request.
63
+
64
+ - **Per-entry usage counters (schema v2).** `memory_usage` records `inject_count`,
65
+ `read_count`, `last_injected_at`, `last_read_at` per memory, so "which line spends
66
+ prompt bytes and is never opened" becomes answerable. The panel's Stats tab lists
67
+ that dead weight; `loci_get` records a read.
68
+
69
+ ### Migration
70
+
71
+ Schema v1 → v2 adds one table. It runs through the existing migration runner: a
72
+ `VACUUM INTO` snapshot first, then one immediate transaction, then a row-count guard
73
+ over every pre-existing table. Rollback stays "restore the snapshot". This is the
74
+ first real migration this project has shipped, so `test/integration/migrate.test.mjs`
75
+ now exercises both the rehearsal step and the shipped one.
76
+
77
+ ### Note
78
+
79
+ The same review found two things worth stating openly rather than burying: the
80
+ plugin is **not yet in the DSH plugin catalog** (the catalog snapshot predates its
81
+ publication), and it has been verified on `0.2.2-rc.1` only, while `engines.dsh`
82
+ admits the whole `0.2.2-rc.1 <0.3.0-0` range. Both are tracked as open work.
83
+
7
84
  ## [2.0.3] — 2026-11-14
8
85
 
9
86
  ### Fixed
package/README.md CHANGED
@@ -119,7 +119,7 @@ The settings page writes the fields marked *live* without a restart.
119
119
 
120
120
  - No model call in any automatic path in v1.0 — capture and summaries are
121
121
  deterministic, so the cost is visible and zero.
122
- - No cloud, no telemetry, no network unless you configure `vectorMode: 'http'`.
122
+ - No cloud, no telemetry. Two optional network paths, both off by default: the update check (`updateCheck`) and the HTTP embedding channel (`vectorMode: 'http'`).
123
123
  - No cross-project leakage: reads use an explicit scope-key set, never a wildcard.
124
124
  - No physical deletion by default; `purge` needs a config switch, a confirmation,
125
125
  and leaves an audit line.
package/README.zh.md CHANGED
@@ -88,7 +88,7 @@ node "$HOME/.dsh/profiles/<profile>/node_modules/dsh-loci/scripts/cli.mjs" docto
88
88
  ## 它明确不做
89
89
 
90
90
  - v1.0 的自动路径里没有模型调用 —— 捕获与摘要是确定性的,成本可见且为零。
91
- - 无云、无遥测;除非你配置 `vectorMode: 'http'`,否则不出网。
91
+ - 无云、无遥测。只有两条可选出网路径,**都默认关闭**:更新检查(`updateCheck`)与 HTTP 向量通道(`vectorMode: 'http'`)。
92
92
  - 不跨项目串味:读取用显式 scope-key 集合,从不通配。
93
93
  - 默认不物理删除;`purge` 需要配置开关 + 确认 + 留审计。
94
94
 
package/client.js CHANGED
@@ -43,6 +43,7 @@ window.__ModuleLoader__.load({
43
43
  'card.check': 'Check for updates',
44
44
  'card.upToDate': 'Up to date',
45
45
  'card.updateTo': 'Update available',
46
+ 'card.checkOff': 'Update check off',
46
47
  'card.checking': 'Checking…',
47
48
  'card.unknown': 'Version check unavailable',
48
49
  'card.unpublished': 'Not on npm yet',
@@ -138,6 +139,9 @@ window.__ModuleLoader__.load({
138
139
  'panel.verifyAll': 'Verify all {n}',
139
140
  'panel.actionFailed': 'Action failed',
140
141
  'panel.injections': 'Recent injections',
142
+ 'panel.deadWeight': 'Injected but never opened',
143
+ 'panel.injected': 'injected',
144
+ 'panel.opened': 'opened',
141
145
  'panel.store': 'Store',
142
146
  'panel.confirmDelete': 'Soft-delete this entry?',
143
147
  'panel.error': 'Request failed',
@@ -160,6 +164,7 @@ window.__ModuleLoader__.load({
160
164
  'card.check': '检查更新',
161
165
  'card.upToDate': '已是最新',
162
166
  'card.updateTo': '有新版本',
167
+ 'card.checkOff': '更新检查已关闭',
163
168
  'card.checking': '检查中…',
164
169
  'card.unknown': '无法检查版本',
165
170
  'card.unpublished': '尚未发布',
@@ -255,6 +260,9 @@ window.__ModuleLoader__.load({
255
260
  'panel.verifyAll': '全部核实({n})',
256
261
  'panel.actionFailed': '操作失败',
257
262
  'panel.injections': '最近注入',
263
+ 'panel.deadWeight': '注入了但从未被打开',
264
+ 'panel.injected': '次注入',
265
+ 'panel.opened': '次打开',
258
266
  'panel.store': '存储',
259
267
  'panel.confirmDelete': '软删除这条记忆?',
260
268
  'panel.error': '请求失败',
@@ -483,13 +491,18 @@ window.__ModuleLoader__.load({
483
491
  const version = typeof update?.version === 'string' ? update.version.replace(/^v/i, '') : null;
484
492
  const hasUpdate = update?.updateAvailable === true;
485
493
  const unknown = update !== undefined && update.status === 'unavailable';
494
+ // The check is opt-in, so "off" is a normal state, not a failure: it must read
495
+ // as switched off rather than as a button that does nothing.
496
+ const checkDisabled = update?.checkEnabled === false;
486
497
  const text = hasUpdate
487
498
  ? `${t('card.updateTo')}${update.latestVersion ? ` · v${update.latestVersion}` : ''}`
488
499
  : unknown
489
500
  ? t('card.unknown')
490
- : update?.status === 'ok'
491
- ? t('card.upToDate')
492
- : t('card.check');
501
+ : checkDisabled
502
+ ? t('card.checkOff')
503
+ : update?.status === 'ok'
504
+ ? t('card.upToDate')
505
+ : t('card.check');
493
506
  return h(
494
507
  'span',
495
508
  { className: 'loci-card-line', style: { gap: '5px' } },
@@ -500,7 +513,7 @@ window.__ModuleLoader__.load({
500
513
  type: 'button',
501
514
  className: 'loci-btn is-quiet',
502
515
  title: text,
503
- disabled: checking === true,
516
+ disabled: checking === true || checkDisabled,
504
517
  onClick: (event) => {
505
518
  event.stopPropagation();
506
519
  onCheck?.();
@@ -1063,6 +1076,7 @@ window.__ModuleLoader__.load({
1063
1076
  { className: 'loci-kv' },
1064
1077
  h('dt', null, 'path'), h('dd', null, data.memoryRoot ?? '—'),
1065
1078
  h('dt', null, 'schema'), h('dd', null, `v${data.schemaVersion}`),
1079
+ h('dt', null, 'host'), h('dd', null, data.hostVersion ?? 'unknown'),
1066
1080
  h('dt', null, 'vector'), h('dd', null, `${vs.mode ?? 'off'}${vs.healthy ? ' · healthy' : ''}${vs.lastError ? ` · ${vs.lastError}` : ''}`),
1067
1081
  h('dt', null, 'last injection'),
1068
1082
  h('dd', null, data.lastInjection ? `${data.lastInjection.bytes} B · ${data.lastInjection.listed} listed · ${data.lastInjection.hidden} hidden` : '—'),
@@ -1077,6 +1091,31 @@ window.__ModuleLoader__.load({
1077
1091
  ),
1078
1092
  ),
1079
1093
  h('div', { className: 'loci-meta', style: { marginTop: '14px' } }, `${t('panel.version')} v${data.pluginVersion ?? '—'}`),
1094
+ // Which lines spend prompt bytes and are never opened. Sorted host-side,
1095
+ // so the first row is the most expensive and least used.
1096
+ (data.usage ?? []).some((row) => row.injections > 0 && row.reads === 0)
1097
+ ? h(
1098
+ 'div',
1099
+ null,
1100
+ h('div', { className: 'loci-section' }, t('panel.deadWeight')),
1101
+ (data.usage ?? [])
1102
+ .filter((row) => row.injections > 0 && row.reads === 0)
1103
+ .slice(0, 8)
1104
+ .map((row) =>
1105
+ h(
1106
+ 'div',
1107
+ { key: row.id, className: 'loci-row' },
1108
+ h('span', { className: 'loci-id' }, row.id),
1109
+ h(
1110
+ 'div',
1111
+ { className: 'loci-main' },
1112
+ h('div', { className: 'loci-name' }, row.title),
1113
+ h('div', { className: 'loci-meta' }, `${row.injections}×${t('panel.injected')} · 0×${t('panel.opened')}`),
1114
+ ),
1115
+ ),
1116
+ ),
1117
+ )
1118
+ : null,
1080
1119
  )
1081
1120
  : null,
1082
1121
  );
@@ -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/docs/SECURITY.md CHANGED
@@ -17,7 +17,7 @@ file sandbox, approval policy) are the Harness's and are assumed to work.
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 |
20
- | T9 | Memory exfiltration over the network | Privacy breach | no network code unless `vectorMode: 'http'`; the endpoint is user-configured; a failure parks the channel |
20
+ | T9 | Memory exfiltration over the network | Privacy breach | two opt-in paths only, both off by default: the update check (`updateCheck`, one `GET` of the package metadata, carries nothing about memory) and the HTTP embedding channel (`vectorMode: 'http'`, endpoint user-configured, failure parks the channel). No other request exists — `docs/PRIVACY.md` §3 lists both |
21
21
  | T10 | Unbounded growth | Token cost creep | hard byte budget, pinned/rule caps, soft per-scope cap option |
22
22
 
23
23
  ## 2. The trust gate, precisely
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-loci",
3
- "version": "2.0.3",
3
+ "version": "2.2.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",
@@ -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;
@@ -101,6 +101,9 @@ export function registerApiRoutes(ctx, deps) {
101
101
  pluginVersion: deps.version ?? '0.0.0',
102
102
  injections: store.recentInjections(50),
103
103
  audit: store.auditTail(50),
104
+ // Which entries spend prompt bytes and are never opened. Aggregate request
105
+ // stats cannot answer this, which is why schema v2 added per-entry counters.
106
+ usage: store.usageReport({ scopeKeys, limit: 50 }),
104
107
  });
105
108
  });
106
109
 
@@ -347,6 +350,9 @@ function installedOnly(version) {
347
350
  updateAvailable: false,
348
351
  checkedAt: null,
349
352
  status: 'idle',
353
+ // The card shows a "check" button; without this flag a disabled check looks
354
+ // broken ("nothing happens") instead of switched off.
355
+ checkEnabled: false,
350
356
  };
351
357
  }
352
358
 
@@ -100,8 +100,15 @@ export const DEFAULTS = Object.freeze({
100
100
  workingTtlDays: 7,
101
101
  /** Allow `op:'purge'` to physically delete revisions. */
102
102
  allowHardDelete: false,
103
- /** Ask the registry for a newer version when the card is opened. */
104
- updateCheck: true,
103
+ /**
104
+ * Ask the registry for a newer version when the card is opened.
105
+ *
106
+ * Off by default: the whole product claims "no network unless you opt in", and a
107
+ * default-on version check made that sentence false. A reviewer caught it, and the
108
+ * claim is worth more than the convenience — turning this on is one click in
109
+ * Settings, and the request carries nothing about memory.
110
+ */
111
+ updateCheck: false,
105
112
  /** Registry used by that check; point it at a mirror when npmjs.org is slow. */
106
113
  updateRegistry: 'https://registry.npmjs.org',
107
114
  /** 'error' | 'info' | 'debug'. */
@@ -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));
@@ -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.0.3';
26
+ export const VERSION = '2.2.0';
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,
@@ -24,8 +24,14 @@ export const INDEX_SECTION_NAME = 'loci:index';
24
24
  /** Placement: after MCP servers (3100), before the tools SDK section (5000). */
25
25
  export const INDEX_SECTION_ORDER = 4000;
26
26
 
27
- /** Bytes reserved for the closing footer before index lines are admitted. */
28
- const FOOTER_RESERVE = 240;
27
+ /**
28
+ * Bytes reserved for the closing footer before index lines are admitted.
29
+ *
30
+ * The footer is not fixed: "… +N more" and the pending-review notice grow with the
31
+ * store, and the shrink loop below would otherwise admit a line and immediately
32
+ * take it back on every render. 400 bytes covers the worst realistic footer.
33
+ */
34
+ const FOOTER_RESERVE = 400;
29
35
 
30
36
  /** Days in milliseconds. */
31
37
  export const DAY = 86_400_000;
@@ -64,14 +70,17 @@ export function renderIndexBody(options) {
64
70
  const rulesMaxBytes = Math.max(0, Math.min(maxBytes, cfg('rulesMaxBytes')));
65
71
  const maxEntries = Math.max(1, Math.min(2000, cfg('indexMaxEntries')));
66
72
  const lineChars = Math.max(40, Math.min(400, cfg('indexLineMaxChars')));
67
- const empty = { text: '', bytes: 0, listed: 0, total: 0, hidden: 0 };
73
+ const empty = { text: '', bytes: 0, listed: 0, total: 0, hidden: 0, ids: [] };
68
74
 
69
75
  const rules = store.ruleEntries({ scopeKeys, now, limit: 24 });
70
76
  const entries = store.indexEntries({ scopeKeys, now, limit: maxEntries + 1 });
71
77
  const truncatedByCount = entries.length > maxEntries;
72
78
  const visible = truncatedByCount ? entries.slice(0, maxEntries) : entries;
73
79
  const total = truncatedByCount ? store.countIndex(scopeKeys, now) : visible.length;
74
- if (rules.length === 0 && visible.length === 0) return empty;
80
+ // Counted always, rendered only when it is not zero: an empty queue must cost
81
+ // exactly zero bytes, or the "0 bytes when empty" promise is a lie.
82
+ const pending = store.countPending(scopeKeys);
83
+ if (rules.length === 0 && visible.length === 0 && pending === 0) return empty;
75
84
 
76
85
  const headLines = [FENCE_OPEN];
77
86
  const ruleLines = [];
@@ -95,19 +104,30 @@ export function renderIndexBody(options) {
95
104
  // A scope label comes from a directory name, which a user can make contain a
96
105
  // newline. Flattening it here is what stops a crafted folder name from writing
97
106
  // extra lines into the fence.
98
- const indexHeader = `Index — ${renderSafe(indexTitle(scopeLabel, 120))}`;
107
+ // Only when there is an index to label. A store holding nothing but pending
108
+ // candidates used to render an empty "Index —" heading, which reads as "memory is
109
+ // empty" right next to the line saying something is waiting.
110
+ const indexHeader = visible.length > 0 ? `Index — ${renderSafe(indexTitle(scopeLabel, 120))}` : '';
99
111
  const entryLine = (entry) => `- ${entry.id} · ${entry.kind} · ${formatDate(entry.validFrom)} · ${renderSafe(indexTitle(entry.title, lineChars))}`;
100
112
 
101
113
  const footerFor = (listed) => {
102
114
  const hidden = Math.max(0, total - listed);
103
115
  const parts = [];
104
116
  if (hidden > 0) parts.push(`… +${hidden} more (loci_search finds the rest; this section shows ${listed} of ${total})`);
117
+ // The one line that makes verified-only injection legible from inside the
118
+ // conversation. Without it "you never saved that" and "I saved it but it is
119
+ // waiting for you" are indistinguishable to the model and to the user.
120
+ if (pending > 0) {
121
+ parts.push(
122
+ `${pending} item(s) await review and are deliberately absent here — a person approves them in Memory → Review (/loci review); say so if the user expects them.`,
123
+ );
124
+ }
105
125
  parts.push(`as of ${formatDate(now)} · ${FULL_TEXT_HINT}`);
106
126
  parts.push(FENCE_CLOSE);
107
127
  return parts.join('\n');
108
128
  };
109
129
 
110
- const baseBytes = bytes([...headLines, ...ruleLines, indexHeader].join('\n'));
130
+ const baseBytes = bytes([...headLines, ...ruleLines, ...(indexHeader === '' ? [] : [indexHeader])].join('\n'));
111
131
 
112
132
  // Admit entries while the reserved footer still fits.
113
133
  let admitted = 0;
@@ -123,7 +143,7 @@ export function renderIndexBody(options) {
123
143
  let listed = admitted;
124
144
  let text = '';
125
145
  for (let n = admitted; ; n -= 1) {
126
- const lines = [...headLines, ...ruleLines, indexHeader, ...visible.slice(0, n).map(entryLine), footerFor(n)];
146
+ const lines = [...headLines, ...ruleLines, ...(indexHeader === '' ? [] : [indexHeader]), ...visible.slice(0, n).map(entryLine), footerFor(n)];
127
147
  text = lines.join('\n');
128
148
  if (bytes(text) <= maxBytes || n === 0) {
129
149
  listed = n;
@@ -135,12 +155,23 @@ export function renderIndexBody(options) {
135
155
  // lines from the tail rather than ever exceeding the budget.
136
156
  while (bytes(text) > maxBytes && ruleLines.length > 0) {
137
157
  ruleLines.pop();
138
- const lines = [...headLines, ...ruleLines, indexHeader, footerFor(0)];
158
+ const lines = [...headLines, ...ruleLines, ...(indexHeader === '' ? [] : [indexHeader]), footerFor(0)];
139
159
  text = lines.join('\n');
140
160
  listed = 0;
141
161
  }
142
162
 
143
- return { text, bytes: bytes(text), listed, total, hidden: Math.max(0, total - listed) };
163
+ return {
164
+ text,
165
+ bytes: bytes(text),
166
+ listed,
167
+ total,
168
+ hidden: Math.max(0, total - listed),
169
+ // Rule lines are injected too, and they are the ones a person most wants usage
170
+ // data for, so both blocks report their ids.
171
+ // Rule lines are injected too, and they are the ones a person most wants usage
172
+ // data for, so both blocks report their ids. `ruleEntries` is already verified-only.
173
+ ids: [...rules.slice(0, 12).map((rule) => rule.id), ...visible.slice(0, listed).map((entry) => entry.id)],
174
+ };
144
175
  }
145
176
 
146
177
  /**
@@ -168,6 +199,7 @@ export function createIndexSection(options) {
168
199
  bytes: rendered.bytes,
169
200
  listed: rendered.listed,
170
201
  hidden: rendered.hidden,
202
+ ids: rendered.ids ?? [],
171
203
  });
172
204
  } catch (error) {
173
205
  log?.warn?.(`loci: cannot record injection: ${String(error?.message ?? error)}`);
@@ -203,7 +235,11 @@ export function createIndexSection(options) {
203
235
  ].join(':');
204
236
  const now = Date.now();
205
237
  const day = new Date(now).toISOString().slice(0, 10);
206
- const key = `${context.scopeKeys.join(',')}|${store.indexRev()}|${configKey}|${day}`;
238
+ // The pending count is part of the key: if it is not, a newly queued candidate
239
+ // stays invisible until something else invalidates the cache, which defeats
240
+ // the whole point of the notice.
241
+ const pending = store.countPending(context.scopeKeys);
242
+ const key = `${context.scopeKeys.join(',')}|${store.indexRev()}|${configKey}|${day}|${pending}`;
207
243
  const cached = cache.get(key);
208
244
  if (cached !== undefined) {
209
245
  record(context, cached);
@@ -12,6 +12,7 @@
12
12
  import { copyFileSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { backupTo, tableCounts, withTransaction } from './db.js';
15
+ import { MEMORY_USAGE_DDL } from './schema.js';
15
16
  import {
16
17
  META_KEYS,
17
18
  SCHEMA_VERSION,
@@ -28,7 +29,18 @@ import {
28
29
  * drives it with a rehearsal step so the path is never untested.
29
30
  * @type {Array<{from:number,to:number,up:(db:import('node:sqlite').DatabaseSync)=>void,verify?:(db:import('node:sqlite').DatabaseSync)=>void}>}
30
31
  */
31
- export const SHIPPED_MIGRATIONS = [];
32
+ export const SHIPPED_MIGRATIONS = [
33
+ {
34
+ from: 1,
35
+ to: 2,
36
+ // v2 adds per-entry usage counters. The table is new and empty, so the
37
+ // migration cannot lose a row; the row-count guard in \`migrate()\` still runs
38
+ // over every existing table and would catch a mistake here.
39
+ up(db) {
40
+ db.exec(MEMORY_USAGE_DDL);
41
+ },
42
+ },
43
+ ];
32
44
 
33
45
  /**
34
46
  * Whether the file already holds the Loci core table.
@@ -8,7 +8,27 @@
8
8
  */
9
9
 
10
10
  /** Schema version this build writes and expects. */
11
- export const SCHEMA_VERSION = 1;
11
+ export const SCHEMA_VERSION = 2;
12
+
13
+ /**
14
+ * DDL for the v2 per-entry usage table.
15
+ *
16
+ * Kept as its own constant because two paths must agree on it: a fresh database
17
+ * (via {@link SCHEMA_SQL}) and an existing one (via the v1→v2 migration). A table
18
+ * that only exists on new installs is the classic way a feature "works on my
19
+ * machine".
20
+ */
21
+ export const MEMORY_USAGE_DDL = `
22
+ CREATE TABLE IF NOT EXISTS memory_usage (
23
+ id TEXT PRIMARY KEY,
24
+ inject_count INTEGER NOT NULL DEFAULT 0,
25
+ read_count INTEGER NOT NULL DEFAULT 0,
26
+ last_injected_at INTEGER,
27
+ last_read_at INTEGER
28
+ );
29
+ CREATE INDEX IF NOT EXISTS ix_memory_usage_inject ON memory_usage (inject_count DESC);
30
+ CREATE INDEX IF NOT EXISTS ix_memory_usage_read ON memory_usage (read_count DESC);
31
+ `;
12
32
 
13
33
  /** DDL for a fresh database. Order matters: FTS and the head table follow `memory`. */
14
34
  export const SCHEMA_SQL = `
@@ -113,6 +133,9 @@ CREATE TABLE IF NOT EXISTS token_stat (
113
133
  df INTEGER NOT NULL DEFAULT 0
114
134
  );
115
135
 
136
+ ${MEMORY_USAGE_DDL}
137
+
138
+
116
139
  CREATE TABLE IF NOT EXISTS request_stat (
117
140
  id INTEGER PRIMARY KEY AUTOINCREMENT,
118
141
  ts INTEGER NOT NULL,
@@ -137,6 +160,8 @@ export const META_KEYS = Object.freeze({
137
160
  createdAt: 'created_at',
138
161
  indexRev: 'index_rev',
139
162
  lastMaintenance: 'last_maintenance',
163
+ /** The Harness version this store has been used with, recorded at activation. */
164
+ hostVersion: 'host_version',
140
165
  });
141
166
 
142
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
@@ -829,6 +831,32 @@ export class Store {
829
831
  );
830
832
  }
831
833
 
834
+ /**
835
+ * How many entries in these scopes are waiting for a human.
836
+ *
837
+ * This is what the injected section reports. Verified-only injection has a
838
+ * failure mode the user cannot see: a fact the model wrote at midnight is simply
839
+ * absent at 9 a.m., and "never saved" looks exactly like "saved but gated". One
840
+ * number in the prompt is enough to tell those apart, and it costs nothing when
841
+ * the queue is empty.
842
+ *
843
+ * @param scopeKeys - explicit scope keys (never a wildcard).
844
+ * @returns the count of candidate and disputed entries.
845
+ */
846
+ countPending(scopeKeys) {
847
+ if (scopeKeys.length === 0) return 0;
848
+ const marks = scopeKeys.map(() => '?').join(', ');
849
+ return Number(
850
+ this.db
851
+ .prepare(
852
+ `SELECT COUNT(*) AS n FROM memory m
853
+ WHERE m.status IN ('candidate', 'disputed') AND m.deleted_at IS NULL
854
+ AND m.scope_key IN (${marks})`,
855
+ )
856
+ .get(...scopeKeys)?.n ?? 0,
857
+ );
858
+ }
859
+
832
860
  /**
833
861
  * Verified rules for the rules block, newest last.
834
862
  * @param options - `scopeKeys`, `now`, `limit`.
@@ -1033,6 +1061,9 @@ export class Store {
1033
1061
  byStatus,
1034
1062
  byKind,
1035
1063
  byScope,
1064
+ // Which Harness point release this store has met. A range in engines.dsh is a
1065
+ // promise; this is the record of what actually opened the file.
1066
+ hostVersion: this.hostVersion ?? readMeta(this.db, META_KEYS.hostVersion) ?? null,
1036
1067
  pendingCandidates: pending,
1037
1068
  disputed,
1038
1069
  lastInjection:
@@ -1062,6 +1093,20 @@ export class Store {
1062
1093
  'INSERT INTO request_stat (ts, session_id, scope_key, section_bytes, entries_listed, entries_hidden) VALUES (?, ?, ?, ?, ?, ?)',
1063
1094
  )
1064
1095
  .run(this.now(), row.sessionId ?? null, row.scopeKey ?? null, row.bytes, row.listed, row.hidden);
1096
+ // Per-entry counters, so "which line earns its bytes" stays answerable after the
1097
+ // aggregate rows rotate out. A listed entry never fetched by id is the dead
1098
+ // weight this product exists to find.
1099
+ if (Array.isArray(row.ids) && row.ids.length > 0) {
1100
+ const bump = this.db.prepare(
1101
+ `INSERT INTO memory_usage (id, inject_count, read_count, last_injected_at)
1102
+ VALUES (?, 1, 0, ?)
1103
+ ON CONFLICT(id) DO UPDATE SET inject_count = inject_count + 1, last_injected_at = excluded.last_injected_at`,
1104
+ );
1105
+ const now = this.now();
1106
+ withTransaction(this.db, () => {
1107
+ for (const id of row.ids) bump.run(id, now);
1108
+ });
1109
+ }
1065
1110
  this.db.prepare('DELETE FROM request_stat WHERE ts < ?').run(this.now() - 90 * 86_400_000);
1066
1111
  this.audit.append('inject', {
1067
1112
  sessionId: row.sessionId ?? null,
@@ -1072,6 +1117,64 @@ export class Store {
1072
1117
  });
1073
1118
  }
1074
1119
 
1120
+ /**
1121
+ * Record that one entry was fetched by id.
1122
+ *
1123
+ * Injection counts alone cannot tell "the index line was enough" from "the model
1124
+ * opened it": only this counter can.
1125
+ * @param id - memory id.
1126
+ */
1127
+ recordRead(id) {
1128
+ if (typeof id !== 'string' || id.length === 0) return;
1129
+ try {
1130
+ this.db
1131
+ .prepare(
1132
+ `INSERT INTO memory_usage (id, inject_count, read_count, last_read_at)
1133
+ VALUES (?, 0, 1, ?)
1134
+ ON CONFLICT(id) DO UPDATE SET read_count = read_count + 1, last_read_at = excluded.last_read_at`,
1135
+ )
1136
+ .run(id, this.now());
1137
+ } catch {
1138
+ // A counter must never fail a read.
1139
+ }
1140
+ }
1141
+
1142
+ /**
1143
+ * Which entries spend prompt budget without being opened.
1144
+ *
1145
+ * @param options - `scopeKeys`, `limit`.
1146
+ * @returns rows sorted by injections desc, reads asc.
1147
+ */
1148
+ usageReport(options = {}) {
1149
+ const keys = options.scopeKeys ?? [];
1150
+ const limit = Math.max(1, Math.min(200, options.limit ?? 20));
1151
+ const marks = keys.length > 0 ? ` AND m.scope_key IN (${keys.map(() => '?').join(', ')})` : '';
1152
+ const rows = this.db
1153
+ .prepare(
1154
+ `SELECT m.id, m.title, m.kind, h.status,
1155
+ COALESCE(u.inject_count, 0) AS injections,
1156
+ COALESCE(u.read_count, 0) AS reads,
1157
+ u.last_injected_at, u.last_read_at
1158
+ FROM memory m
1159
+ JOIN memory_head h ON h.ord = m.ord
1160
+ LEFT JOIN memory_usage u ON u.id = m.id
1161
+ WHERE m.deleted_at IS NULL${marks}
1162
+ ORDER BY COALESCE(u.inject_count, 0) DESC, COALESCE(u.read_count, 0) ASC
1163
+ LIMIT ?`,
1164
+ )
1165
+ .all(...keys, limit);
1166
+ return rows.map((row) => ({
1167
+ id: row.id,
1168
+ title: row.title,
1169
+ kind: row.kind,
1170
+ status: row.status,
1171
+ injections: Number(row.injections),
1172
+ reads: Number(row.reads),
1173
+ lastInjectedAt: row.last_injected_at === null ? null : Number(row.last_injected_at),
1174
+ lastReadAt: row.last_read_at === null ? null : Number(row.last_read_at),
1175
+ }));
1176
+ }
1177
+
1075
1178
  /**
1076
1179
  * Record one recall.
1077
1180
  * @param row - `query` length, counts, timing, channels.
@@ -333,7 +333,17 @@ export function createTools(deps) {
333
333
  const id = str(args, 'id', { required: true, maxChars: 64 });
334
334
  const revision = int(args, 'revision', { min: 1 });
335
335
  const includeHistory = bool(args, 'includeHistory', true);
336
- return store.get(id, { revision, includeHistory });
336
+ const result = store.get(id, { revision, includeHistory });
337
+ // A read counter is what separates "the index line was enough" from "the model
338
+ // had to open it". It must never fail the read itself.
339
+ if (result?.entry !== undefined) {
340
+ try {
341
+ store.recordRead(result.entry.id);
342
+ } catch {
343
+ /* counting is best effort */
344
+ }
345
+ }
346
+ return result;
337
347
  },
338
348
  };
339
349
 
@@ -36,7 +36,7 @@ export function openFacadeStore(options = {}) {
36
36
  memoryRoot: root,
37
37
  paths,
38
38
  config,
39
- pluginVersion: '2.0.3',
39
+ pluginVersion: '2.2.0',
40
40
  readOnly: options.readOnly === true,
41
41
  });
42
42
  return { store, paths };