@aiwayds/dsh-topics-memory 0.9.0 → 0.11.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/README.md +20 -2
- package/README.zh.md +19 -1
- package/lib/commands.js +22 -4
- package/lib/config.d.ts +12 -1
- package/lib/config.js +7 -0
- package/lib/distill.d.ts +3 -1
- package/lib/distill.js +2 -0
- package/lib/git.d.ts +4 -1
- package/lib/git.js +5 -2
- package/lib/ilog.d.ts +2 -0
- package/lib/ilog.js +6 -4
- package/lib/index.d.ts +6 -6
- package/lib/index.js +66 -24
- package/lib/quality.d.ts +8 -0
- package/lib/quality.js +7 -2
- package/lib/service.d.ts +24 -6
- package/lib/service.js +105 -11
- package/lib/sync.d.ts +6 -1
- package/lib/sync.js +31 -2
- package/lib/tools.js +14 -1
- package/package.json +2 -2
- package/src/commands.ts +24 -4
- package/src/config.ts +8 -0
- package/src/distill.ts +3 -1
- package/src/git.ts +5 -2
- package/src/ilog.ts +8 -4
- package/src/index.ts +59 -25
- package/src/quality.ts +15 -2
- package/src/service.ts +108 -12
- package/src/sync.ts +29 -2
- package/src/tools.ts +13 -1
package/lib/service.js
CHANGED
|
@@ -15,6 +15,8 @@ import { aggregateStats, querySample } from "./ilog.js";
|
|
|
15
15
|
import { fileHistory, fileAtRev } from "./git.js";
|
|
16
16
|
export class TopicsService {
|
|
17
17
|
cache = new Map();
|
|
18
|
+
/** mtime-keyed parse of the observations log, grouped by session. */
|
|
19
|
+
echoCache;
|
|
18
20
|
store;
|
|
19
21
|
getConfig;
|
|
20
22
|
sync;
|
|
@@ -82,6 +84,60 @@ export class TopicsService {
|
|
|
82
84
|
/** Drop the roster cache (tests, bulk external edits). */
|
|
83
85
|
invalidate() {
|
|
84
86
|
this.cache.clear();
|
|
87
|
+
this.echoCache = undefined;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Slugs distilled from THIS session's own turns — the echo set (2026-09
|
|
91
|
+
* audit finding #3: a topic distilled from in-session knowledge injected
|
|
92
|
+
* back into the same session is a lagging echo of what the model already
|
|
93
|
+
* said, occasionally stale enough to contradict the newer in-session
|
|
94
|
+
* conclusion). Provenance is durable in the observations log: each line
|
|
95
|
+
* carries `sessionId` and, once distilled, `distilledInto`. Sync read on
|
|
96
|
+
* the injection hot path, mtime-cached like the roster.
|
|
97
|
+
*/
|
|
98
|
+
echoSlugsSync(sessionId) {
|
|
99
|
+
const file = join(this.store.metaDir(), 'observations.jsonl');
|
|
100
|
+
let st;
|
|
101
|
+
try {
|
|
102
|
+
st = statSync(file);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return new Set();
|
|
106
|
+
}
|
|
107
|
+
let bySession = this.echoCache;
|
|
108
|
+
if (bySession === undefined || bySession.mtimeMs !== st.mtimeMs || bySession.size !== st.size) {
|
|
109
|
+
bySession = { mtimeMs: st.mtimeMs, size: st.size, bySession: new Map() };
|
|
110
|
+
try {
|
|
111
|
+
const lines = readFileSync(file, 'utf8').split('\n');
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
if (line.trim() === '')
|
|
114
|
+
continue;
|
|
115
|
+
let obs;
|
|
116
|
+
try {
|
|
117
|
+
obs = JSON.parse(line);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
continue; // torn tail line
|
|
121
|
+
}
|
|
122
|
+
if (obs.distilled !== true || typeof obs.sessionId !== 'string' || !Array.isArray(obs.distilledInto))
|
|
123
|
+
continue;
|
|
124
|
+
let set = bySession.bySession.get(obs.sessionId);
|
|
125
|
+
if (set === undefined) {
|
|
126
|
+
set = new Set();
|
|
127
|
+
bySession.bySession.set(obs.sessionId, set);
|
|
128
|
+
}
|
|
129
|
+
for (const slug of obs.distilledInto) {
|
|
130
|
+
if (typeof slug === 'string' && slug !== '')
|
|
131
|
+
set.add(okf.slugify(slug));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return new Set(); // unreadable log: fail open (no suppression)
|
|
137
|
+
}
|
|
138
|
+
this.echoCache = bySession;
|
|
139
|
+
}
|
|
140
|
+
return bySession.bySession.get(sessionId) ?? new Set();
|
|
85
141
|
}
|
|
86
142
|
/**
|
|
87
143
|
* Full retrieval round: search → assemble → log (ADR 0006/0007). The
|
|
@@ -151,15 +207,19 @@ export class TopicsService {
|
|
|
151
207
|
*
|
|
152
208
|
* `dedup.exclude` (session-level injection dedup) filters hits AND slow
|
|
153
209
|
* picks before assembly; excluded slugs are reported back as `deduped`
|
|
154
|
-
* (and logged as `record.deduped`), never packed. `
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
210
|
+
* (and logged as `record.deduped`), never packed. `dedup.echo` (蒸馏回声,
|
|
211
|
+
* v4 audit) filters the same way but reports separately as `echoed` /
|
|
212
|
+
* `record.echoed`: slugs distilled from THIS session's own turns.
|
|
213
|
+
* `included` mirrors the fast pointers that entered the context and
|
|
214
|
+
* `slowIncluded` the slow ones — only these may be marked as injected by
|
|
215
|
+
* the caller; budget-dropped slugs stay injectable later.
|
|
158
216
|
*
|
|
159
217
|
* `slow` (v4 §4.2) carries the consumed pending when one exists: picks are
|
|
160
218
|
* packed after the fast pointers under the same budget, take log-only
|
|
161
219
|
* shadow re-gate verdicts against the CURRENT query (B3: record, never
|
|
162
|
-
* block), and fill the record's lane field family.
|
|
220
|
+
* block), and fill the record's lane field family. A pick the fast lane
|
|
221
|
+
* already delivers THIS round is counted as slow-delivered (it entered the
|
|
222
|
+
* context) but not packed twice.
|
|
163
223
|
*/
|
|
164
224
|
retrieveSync(query, sessionId, dedup, slow, slowExpired) {
|
|
165
225
|
const cfg = this.cfg;
|
|
@@ -194,7 +254,7 @@ export class TopicsService {
|
|
|
194
254
|
}
|
|
195
255
|
void this.store.appendInjectionRecord(trace).catch(() => undefined);
|
|
196
256
|
}
|
|
197
|
-
return { text: '', outcome: empty, deduped: [], included: [], slowIncluded: [] };
|
|
257
|
+
return { text: '', outcome: empty, deduped: [], echoed: [], included: [], slowIncluded: [] };
|
|
198
258
|
}
|
|
199
259
|
const conflicts = this.store.getConflictsSync();
|
|
200
260
|
const outcome = searchTopics(query, roster, {
|
|
@@ -206,13 +266,19 @@ export class TopicsService {
|
|
|
206
266
|
conflicts,
|
|
207
267
|
});
|
|
208
268
|
const exclude = dedup?.exclude;
|
|
269
|
+
const echo = dedup?.echo;
|
|
209
270
|
const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug);
|
|
271
|
+
const echoed = [];
|
|
210
272
|
const bySlug = new Map(roster.map((r) => [r.slug, r]));
|
|
211
273
|
const entries = [];
|
|
212
274
|
const unreadable = [];
|
|
213
275
|
for (const hit of outcome.hits) {
|
|
214
276
|
if (exclude?.has(hit.slug))
|
|
215
277
|
continue;
|
|
278
|
+
if (echo?.has(hit.slug)) {
|
|
279
|
+
echoed.push(hit.slug);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
216
282
|
const doc = this.readDocSync(hit.slug);
|
|
217
283
|
if (doc === undefined) {
|
|
218
284
|
// The hit has no readable doc — without this accounting it used to
|
|
@@ -236,9 +302,21 @@ export class TopicsService {
|
|
|
236
302
|
deduped.push(item.slug);
|
|
237
303
|
continue;
|
|
238
304
|
}
|
|
305
|
+
if (echo?.has(item.slug)) {
|
|
306
|
+
if (!echoed.includes(item.slug))
|
|
307
|
+
echoed.push(item.slug);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
239
310
|
const meta = bySlug.get(item.slug);
|
|
240
311
|
if (meta === undefined)
|
|
241
312
|
continue; // vanished between production and consumption
|
|
313
|
+
if (entries.some((e) => e.slug === item.slug)) {
|
|
314
|
+
// The fast lane delivers the same slug THIS round — the content
|
|
315
|
+
// enters the context exactly once (never packed twice), and the
|
|
316
|
+
// pick still counts as delivered for the lane bookkeeping.
|
|
317
|
+
slowDelivered.push({ slug: item.slug, why: item.why });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
242
320
|
const verdict = scoreTopic(queryTokens, meta, {
|
|
243
321
|
threshold: cfg.matchThreshold,
|
|
244
322
|
topK: cfg.topK,
|
|
@@ -289,6 +367,8 @@ export class TopicsService {
|
|
|
289
367
|
record.sessionId = sessionId;
|
|
290
368
|
if (deduped.length > 0)
|
|
291
369
|
record.deduped = deduped;
|
|
370
|
+
if (echoed.length > 0)
|
|
371
|
+
record.echoed = echoed;
|
|
292
372
|
if (unreadable.length > 0) {
|
|
293
373
|
record.dropped = [...(record.dropped ?? []), ...unreadable.map((slug) => ({ slug, reason: 'doc-unreadable' }))];
|
|
294
374
|
}
|
|
@@ -328,7 +408,7 @@ export class TopicsService {
|
|
|
328
408
|
if (injection.dropped.length > 0)
|
|
329
409
|
record.dropped = [...(record.dropped ?? []), ...injection.dropped];
|
|
330
410
|
void this.store.appendInjectionRecord(record).catch(() => undefined);
|
|
331
|
-
return { text: injection.text, outcome, deduped, included: injection.included, slowIncluded };
|
|
411
|
+
return { text: injection.text, outcome, deduped, echoed, included: injection.included, slowIncluded };
|
|
332
412
|
}
|
|
333
413
|
/**
|
|
334
414
|
* topic_open tool path (v4 §4.1): full conclusion / open questions /
|
|
@@ -336,8 +416,16 @@ export class TopicsService {
|
|
|
336
416
|
* the store stamps it on every write, so it IS the last-updated instant).
|
|
337
417
|
* Logs an open record for the pointer-open-rate stat.
|
|
338
418
|
*/
|
|
339
|
-
async openTopic(
|
|
340
|
-
|
|
419
|
+
async openTopic(rawSlug, sessionId) {
|
|
420
|
+
// Pointers render slugs as `(topics:<slug>)` and models copy that whole
|
|
421
|
+
// token back as the argument — unwrap every `topics:`/`topics/` wrap the
|
|
422
|
+
// same way the depends edges do (audit ⑨: the wrapped form read as a
|
|
423
|
+
// missing topic). Then CANONICALIZE with the save path's own slugify:
|
|
424
|
+
// unwrap alone is case-preserving, and on a case-sensitive filesystem
|
|
425
|
+
// (Linux bundles) an uppercase-carrying argument would miss the file the
|
|
426
|
+
// save path wrote under its folded slug (CI-caught, v0.11.0 round 1).
|
|
427
|
+
const slug = okf.slugify(okf.unwrapTopicRef(rawSlug));
|
|
428
|
+
const doc = slug === '' ? undefined : await this.store.readTopic(slug).catch(() => undefined);
|
|
341
429
|
if (doc === undefined)
|
|
342
430
|
return { found: false, slug };
|
|
343
431
|
// Awaited (tool path, not hot): a settled write makes the pointer-open
|
|
@@ -345,17 +433,23 @@ export class TopicsService {
|
|
|
345
433
|
await this.store
|
|
346
434
|
.appendOpenRecord({ slug, at: new Date().toISOString(), ...(sessionId !== undefined ? { sessionId } : {}) })
|
|
347
435
|
.catch(() => undefined);
|
|
348
|
-
|
|
436
|
+
// Optional fields are OMITTED, never present-as-undefined: the host's
|
|
437
|
+
// tool-output validation rejects any object carrying an undefined-valued
|
|
438
|
+
// key as non-lossless JSON (INVALID_TOOL_OUTPUT; audit ②⑦ — topics
|
|
439
|
+
// without a description made every open of them fail).
|
|
440
|
+
const found = {
|
|
349
441
|
found: true,
|
|
350
442
|
slug,
|
|
351
443
|
title: doc.fm.title,
|
|
352
444
|
status: doc.fm.status,
|
|
353
445
|
updatedAt: doc.fm.generated.at,
|
|
354
|
-
description: doc.fm.description,
|
|
355
446
|
conclusion: okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '',
|
|
356
447
|
openQuestions: doc.fm.open_questions,
|
|
357
448
|
recommendations: okf.sectionOf(doc.body, okf.RECOMMENDATIONS_HEADING) ?? '',
|
|
358
449
|
};
|
|
450
|
+
if (doc.fm.description !== undefined)
|
|
451
|
+
found.description = doc.fm.description;
|
|
452
|
+
return found;
|
|
359
453
|
}
|
|
360
454
|
/** Sync roster read (same mtime cache as roster()). */
|
|
361
455
|
rosterSync() {
|
package/lib/sync.d.ts
CHANGED
|
@@ -32,7 +32,12 @@ export declare class Sync {
|
|
|
32
32
|
private readonly urlFor;
|
|
33
33
|
constructor(store: BundleStore, cfg: () => SyncConfig, token?: TokenResolver, urlFor?: UrlResolver);
|
|
34
34
|
get active(): boolean;
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Session start (or `/topics sync pull`): rebase onto remote, mark
|
|
37
|
+
* conflicts, then replay any push the previous session's local-only exit
|
|
38
|
+
* deferred. The replay is best-effort — a failure just marks lastError and
|
|
39
|
+
* the debounced flush (or the next boot's pull) retries.
|
|
40
|
+
*/
|
|
36
41
|
pull(): Promise<{
|
|
37
42
|
ok: boolean;
|
|
38
43
|
conflicted: string[];
|
package/lib/sync.js
CHANGED
|
@@ -35,7 +35,12 @@ export class Sync {
|
|
|
35
35
|
get active() {
|
|
36
36
|
return this.cfg().repo !== '';
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Session start (or `/topics sync pull`): rebase onto remote, mark
|
|
40
|
+
* conflicts, then replay any push the previous session's local-only exit
|
|
41
|
+
* deferred. The replay is best-effort — a failure just marks lastError and
|
|
42
|
+
* the debounced flush (or the next boot's pull) retries.
|
|
43
|
+
*/
|
|
39
44
|
async pull() {
|
|
40
45
|
if (!this.active)
|
|
41
46
|
return { ok: true, conflicted: [], message: 'local-only 模式,无同步' };
|
|
@@ -52,6 +57,23 @@ export class Sync {
|
|
|
52
57
|
const prior = await this.store.getConflicts();
|
|
53
58
|
if (prior.size > 0)
|
|
54
59
|
await this.store.setConflicts([]);
|
|
60
|
+
// Deferred-push replay: the previous exit committed locally only, so a
|
|
61
|
+
// successful rebase here is the moment the backlog lands on the remote.
|
|
62
|
+
try {
|
|
63
|
+
if ((await gitmod.unpushedCount(this.store.root)) > 0) {
|
|
64
|
+
const pushed = await gitmod.push(this.store.root, token);
|
|
65
|
+
if (pushed.ok) {
|
|
66
|
+
this.lastPushAt = new Date().toISOString();
|
|
67
|
+
this.lastError = '';
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
this.lastError = pushed.output.slice(-500);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// pull must stay best-effort for the session-start caller
|
|
76
|
+
}
|
|
55
77
|
return { ok: true, conflicted: [], message: '已与远端同步' };
|
|
56
78
|
}
|
|
57
79
|
const conflictedSlugs = outcome.conflicted.map((p) => p.replace(/^topics\//, '').replace(/\.md$/, ''));
|
|
@@ -59,7 +81,14 @@ export class Sync {
|
|
|
59
81
|
await this.store.setConflicts(outcome.conflicted);
|
|
60
82
|
}
|
|
61
83
|
this.lastError = outcome.output.slice(-500);
|
|
62
|
-
|
|
84
|
+
// A rebase can fail without producing conflicted paths (identity missing,
|
|
85
|
+
// fetch failure, corrupt state) — surface the git output tail instead of
|
|
86
|
+
// the misleading 「未知路径」 so a headless failure is diagnosable.
|
|
87
|
+
if (conflictedSlugs.length === 0) {
|
|
88
|
+
const tail = outcome.output.trim().split('\n').slice(-3).join(';').slice(-200);
|
|
89
|
+
return { ok: false, conflicted: [], message: `rebase 失败(无冲突文件):${tail !== '' ? tail : 'git 无输出'}` };
|
|
90
|
+
}
|
|
91
|
+
return { ok: false, conflicted: conflictedSlugs, message: `rebase 冲突:${conflictedSlugs.join('、')}(已标记降权,待手工解决)` };
|
|
63
92
|
}
|
|
64
93
|
/** Called after every write-through commit — schedules the debounced push. */
|
|
65
94
|
schedulePush() {
|
package/lib/tools.js
CHANGED
|
@@ -244,7 +244,20 @@ export function buildTopicTools(service) {
|
|
|
244
244
|
},
|
|
245
245
|
async execute(args) {
|
|
246
246
|
const { entries } = await service.history(args.slug, args.limit ?? 20);
|
|
247
|
-
|
|
247
|
+
// conclusion is omitted, never present-as-undefined — the host rejects
|
|
248
|
+
// undefined-valued keys as non-lossless JSON (INVALID_TOOL_OUTPUT).
|
|
249
|
+
return {
|
|
250
|
+
entries: entries.flatMap((e) => {
|
|
251
|
+
const entry = {
|
|
252
|
+
hash: e.hash,
|
|
253
|
+
date: e.date,
|
|
254
|
+
message: e.message,
|
|
255
|
+
};
|
|
256
|
+
if (typeof e.conclusion === 'string')
|
|
257
|
+
entry.conclusion = e.conclusion;
|
|
258
|
+
return [entry];
|
|
259
|
+
}),
|
|
260
|
+
};
|
|
248
261
|
},
|
|
249
262
|
});
|
|
250
263
|
return [topicSave, topicOpen, topicSearch, topicObserve, topicHistory];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwayds/dsh-topics-memory",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "dsh-plugin:
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "dsh-plugin: quality-first topic memory for dsh — a topic keeps the starting question, conclusion, impact and dependencies, not the process; local-first git-traced OKF bundle, budgeted LLM-free injection",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
package/src/commands.ts
CHANGED
|
@@ -196,15 +196,23 @@ async function renderStats(service: TopicsService): Promise<string> {
|
|
|
196
196
|
lines.push(` 慢道参与轮:${slowRounds}(快 ${(records.length - slowRounds)} / 慢或混合 ${slowRounds}${medianLag})`)
|
|
197
197
|
}
|
|
198
198
|
// Pointer open rate (v4 §4.3): topic_open calls vs pointer entries injected.
|
|
199
|
+
// Echoed hits ride the retrieval but never became pointers — excluded here.
|
|
199
200
|
const opens = await service.store.readOpenRecords()
|
|
200
201
|
if (opens.length > 0 || records.some((r) => r.lane !== undefined)) {
|
|
201
202
|
const entries = records.reduce(
|
|
202
|
-
(acc, r) =>
|
|
203
|
+
(acc, r) =>
|
|
204
|
+
acc +
|
|
205
|
+
r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug) && !(r.echoed ?? []).includes(h.slug)).length +
|
|
206
|
+
(r.slow?.length ?? 0),
|
|
203
207
|
0,
|
|
204
208
|
)
|
|
205
209
|
const rate = entries === 0 ? 0 : Math.min(1, opens.length / entries)
|
|
206
210
|
lines.push(` 指针打开率:${(rate * 100).toFixed(1)}%(${opens.length} 次 topic_open / ${entries} 条注入指针)`)
|
|
207
211
|
}
|
|
212
|
+
const echoedCount = records.reduce((acc, r) => acc + (r.echoed?.length ?? 0), 0)
|
|
213
|
+
if (echoedCount > 0) {
|
|
214
|
+
lines.push(` 回声抑制:${echoedCount} 次(本会话蒸馏出的 topic 不回注)`)
|
|
215
|
+
}
|
|
208
216
|
if (stats.topTopics.length > 0) {
|
|
209
217
|
lines.push(' Top-N 被注入 Topic:')
|
|
210
218
|
for (const t of stats.topTopics.slice(0, 5)) lines.push(` ${t.slug} ×${t.count}`)
|
|
@@ -232,13 +240,25 @@ export function tuningHint(stats: ReturnType<typeof aggregateStats>, threshold:
|
|
|
232
240
|
async function renderList(service: TopicsService): Promise<string> {
|
|
233
241
|
const metas = await service.store.listTopics()
|
|
234
242
|
if (metas.length === 0) return 'Bundle 里还没有 Topic —— 在会话里让我记点什么,或 /topics set 配置好蒸馏。'
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
243
|
+
metas.sort((a, b) => a.slug.localeCompare(b.slug))
|
|
244
|
+
const lines = [
|
|
245
|
+
`共 ${metas.length} 个 Topic:`,
|
|
246
|
+
'',
|
|
247
|
+
'| Slug | 标题 | 状态 | 标签 | 更新 |',
|
|
248
|
+
'| --- | --- | --- | --- | --- |',
|
|
249
|
+
]
|
|
250
|
+
for (const m of metas) {
|
|
251
|
+
const tags = m.tags.length > 0 ? m.tags.map((t) => `#${cell(t)}`).join(' ') : '—'
|
|
252
|
+
lines.push(`| \`${cell(m.slug)}\` | ${cell(m.title)} | ${m.status} | ${tags} | ${cell(m.generatedAt.slice(0, 10))} |`)
|
|
238
253
|
}
|
|
239
254
|
return lines.join('\n')
|
|
240
255
|
}
|
|
241
256
|
|
|
257
|
+
/** One markdown table cell: pipes escaped, whitespace flattened. */
|
|
258
|
+
function cell(text: string): string {
|
|
259
|
+
return text.replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ')
|
|
260
|
+
}
|
|
261
|
+
|
|
242
262
|
async function renderShow(service: TopicsService, slug: string | undefined): Promise<CommandResult> {
|
|
243
263
|
if (slug === undefined || slug === '') return fail('用法:/topics show <slug>')
|
|
244
264
|
const doc = await service.store.readTopic(slug)
|
package/src/config.ts
CHANGED
|
@@ -14,6 +14,11 @@ export const TopicsConfig = z.object({
|
|
|
14
14
|
autoInject: z.boolean().default(true),
|
|
15
15
|
/** Skip re-injecting topics already injected earlier in the same session. */
|
|
16
16
|
injectDedup: z.boolean().default(true),
|
|
17
|
+
/** Skip re-injecting topics distilled from the CURRENT session's own turns
|
|
18
|
+
* (蒸馏回声): the conversation already carries that knowledge, so a
|
|
19
|
+
* pointer is a stale echo at best (2026-09 audit: 3 of 6 useless rounds).
|
|
20
|
+
* Provenance rides the observations log (sessionId → distilledInto). */
|
|
21
|
+
suppressEcho: z.boolean().default(true),
|
|
17
22
|
/** Max topics injected per round (ADR 0006: ≤4). */
|
|
18
23
|
topK: z.number().default(4),
|
|
19
24
|
/** Per-topic digest budget in tokens. */
|
|
@@ -61,6 +66,7 @@ export type TopicsConfigValue = {
|
|
|
61
66
|
repo: string
|
|
62
67
|
autoInject: boolean
|
|
63
68
|
injectDedup: boolean
|
|
69
|
+
suppressEcho: boolean
|
|
64
70
|
topK: number
|
|
65
71
|
perTopicBudget: number
|
|
66
72
|
totalBudget: number
|
|
@@ -86,6 +92,7 @@ export const CONFIG_KEYS = [
|
|
|
86
92
|
'repo',
|
|
87
93
|
'autoInject',
|
|
88
94
|
'injectDedup',
|
|
95
|
+
'suppressEcho',
|
|
89
96
|
'topK',
|
|
90
97
|
'perTopicBudget',
|
|
91
98
|
'totalBudget',
|
|
@@ -125,6 +132,7 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
|
|
|
125
132
|
}
|
|
126
133
|
case 'autoInject':
|
|
127
134
|
case 'injectDedup':
|
|
135
|
+
case 'suppressEcho':
|
|
128
136
|
case 'autoObserve':
|
|
129
137
|
case 'distillOnSessionEnd':
|
|
130
138
|
case 'includeSubagents': {
|
package/src/distill.ts
CHANGED
|
@@ -161,8 +161,10 @@ export class Distiller {
|
|
|
161
161
|
* caller can hook post-run cleanup — the per-session llm capture in
|
|
162
162
|
* index.ts must live exactly as long as a run that may read it. `manual`
|
|
163
163
|
* is the /topics distill trigger: same lane, same in-flight guard.
|
|
164
|
+
* `boot-replay` is the session-start backlog drain: a previous exit's
|
|
165
|
+
* skipped (or killed-mid-run) distill replays here.
|
|
164
166
|
*/
|
|
165
|
-
request(sessionId: string, reason: 'every-n' | 'session-end' | 'manual'): Promise<DistillResult> | undefined {
|
|
167
|
+
request(sessionId: string, reason: 'every-n' | 'session-end' | 'manual' | 'boot-replay'): Promise<DistillResult> | undefined {
|
|
166
168
|
if (!this.configured) return undefined
|
|
167
169
|
if (this.inFlight.has(sessionId)) return undefined
|
|
168
170
|
const run = this.run(sessionId).finally(() => this.inFlight.delete(sessionId))
|
package/src/git.ts
CHANGED
|
@@ -152,10 +152,13 @@ export interface PullOutcome {
|
|
|
152
152
|
* `pull --rebase` with conflict containment: on conflict we abort the rebase
|
|
153
153
|
* (ADR 0003 — no automatic smart merging) and report the conflicted paths so
|
|
154
154
|
* the caller can mark those topics. Remote and branch are explicit — the
|
|
155
|
-
* bundle never depends on upstream tracking config.
|
|
155
|
+
* bundle never depends on upstream tracking config. IDENTITY rides along:
|
|
156
|
+
* a rebase REPLAYS local commits, which requires a committer identity, and
|
|
157
|
+
* a fresh machine (CI, new host) may have none — without this the pull
|
|
158
|
+
* fails with an empty conflicted list on every session start.
|
|
156
159
|
*/
|
|
157
160
|
export async function pullRebase(cwd: string, token?: string, remote = 'origin', branch = 'main'): Promise<PullOutcome> {
|
|
158
|
-
const r = await runGit(['pull', '--rebase', '--autostash', remote, branch], { cwd, token, mayFail: true, timeoutMs: 60_000 }).catch((e) => {
|
|
161
|
+
const r = await runGit([...IDENTITY, 'pull', '--rebase', '--autostash', remote, branch], { cwd, token, mayFail: true, timeoutMs: 60_000 }).catch((e) => {
|
|
159
162
|
return { code: -1, stdout: '', stderr: String(e instanceof Error ? e.message : e) } satisfies GitResult
|
|
160
163
|
})
|
|
161
164
|
if (r.code === 0) return { ok: true, conflicted: [], output: r.stdout + r.stderr }
|
package/src/ilog.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface InjectionRecord {
|
|
|
41
41
|
dropped?: { slug: string; reason: string }[]
|
|
42
42
|
/** Slugs blocked this round by session-level injection dedup (never assembled). */
|
|
43
43
|
deduped?: string[]
|
|
44
|
+
/** Slugs blocked this round as 蒸馏回声 (distilled from this session's own turns). */
|
|
45
|
+
echoed?: string[]
|
|
44
46
|
usedTokens?: number
|
|
45
47
|
// ---- v4 lane field family (§4.3) — absent on pure fast-lane rounds ----
|
|
46
48
|
/** fast = lexical pointers only; slow = async picks only; mixed = both. */
|
|
@@ -88,12 +90,14 @@ export function aggregateStats(records: readonly InjectionRecord[]): AggregateSt
|
|
|
88
90
|
budgetSamples += 1
|
|
89
91
|
budgetSum += r.usedTokens
|
|
90
92
|
}
|
|
91
|
-
// topTopics feeds 「Top-N 被注入 Topic」— deduped hits were NOT
|
|
92
|
-
// so they must not inflate the per-slug injection counts.
|
|
93
|
-
// metrics above (hits/rounds, zero-hit rounds) keep
|
|
93
|
+
// topTopics feeds 「Top-N 被注入 Topic」— deduped/echoed hits were NOT
|
|
94
|
+
// injected, so they must not inflate the per-slug injection counts.
|
|
95
|
+
// Retrieval-shape metrics above (hits/rounds, zero-hit rounds) keep
|
|
96
|
+
// counting raw hits.
|
|
94
97
|
const deduped = r.deduped === undefined ? undefined : new Set(r.deduped)
|
|
98
|
+
const echoed = r.echoed === undefined ? undefined : new Set(r.echoed)
|
|
95
99
|
for (const h of r.hits) {
|
|
96
|
-
if (deduped?.has(h.slug)) continue
|
|
100
|
+
if (deduped?.has(h.slug) || echoed?.has(h.slug)) continue
|
|
97
101
|
topicCounts.set(h.slug, (topicCounts.get(h.slug) ?? 0) + 1)
|
|
98
102
|
}
|
|
99
103
|
for (const nm of r.nearMisses) {
|
package/src/index.ts
CHANGED
|
@@ -39,13 +39,13 @@ import { isDelegated } from './delegation.ts'
|
|
|
39
39
|
export const name = 'dsh-topics-memory'
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* Bounded wait for the process-exit
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
42
|
+
* Bounded wait for the process-exit meta commit. The exit path is
|
|
43
|
+
* local-only: no pull/push, no model call — the deferred push rides the
|
|
44
|
+
* next boot's pull and the skipped exit distill is replayed there. The
|
|
45
|
+
* window only bounds the local `git commit` against a pathological stall
|
|
46
|
+
* (a wedged index.lock, a hung filesystem), which should never fire.
|
|
47
47
|
*/
|
|
48
|
-
export const
|
|
48
|
+
export const EXIT_COMMIT_TIMEOUT_MS = 10_000
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Resolve when `p` settles (rejection swallowed) or after `ms`, whichever
|
|
@@ -244,6 +244,7 @@ export function apply(ctx: Context): void {
|
|
|
244
244
|
if (state === undefined) return
|
|
245
245
|
const cfg = cfgNow()
|
|
246
246
|
const seen = cfg.injectDedup ? injectedBySession.get(sessionId) : undefined
|
|
247
|
+
const echo = cfg.suppressEcho ? service.echoSlugsSync(sessionId) : undefined
|
|
247
248
|
// Slow-lane consumption point (v4 生命周期表): the pending produced at
|
|
248
249
|
// the previous turn/end rides THIS splice — consumed or expired, the
|
|
249
250
|
// slot is gone either way (消费即清; expiry surfaces in record.why).
|
|
@@ -256,7 +257,7 @@ export function apply(ctx: Context): void {
|
|
|
256
257
|
else slowExpired = consumed.expired
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
|
-
const r = service.retrieveSync(query, sessionId, seen === undefined ? undefined : { exclude: seen }, slow, slowExpired)
|
|
260
|
+
const r = service.retrieveSync(query, sessionId, seen === undefined && echo === undefined ? undefined : { exclude: seen, echo }, slow, slowExpired)
|
|
260
261
|
// Mark only what entered the context this round (hits AND slow picks →
|
|
261
262
|
// dedup filter → assemble → registry mark; budget-dropped slugs stay
|
|
262
263
|
// injectable).
|
|
@@ -301,6 +302,18 @@ export function apply(ctx: Context): void {
|
|
|
301
302
|
slowLane.dispatch(sessionId, {
|
|
302
303
|
ring: observer.recentTurns(sessionId),
|
|
303
304
|
turnId: observer.turnCountOf(sessionId),
|
|
305
|
+
// The lane must not spend its rerank on slugs this session already
|
|
306
|
+
// carries (injected earlier / distilled from its own turns) — such a
|
|
307
|
+
// pick is silently swallowed at consumption and the pending dies.
|
|
308
|
+
exclude: (() => {
|
|
309
|
+
const seen = injectedBySession.get(sessionId)
|
|
310
|
+
const echo = cfgNow().suppressEcho ? service.echoSlugsSync(sessionId) : undefined
|
|
311
|
+
if (seen === undefined || seen.size === 0) return echo
|
|
312
|
+
if (echo === undefined || echo.size === 0) return seen
|
|
313
|
+
const merged = new Set(seen)
|
|
314
|
+
for (const slug of echo) merged.add(slug)
|
|
315
|
+
return merged
|
|
316
|
+
})(),
|
|
304
317
|
})
|
|
305
318
|
} catch {
|
|
306
319
|
// contained — the lane must never break the event handler
|
|
@@ -368,10 +381,10 @@ export function apply(ctx: Context): void {
|
|
|
368
381
|
const slowLane = new SlowLane(service, caller, releaseSessionLlm)
|
|
369
382
|
|
|
370
383
|
// ---- Observer (M2) ----
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
384
|
+
// Trigger runs are fire-and-forget everywhere now — including the exit
|
|
385
|
+
// one. Observations are write-through on disk, so a run the process exits
|
|
386
|
+
// under is never lost work: its input is still undistilled and the next
|
|
387
|
+
// boot's replay distills it.
|
|
375
388
|
const observer = new Observer(service, (sessionId, reason) => {
|
|
376
389
|
// Trigger-time capture: the agent is still registered here, so its scoped
|
|
377
390
|
// ctx can hand over the llm instance whose adapters are live.
|
|
@@ -387,7 +400,6 @@ export function apply(ctx: Context): void {
|
|
|
387
400
|
// and its own settle hook does the release.
|
|
388
401
|
const run = distiller.request(sessionId, reason)
|
|
389
402
|
if (run !== undefined) {
|
|
390
|
-
if (sessionId === 'dispose') exitDistill = run
|
|
391
403
|
void run.finally(() => releaseSessionLlm(sessionId)).catch(() => undefined)
|
|
392
404
|
}
|
|
393
405
|
})
|
|
@@ -508,13 +520,37 @@ export function apply(ctx: Context): void {
|
|
|
508
520
|
}) as never)
|
|
509
521
|
}
|
|
510
522
|
|
|
511
|
-
// ---- Sync lifecycle: pull on session start,
|
|
523
|
+
// ---- Sync lifecycle: pull on session start, local commit on dispose ----
|
|
512
524
|
ctx.on('session/event' as never, ((session: { id: unknown }, event: SessionEvent) => {
|
|
513
525
|
if (event.type === 'agent/session-start') {
|
|
514
526
|
void sync
|
|
515
527
|
.pull()
|
|
516
528
|
.catch(() => undefined)
|
|
517
529
|
.finally(() => store.ensure().catch(() => undefined))
|
|
530
|
+
.then(() => {
|
|
531
|
+
// Boot replay: the observations JSONL is the durable distill queue.
|
|
532
|
+
// Whatever the previous exit skipped (local-only exit since the
|
|
533
|
+
// no-network exit commit) or a killed run left unmarked is still
|
|
534
|
+
// undistilled here. Empty pool and no-model are cheap no-ops inside
|
|
535
|
+
// the distiller; the per-session dedup guards double runs.
|
|
536
|
+
void store
|
|
537
|
+
.undistilledObservations(1)
|
|
538
|
+
.then((pending) => {
|
|
539
|
+
if (pending.length === 0) return
|
|
540
|
+
// Same trigger-time capture the observer callback does: the
|
|
541
|
+
// replay run's caller resolves candidates lazily and a fresh
|
|
542
|
+
// session has no prior capture to lean on.
|
|
543
|
+
try {
|
|
544
|
+
captureFromAgent(agents()?.get(session.id) as unknown)
|
|
545
|
+
} catch {
|
|
546
|
+
// contained — the run below fails with the readable
|
|
547
|
+
// no-adapter detail instead
|
|
548
|
+
}
|
|
549
|
+
const run = distiller.request(String(session.id), 'boot-replay')
|
|
550
|
+
if (run !== undefined) void run.catch(() => undefined)
|
|
551
|
+
})
|
|
552
|
+
.catch(() => undefined)
|
|
553
|
+
})
|
|
518
554
|
}
|
|
519
555
|
}) as never)
|
|
520
556
|
|
|
@@ -522,27 +558,24 @@ export function apply(ctx: Context): void {
|
|
|
522
558
|
() => {
|
|
523
559
|
void store.ensure().catch(() => undefined)
|
|
524
560
|
// Async disposer: cordis awaits it during unload (Disposable may be
|
|
525
|
-
// async)
|
|
526
|
-
//
|
|
561
|
+
// async). The exit path is local-only — one git commit of the meta
|
|
562
|
+
// sidecars (observations / injection log / distill state), no pull, no
|
|
563
|
+
// push, no model call. The deferred push rides the next boot's pull
|
|
564
|
+
// (sync.pull replays it) and the skipped exit distill is replayed
|
|
565
|
+
// there too; both only ever needed the on-disk state, which the commit
|
|
566
|
+
// below makes durable before the process goes away.
|
|
527
567
|
return () => {
|
|
528
568
|
// A real session's session-end run (agent/disposed trigger, moments
|
|
529
569
|
// earlier) may still be in flight — and the fake-'dispose' run would
|
|
530
570
|
// feed the SAME global pool head to the model a second time (double
|
|
531
571
|
// evaluation, double GC attempts). Skip the exit trigger while any
|
|
532
|
-
// run is pending;
|
|
533
|
-
// extra to await.
|
|
572
|
+
// run is pending; its capture is already write-through on disk.
|
|
534
573
|
if (!distiller.hasAnyPending()) {
|
|
535
574
|
observer.onSessionEvent('dispose', 'agent/disposed', undefined)
|
|
536
575
|
}
|
|
537
576
|
sync.dispose()
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
// exit race that window was built to win.
|
|
541
|
-
const exitFlush = sync.commitMeta().then(() => sync.flush()).catch(() => undefined)
|
|
542
|
-
return settleBounded(
|
|
543
|
-
exitDistill === undefined ? exitFlush : Promise.allSettled([exitDistill, exitFlush]),
|
|
544
|
-
EXIT_DISTILL_TIMEOUT_MS,
|
|
545
|
-
)
|
|
577
|
+
const exitFlush = sync.commitMeta().catch(() => undefined)
|
|
578
|
+
return settleBounded(exitFlush, EXIT_COMMIT_TIMEOUT_MS)
|
|
546
579
|
}
|
|
547
580
|
},
|
|
548
581
|
'topics: lifecycle',
|
|
@@ -626,6 +659,7 @@ const DEFAULTS: TopicsConfigValue = {
|
|
|
626
659
|
repo: '',
|
|
627
660
|
autoInject: true,
|
|
628
661
|
injectDedup: true,
|
|
662
|
+
suppressEcho: true,
|
|
629
663
|
topK: 4,
|
|
630
664
|
perTopicBudget: 300,
|
|
631
665
|
totalBudget: 1500,
|