dsh-loci 2.2.0 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/client.js +34 -1
- package/package.json +1 -1
- package/src/host/capture/capture.js +43 -1
- package/src/host/config.js +16 -2
- package/src/host/extract/extractor.js +19 -5
- package/src/host/index.js +1 -1
- package/src/host/store/store.js +4 -1
- package/src/mcp/store-facade.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,53 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project follows
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html) from 1.0.0.
|
|
6
6
|
|
|
7
|
+
## [2.3.1] — 2026-11-15
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **A corrupted line in the config schema.** `2.3.0` failed in CI at
|
|
12
|
+
`.number().n .step(1)` — a patch script wrote a literal `.n` where a newline was
|
|
13
|
+
meant. It passed every local gate, because `buildSchema()` only runs when
|
|
14
|
+
`@deepseek-ai/schemastery` resolves, and a checkout without dependencies never
|
|
15
|
+
executes that line.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- `test/unit/config-schema.test.mjs` builds the schema whenever the peer dependency
|
|
20
|
+
is present, and constructs it with the shipped defaults. A broken chain, an
|
|
21
|
+
out-of-range default or a wrong union member now fails a test instead of a tag.
|
|
22
|
+
The corruption above was reintroduced on purpose to confirm this test catches it.
|
|
23
|
+
|
|
24
|
+
### Note
|
|
25
|
+
|
|
26
|
+
`2.3.0` was tagged but **never published** (the CI job stopped before the publish
|
|
27
|
+
step). This is the first version carrying the session digest.
|
|
28
|
+
|
|
29
|
+
## [2.3.0] — 2026-11-15
|
|
30
|
+
|
|
31
|
+
The last open item from the blind review, and the one a heavy daily user called the
|
|
32
|
+
biggest product hole: between "deterministic rules" and "ask the model every turn"
|
|
33
|
+
there was no middle. Per-turn extraction asks for one review decision per turn until
|
|
34
|
+
the queue is abandoned; rules alone miss anything stated in passing.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **`extractMode: 'session'`** — one digest per session, run when the session ends,
|
|
39
|
+
proposing at most `extractSessionMaxCandidates` (5) items. Same rules as before:
|
|
40
|
+
candidates only, verbatim-quote grounding, hourly budget, timeout, silent failure.
|
|
41
|
+
`'on'`/`'turn'` keep the per-turn behaviour for anyone who wants it.
|
|
42
|
+
|
|
43
|
+
- **Review groups a digest.** List rows now carry `sourceSession`, and the review tab
|
|
44
|
+
shows "N proposed together" with one button to verify that batch — the shape the
|
|
45
|
+
reviewer asked for, instead of five identical rows and five decisions.
|
|
46
|
+
|
|
47
|
+
### Notes
|
|
48
|
+
|
|
49
|
+
- The digest runs independently of the episodic summary, so a user can want one
|
|
50
|
+
without the other, and a failing model cannot break the session release.
|
|
51
|
+
- The buffer is copied before the asynchronous call: releasing it at dispose must
|
|
52
|
+
not race with the model reading it.
|
|
53
|
+
|
|
7
54
|
## [2.2.0] — 2026-11-15
|
|
8
55
|
|
|
9
56
|
Four reviewers of an independent blind review named the same risk: `engines.dsh`
|
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ The settings page writes the fields marked *live* without a restart.
|
|
|
108
108
|
| `rulesMaxBytes` | `1024` | ceiling for the rules block inside that budget |
|
|
109
109
|
| `defaultScope` | `workspace` | scope a write lands in when it does not say |
|
|
110
110
|
| `captureMode` | `rules` | deterministic capture of explicit durable statements |
|
|
111
|
-
| `extractMode` | `off` | `
|
|
111
|
+
| `extractMode` | `off` | `session` asks the model once, when a session ends, for up to `extractSessionMaxCandidates` proposals — one sitting to review, not one per turn. `turn`/`on` keep the older per-turn behaviour. Always candidates, always budgeted |
|
|
112
112
|
| `recallAssist` | `off` | one bounded first-turn recall block per session |
|
|
113
113
|
| `maintenanceMode` | `sweep` | TTL, index repair, and embedding backfill every 6h |
|
|
114
114
|
| `secretPolicy` | `redact` | redact high-confidence secrets, or refuse the write |
|
package/README.zh.md
CHANGED
|
@@ -78,7 +78,7 @@ node "$HOME/.dsh/profiles/<profile>/node_modules/dsh-loci/scripts/cli.mjs" docto
|
|
|
78
78
|
| `rulesMaxBytes` | `1024` | 段内规则块的字节上限 |
|
|
79
79
|
| `defaultScope` | `workspace` | 未显式指定时的写入作用域 |
|
|
80
80
|
| `captureMode` | `rules` | 规则型捕获用户的明确约定 |
|
|
81
|
-
| `extractMode` | `off` | 设为 `on`
|
|
81
|
+
| `extractMode` | `off` | 设为 `session` 时,在**会话结束**让模型提一次、最多 `extractSessionMaxCandidates` 条——一次坐下审完,而不是每轮审一条。`turn`/`on` 保留旧的逐轮行为。一律落为候选,且有预算上限 |
|
|
82
82
|
| `recallAssist` | `off` | 每会话一次、有上限的首轮辅助召回块 |
|
|
83
83
|
| `maintenanceMode` | `sweep` | 每 6 小时做 TTL、索引修复、向量补齐 |
|
|
84
84
|
| `secretPolicy` | `redact` | 高置信密钥脱敏,或直接拒绝写入 |
|
package/client.js
CHANGED
|
@@ -136,6 +136,8 @@ window.__ModuleLoader__.load({
|
|
|
136
136
|
'panel.noCandidates': 'Nothing waiting for review.',
|
|
137
137
|
'panel.reviewHint': 'Only entries that could be injected are listed. A summary of a past session is history, not a claim.',
|
|
138
138
|
'panel.reject': 'Reject',
|
|
139
|
+
'panel.digestOf': '{n} proposed together',
|
|
140
|
+
'panel.verifyGroup': 'Verify these {n}',
|
|
139
141
|
'panel.verifyAll': 'Verify all {n}',
|
|
140
142
|
'panel.actionFailed': 'Action failed',
|
|
141
143
|
'panel.injections': 'Recent injections',
|
|
@@ -257,6 +259,8 @@ window.__ModuleLoader__.load({
|
|
|
257
259
|
'panel.noCandidates': '没有待审条目。',
|
|
258
260
|
'panel.reviewHint': '这里只列可能被注入的条目;过去会话的摘要属于历史,不是主张。',
|
|
259
261
|
'panel.reject': '驳回',
|
|
262
|
+
'panel.digestOf': '同一批次提出 {n} 条',
|
|
263
|
+
'panel.verifyGroup': '核实这 {n} 条',
|
|
260
264
|
'panel.verifyAll': '全部核实({n})',
|
|
261
265
|
'panel.actionFailed': '操作失败',
|
|
262
266
|
'panel.injections': '最近注入',
|
|
@@ -985,6 +989,35 @@ window.__ModuleLoader__.load({
|
|
|
985
989
|
),
|
|
986
990
|
h('div', { className: 'loci-actions' }, actions, h('button', { type: 'button', className: 'loci-btn is-quiet', onClick: () => onOpen(item.id) }, '→')),
|
|
987
991
|
);
|
|
992
|
+
// A session digest arrives as a batch. Showing five identical-shaped rows with
|
|
993
|
+
// no grouping is what turns one sitting into five decisions; grouping lets the
|
|
994
|
+
// reviewer confirm the batch that was proposed together.
|
|
995
|
+
const bySession = (items) => {
|
|
996
|
+
const groups = new Map();
|
|
997
|
+
for (const item of items) {
|
|
998
|
+
const key = item.sourceSession ?? '';
|
|
999
|
+
const list = groups.get(key);
|
|
1000
|
+
if (list === undefined) groups.set(key, [item]);
|
|
1001
|
+
else list.push(item);
|
|
1002
|
+
}
|
|
1003
|
+
return groups;
|
|
1004
|
+
};
|
|
1005
|
+
const sessionGroups = (items) =>
|
|
1006
|
+
[...bySession(items).entries()]
|
|
1007
|
+
.filter(([key, list]) => key !== '' && list.length > 1)
|
|
1008
|
+
.map(([key, list]) =>
|
|
1009
|
+
h(
|
|
1010
|
+
'div',
|
|
1011
|
+
{ key, className: 'loci-meta', style: { margin: '10px 0 4px', alignItems: 'center' } },
|
|
1012
|
+
h('span', null, `${t('panel.digestOf').replace('{n}', String(list.length))} · ${key.slice(0, 12)}`),
|
|
1013
|
+
h('span', { className: 'loci-grow' }),
|
|
1014
|
+
h(
|
|
1015
|
+
'button',
|
|
1016
|
+
{ type: 'button', className: 'loci-btn is-primary', disabled: busy !== undefined, onClick: () => verifyMany(list) },
|
|
1017
|
+
t('panel.verifyGroup').replace('{n}', String(list.length)),
|
|
1018
|
+
),
|
|
1019
|
+
),
|
|
1020
|
+
);
|
|
988
1021
|
const block = (title, state, actions) =>
|
|
989
1022
|
h(
|
|
990
1023
|
'div',
|
|
@@ -1008,7 +1041,7 @@ window.__ModuleLoader__.load({
|
|
|
1008
1041
|
: null,
|
|
1009
1042
|
),
|
|
1010
1043
|
state.data?.items?.length
|
|
1011
|
-
? state.data.items.map((item) => row(item, actions(item)))
|
|
1044
|
+
? [sessionGroups(state.data.items), ...state.data.items.map((item) => row(item, actions(item)))]
|
|
1012
1045
|
: h('div', { className: 'loci-muted' }, t('panel.noCandidates')),
|
|
1013
1046
|
);
|
|
1014
1047
|
const candidateActions = (item) => [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-loci",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"description": "Layered long-term memory for DeepSeek Harness: the prompt sees only a stable index, full text is one lookup away. Bi-temporal facts, strict scope isolation, verified-only injection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -185,7 +185,10 @@ export class Capture {
|
|
|
185
185
|
* slow model must not delay the rule capture that costs nothing.
|
|
186
186
|
*/
|
|
187
187
|
#scheduleExtraction(sessionId, buffer) {
|
|
188
|
-
|
|
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;
|
package/src/host/config.js
CHANGED
|
@@ -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
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* 'off' | 'on' | 'turn' | 'session' — background extraction of preferences stated
|
|
66
|
+
* in passing. 'on' and 'turn' mean one pass per turn (noisy: one proposal per
|
|
67
|
+
* turn becomes a review chore). 'session' means one digest when the session ends,
|
|
68
|
+
* which is the mode a heavy user asked for: five proposals, one sitting.
|
|
69
|
+
*/
|
|
65
70
|
extractMode: 'off',
|
|
71
|
+
/** How many candidates one session-end digest may propose. */
|
|
72
|
+
extractSessionMaxCandidates: 5,
|
|
66
73
|
/** Hard cap on extraction calls per hour; exhausting it is silent. */
|
|
67
74
|
extractMaxCallsPerHour: 6,
|
|
68
75
|
/** How many candidates one extraction run may write. */
|
|
@@ -123,7 +130,7 @@ export const ENUMS = Object.freeze({
|
|
|
123
130
|
defaultScope: ['workspace', 'project', 'global'],
|
|
124
131
|
recallAssist: ['off', 'session-start'],
|
|
125
132
|
captureMode: ['rules', 'off'],
|
|
126
|
-
extractMode: ['off', 'on'],
|
|
133
|
+
extractMode: ['off', 'on', 'turn', 'session'],
|
|
127
134
|
maintenanceMode: ['sweep', 'off'],
|
|
128
135
|
secretPolicy: ['redact', 'reject'],
|
|
129
136
|
vectorMode: ['off', 'http'],
|
|
@@ -149,6 +156,13 @@ function buildSchema() {
|
|
|
149
156
|
extractMode: z.union(ENUMS.extractMode).default(DEFAULTS.extractMode).volatile(),
|
|
150
157
|
extractMaxCallsPerHour: z.number().step(1).min(0).max(240).default(DEFAULTS.extractMaxCallsPerHour).volatile(),
|
|
151
158
|
extractMaxCandidates: z.number().step(1).min(0).max(20).default(DEFAULTS.extractMaxCandidates).volatile(),
|
|
159
|
+
extractSessionMaxCandidates: z
|
|
160
|
+
.number()
|
|
161
|
+
.step(1)
|
|
162
|
+
.min(0)
|
|
163
|
+
.max(20)
|
|
164
|
+
.default(DEFAULTS.extractSessionMaxCandidates)
|
|
165
|
+
.volatile(),
|
|
152
166
|
extractTimeoutMs: z.number().step(500).min(1000).max(120000).default(DEFAULTS.extractTimeoutMs).volatile(),
|
|
153
167
|
maintenanceMode: z.union(ENUMS.maintenanceMode).default(DEFAULTS.maintenanceMode).volatile(),
|
|
154
168
|
maintenanceIntervalHours: z.number().step(0.5).min(0.5).max(168).default(DEFAULTS.maintenanceIntervalHours).volatile(),
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* lost. This module asks a model to read the recent conversation and propose what
|
|
8
8
|
* is worth remembering.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* Five rules keep it from becoming the thing users turn off:
|
|
11
11
|
*
|
|
12
|
-
* 1. **Off by default.** `extractMode` must be set
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
+
// A turn-sized batch and a session-sized digest are different products: three
|
|
159
|
+
// proposals per turn is a queue nobody drains, five at the end of a session is
|
|
160
|
+
// one sitting.
|
|
161
|
+
const cap =
|
|
162
|
+
request?.mode === 'session'
|
|
163
|
+
? Number(this.config('extractSessionMaxCandidates') ?? 5)
|
|
164
|
+
: Number(this.config('extractMaxCandidates') ?? 3);
|
|
165
|
+
const items = parseExtraction(answer).slice(0, cap);
|
|
152
166
|
if (items.length === 0) return fail('nothing');
|
|
153
167
|
|
|
154
168
|
// A quote that is not in the transcript means the model invented it; those
|
package/src/host/index.js
CHANGED
|
@@ -23,7 +23,7 @@ import { registerCommands } from './commands.js';
|
|
|
23
23
|
import { runDoctor } from './doctor.js';
|
|
24
24
|
|
|
25
25
|
/** Version stamped into the database and diagnostics. */
|
|
26
|
-
export const VERSION = '2.
|
|
26
|
+
export const VERSION = '2.3.1';
|
|
27
27
|
|
|
28
28
|
/** Optional collaborators; without them the plugin still serves tools. */
|
|
29
29
|
export const inject = ['systemPrompt', 'tools'];
|
package/src/host/store/store.js
CHANGED
|
@@ -976,7 +976,7 @@ export class Store {
|
|
|
976
976
|
const items = this.db
|
|
977
977
|
.prepare(
|
|
978
978
|
`SELECT m.id, m.revision, m.kind, m.scope, m.scope_label, m.title, m.status, m.pinned, m.tags,
|
|
979
|
-
m.valid_from, m.valid_to, m.updated_at, m.created_at,
|
|
979
|
+
m.valid_from, m.valid_to, m.updated_at, m.created_at, m.source_session,
|
|
980
980
|
substr(m.body, 1, 240) AS snippet
|
|
981
981
|
FROM memory m
|
|
982
982
|
WHERE (m.valid_to IS NULL OR m.valid_to > ?) AND m.scope_key IN (${keyMarks})
|
|
@@ -1000,6 +1000,9 @@ export class Store {
|
|
|
1000
1000
|
updatedAt: Number(row.updated_at),
|
|
1001
1001
|
createdAt: Number(row.created_at),
|
|
1002
1002
|
snippet: row.snippet ?? '',
|
|
1003
|
+
// Which session proposed this row. A session digest is reviewed as one
|
|
1004
|
+
// batch, so the reviewer needs to see that these items belong together.
|
|
1005
|
+
sourceSession: row.source_session ?? null,
|
|
1003
1006
|
}));
|
|
1004
1007
|
return { items, total };
|
|
1005
1008
|
}
|