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 +77 -0
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/client.js +43 -4
- package/docs/COMPATIBILITY.md +31 -0
- package/docs/SECURITY.md +1 -1
- package/package.json +10 -1
- package/scripts/cli.mjs +6 -1
- package/src/host/api/routes.js +6 -0
- package/src/host/config.js +9 -2
- package/src/host/doctor.js +14 -1
- package/src/host/host-version.js +89 -0
- package/src/host/index.js +17 -1
- package/src/host/inject/index-section.js +46 -10
- package/src/host/store/migrate.js +13 -1
- package/src/host/store/schema.js +26 -1
- package/src/host/store/store.js +103 -0
- package/src/host/tools/tools.js +11 -1
- package/src/mcp/store-facade.js +1 -1
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
|
|
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
|
-
-
|
|
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
|
-
:
|
|
491
|
-
? t('card.
|
|
492
|
-
:
|
|
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
|
);
|
package/docs/COMPATIBILITY.md
CHANGED
|
@@ -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 |
|
|
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
|
+
"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) =>
|
|
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;
|
package/src/host/api/routes.js
CHANGED
|
@@ -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
|
|
package/src/host/config.js
CHANGED
|
@@ -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
|
-
/**
|
|
104
|
-
|
|
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'. */
|
package/src/host/doctor.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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.
|
package/src/host/store/schema.js
CHANGED
|
@@ -8,7 +8,27 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/** Schema version this build writes and expects. */
|
|
11
|
-
export const SCHEMA_VERSION =
|
|
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
|
/**
|
package/src/host/store/store.js
CHANGED
|
@@ -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.
|
package/src/host/tools/tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|