@aiwayds/dsh-topics-memory 0.6.0 → 0.8.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 +1 -1
- package/README.zh.md +1 -1
- package/lib/commands.js +23 -2
- package/lib/config.d.ts +19 -5
- package/lib/config.js +21 -3
- package/lib/digest.d.ts +38 -1
- package/lib/digest.js +92 -11
- package/lib/distill.d.ts +1 -0
- package/lib/distill.js +3 -1
- package/lib/ilog.d.ts +36 -0
- package/lib/index.js +95 -23
- package/lib/observer.d.ts +17 -0
- package/lib/observer.js +31 -4
- package/lib/okf.d.ts +7 -0
- package/lib/okf.js +12 -1
- package/lib/quality.d.ts +90 -0
- package/lib/quality.js +315 -0
- package/lib/retrieval.d.ts +35 -3
- package/lib/retrieval.js +73 -19
- package/lib/service.d.ts +48 -7
- package/lib/service.js +198 -25
- package/lib/store.d.ts +13 -0
- package/lib/store.js +54 -0
- package/lib/tools.js +49 -1
- package/package.json +22 -22
- package/src/commands.ts +26 -2
- package/src/config.ts +21 -3
- package/src/digest.ts +118 -11
- package/src/distill.ts +4 -1
- package/src/ilog.ts +39 -2
- package/src/index.ts +87 -19
- package/src/observer.ts +42 -4
- package/src/okf.ts +17 -1
- package/src/quality.ts +335 -0
- package/src/retrieval.ts +93 -19
- package/src/service.ts +214 -25
- package/src/store.ts +53 -0
- package/src/tools.ts +51 -1
package/lib/service.js
CHANGED
|
@@ -9,8 +9,8 @@ import { readFile, stat } from 'node:fs/promises';
|
|
|
9
9
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import * as okf from "./okf.js";
|
|
12
|
-
import { searchTopics, tokenize } from "./retrieval.js";
|
|
13
|
-
import { assembleInjection } from "./digest.js";
|
|
12
|
+
import { searchTopics, scoreTopic, passesGate, tokenize } from "./retrieval.js";
|
|
13
|
+
import { assembleInjection, assemblePointer, POINTER_PER_TOPIC, POINTER_TOTAL } from "./digest.js";
|
|
14
14
|
import { aggregateStats, querySample } from "./ilog.js";
|
|
15
15
|
import { fileHistory, fileAtRev } from "./git.js";
|
|
16
16
|
export class TopicsService {
|
|
@@ -70,6 +70,7 @@ export class TopicsService {
|
|
|
70
70
|
description: doc.fm.description,
|
|
71
71
|
status: doc.fm.status,
|
|
72
72
|
tags: doc.fm.tags,
|
|
73
|
+
triggers: doc.fm.triggers,
|
|
73
74
|
depends: doc.fm.depends,
|
|
74
75
|
links: okf.bodyLinkSlugs(doc.body),
|
|
75
76
|
generatedAt: doc.fm.generated.at,
|
|
@@ -116,10 +117,12 @@ export class TopicsService {
|
|
|
116
117
|
if (doc !== undefined)
|
|
117
118
|
entries.push({ slug: hit.slug, doc, hit });
|
|
118
119
|
}
|
|
119
|
-
const injection =
|
|
120
|
-
perTopicBudget: cfg.
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
const injection = cfg.injectMode !== 'digest'
|
|
121
|
+
? assemblePointer(entries, { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) })
|
|
122
|
+
: assembleInjection(entries, {
|
|
123
|
+
perTopicBudget: cfg.perTopicBudget,
|
|
124
|
+
totalBudget: cfg.totalBudget,
|
|
125
|
+
});
|
|
123
126
|
const record = {
|
|
124
127
|
at: new Date().toISOString(),
|
|
125
128
|
queryTokenCount: tokenize(query).length,
|
|
@@ -146,18 +149,52 @@ export class TopicsService {
|
|
|
146
149
|
* would always lose the race and inject nothing. Reads files with sync fs
|
|
147
150
|
* behind the mtime cache; the log record is written fire-and-forget.
|
|
148
151
|
*
|
|
149
|
-
* `dedup.exclude` (session-level injection dedup) filters hits
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* as injected by the caller; budget-dropped
|
|
152
|
+
* `dedup.exclude` (session-level injection dedup) filters hits AND slow
|
|
153
|
+
* picks before assembly; excluded slugs are reported back as `deduped`
|
|
154
|
+
* (and logged as `record.deduped`), never packed. `included` mirrors the
|
|
155
|
+
* fast pointers that entered the context and `slowIncluded` the slow ones
|
|
156
|
+
* — only these may be marked as injected by the caller; budget-dropped
|
|
157
|
+
* slugs stay injectable later.
|
|
158
|
+
*
|
|
159
|
+
* `slow` (v4 §4.2) carries the consumed pending when one exists: picks are
|
|
160
|
+
* packed after the fast pointers under the same budget, take log-only
|
|
161
|
+
* shadow re-gate verdicts against the CURRENT query (B3: record, never
|
|
162
|
+
* block), and fill the record's lane field family.
|
|
154
163
|
*/
|
|
155
|
-
retrieveSync(query, sessionId, dedup) {
|
|
164
|
+
retrieveSync(query, sessionId, dedup, slow, slowExpired) {
|
|
156
165
|
const cfg = this.cfg;
|
|
157
166
|
const roster = this.rosterSync();
|
|
158
167
|
const empty = { hits: [], nearMisses: [], rosterSize: roster.length };
|
|
159
168
|
if (!cfg.autoInject || query.trim() === '' || roster.length === 0) {
|
|
160
|
-
|
|
169
|
+
// A consumed/expired pending must always leave an ilog trace (ADR 0014:
|
|
170
|
+
// 过期/消费都写 ilog) — even when the fast path early-returns here
|
|
171
|
+
// because the roster vanished between production and consumption.
|
|
172
|
+
if (cfg.autoInject && query.trim() !== '' && (slow !== undefined || slowExpired !== undefined)) {
|
|
173
|
+
const trace = {
|
|
174
|
+
at: new Date().toISOString(),
|
|
175
|
+
queryTokenCount: tokenize(query).length,
|
|
176
|
+
querySample: querySample(query),
|
|
177
|
+
rosterSize: roster.length,
|
|
178
|
+
hits: [],
|
|
179
|
+
nearMisses: [],
|
|
180
|
+
injected: false,
|
|
181
|
+
why: slowExpired !== undefined ? `slow-expired-${slowExpired}` : 'slow-no-roster',
|
|
182
|
+
lane: 'slow',
|
|
183
|
+
};
|
|
184
|
+
if (sessionId !== undefined)
|
|
185
|
+
trace.sessionId = sessionId;
|
|
186
|
+
if (slowExpired !== undefined)
|
|
187
|
+
trace.slowExpired = slowExpired;
|
|
188
|
+
if (slow !== undefined) {
|
|
189
|
+
trace.computedAt = slow.computedAt;
|
|
190
|
+
trace.queryBuild = slow.queryBuild;
|
|
191
|
+
trace.slowModel = slow.model;
|
|
192
|
+
trace.slowMs = slow.ms;
|
|
193
|
+
trace.slow = [];
|
|
194
|
+
}
|
|
195
|
+
void this.store.appendInjectionRecord(trace).catch(() => undefined);
|
|
196
|
+
}
|
|
197
|
+
return { text: '', outcome: empty, deduped: [], included: [], slowIncluded: [] };
|
|
161
198
|
}
|
|
162
199
|
const conflicts = this.store.getConflictsSync();
|
|
163
200
|
const outcome = searchTopics(query, roster, {
|
|
@@ -170,25 +207,81 @@ export class TopicsService {
|
|
|
170
207
|
});
|
|
171
208
|
const exclude = dedup?.exclude;
|
|
172
209
|
const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug);
|
|
210
|
+
const bySlug = new Map(roster.map((r) => [r.slug, r]));
|
|
173
211
|
const entries = [];
|
|
212
|
+
const unreadable = [];
|
|
174
213
|
for (const hit of outcome.hits) {
|
|
175
214
|
if (exclude?.has(hit.slug))
|
|
176
215
|
continue;
|
|
177
216
|
const doc = this.readDocSync(hit.slug);
|
|
178
|
-
if (doc
|
|
179
|
-
|
|
217
|
+
if (doc === undefined) {
|
|
218
|
+
// The hit has no readable doc — without this accounting it used to
|
|
219
|
+
// vanish from `included` AND `dropped` (v4 design §2 疑点).
|
|
220
|
+
unreadable.push(hit.slug);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
entries.push({ slug: hit.slug, doc, hit });
|
|
224
|
+
}
|
|
225
|
+
// ---- Slow-lane merge (log-only shadow gate; consumption bookkeeping) ----
|
|
226
|
+
const slowPointers = [];
|
|
227
|
+
const slowDelivered = [];
|
|
228
|
+
const shadow = [];
|
|
229
|
+
let slowIncluded = [];
|
|
230
|
+
const consumedAt = new Date().toISOString();
|
|
231
|
+
if (slow !== undefined) {
|
|
232
|
+
const queryTokens = new Set(tokenize(query));
|
|
233
|
+
for (const item of slow.items) {
|
|
234
|
+
if (exclude?.has(item.slug)) {
|
|
235
|
+
if (!deduped.includes(item.slug))
|
|
236
|
+
deduped.push(item.slug);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const meta = bySlug.get(item.slug);
|
|
240
|
+
if (meta === undefined)
|
|
241
|
+
continue; // vanished between production and consumption
|
|
242
|
+
const verdict = scoreTopic(queryTokens, meta, {
|
|
243
|
+
threshold: cfg.matchThreshold,
|
|
244
|
+
topK: cfg.topK,
|
|
245
|
+
tagBoost: cfg.tagBoost,
|
|
246
|
+
graphDepth: cfg.graphDepth,
|
|
247
|
+
recencyWindowDays: cfg.recencyWindowDays,
|
|
248
|
+
conflicts,
|
|
249
|
+
structuralGate: true,
|
|
250
|
+
});
|
|
251
|
+
shadow.push({
|
|
252
|
+
slug: item.slug,
|
|
253
|
+
pass: passesGate(verdict),
|
|
254
|
+
why: verdict.strong ? `strong:${verdict.reasons[0] ?? 'field'}` : `body-hits:${verdict.bodyHits}`,
|
|
255
|
+
});
|
|
256
|
+
slowPointers.push({
|
|
257
|
+
slug: item.slug,
|
|
258
|
+
title: meta.title,
|
|
259
|
+
status: meta.status,
|
|
260
|
+
description: meta.description,
|
|
261
|
+
why: item.why,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const pointerMode = cfg.injectMode !== 'digest';
|
|
266
|
+
// Pointer budget: per-entry fixed at the design's 80, total clamped to the
|
|
267
|
+
// fast-lane upper bound of 600 (I4) — totalBudget stays tunable downward.
|
|
268
|
+
const injection = pointerMode
|
|
269
|
+
? assemblePointer(entries, { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) }, slowPointers)
|
|
270
|
+
: assembleInjection(entries, { perTopicBudget: cfg.perTopicBudget, totalBudget: cfg.totalBudget }, slowPointers);
|
|
271
|
+
slowIncluded = injection.slowIncluded ?? [];
|
|
272
|
+
for (const pointer of slowPointers) {
|
|
273
|
+
if (slowIncluded.includes(pointer.slug))
|
|
274
|
+
slowDelivered.push({ slug: pointer.slug, why: pointer.why });
|
|
180
275
|
}
|
|
181
|
-
const injection = assembleInjection(entries, {
|
|
182
|
-
perTopicBudget: cfg.perTopicBudget,
|
|
183
|
-
totalBudget: cfg.totalBudget,
|
|
184
|
-
});
|
|
185
276
|
const record = {
|
|
186
277
|
at: new Date().toISOString(),
|
|
187
278
|
queryTokenCount: tokenize(query).length,
|
|
188
279
|
querySample: querySample(query),
|
|
189
280
|
rosterSize: outcome.rosterSize,
|
|
190
|
-
hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph })),
|
|
191
|
-
|
|
281
|
+
hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph, strong: h.strong, bodyHits: h.bodyHits })),
|
|
282
|
+
// Reasons ride along: the gate-blocked marker is the replay evidence
|
|
283
|
+
// the P3 threshold decision needs (ADR 0014).
|
|
284
|
+
nearMisses: outcome.nearMisses.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons })),
|
|
192
285
|
injected: injection.text !== '',
|
|
193
286
|
usedTokens: injection.usedTokens,
|
|
194
287
|
};
|
|
@@ -196,14 +289,73 @@ export class TopicsService {
|
|
|
196
289
|
record.sessionId = sessionId;
|
|
197
290
|
if (deduped.length > 0)
|
|
198
291
|
record.deduped = deduped;
|
|
199
|
-
if (
|
|
292
|
+
if (unreadable.length > 0) {
|
|
293
|
+
record.dropped = [...(record.dropped ?? []), ...unreadable.map((slug) => ({ slug, reason: 'doc-unreadable' }))];
|
|
294
|
+
}
|
|
295
|
+
if (slow !== undefined) {
|
|
296
|
+
// Any round whose pending was consumed is a slow-participating round —
|
|
297
|
+
// lane never stays undefined when the lane family is present (a
|
|
298
|
+
// delivered pick with fast pointers is 'mixed'; a consumed-but-empty
|
|
299
|
+
// round beside fast pointers stays 'fast'; everything else 'slow').
|
|
300
|
+
record.lane = slowDelivered.length > 0 ? (injection.included.length > 0 ? 'mixed' : 'slow') : injection.included.length > 0 ? 'fast' : 'slow';
|
|
301
|
+
record.computedAt = slow.computedAt;
|
|
302
|
+
record.consumedAt = consumedAt;
|
|
303
|
+
if (shadow.length > 0)
|
|
304
|
+
record.shadowVerdict = shadow;
|
|
305
|
+
record.queryBuild = slow.queryBuild;
|
|
306
|
+
record.slowModel = slow.model;
|
|
307
|
+
record.slowMs = slow.ms;
|
|
308
|
+
record.slow = slowDelivered;
|
|
309
|
+
}
|
|
310
|
+
if (slowExpired !== undefined) {
|
|
311
|
+
// Expiry is recorded INDEPENDENT of whether the fast lane injected —
|
|
312
|
+
// the 赶上率 denominator must not lose rounds to a coincidental hit.
|
|
313
|
+
record.slowExpired = slowExpired;
|
|
314
|
+
if (record.lane === undefined)
|
|
315
|
+
record.lane = injection.text !== '' ? 'fast' : 'slow';
|
|
316
|
+
}
|
|
317
|
+
if (injection.text === '' && (outcome.hits.length > 0 || slowPointers.length > 0)) {
|
|
200
318
|
// All hits deduped is a different story than a budget squeeze — say so.
|
|
201
|
-
record.why = deduped.length === outcome.hits.length ? 'dedup' : 'below-budget-or-dropped';
|
|
319
|
+
record.why = deduped.length === outcome.hits.length && outcome.hits.length > 0 ? 'dedup' : 'below-budget-or-dropped';
|
|
320
|
+
}
|
|
321
|
+
else if (injection.text === '' && slowExpired !== undefined) {
|
|
322
|
+
// The pending died at its hard bounds (TTL / turn-lag) without ever
|
|
323
|
+
// being delivered — the denominator the 赶上率 needs (v4 B4).
|
|
324
|
+
record.why = `slow-expired-${slowExpired}`;
|
|
202
325
|
}
|
|
326
|
+
if (slowExpired !== undefined && injection.text === '')
|
|
327
|
+
record.lane = 'slow';
|
|
203
328
|
if (injection.dropped.length > 0)
|
|
204
|
-
record.dropped = injection.dropped;
|
|
329
|
+
record.dropped = [...(record.dropped ?? []), ...injection.dropped];
|
|
205
330
|
void this.store.appendInjectionRecord(record).catch(() => undefined);
|
|
206
|
-
return { text: injection.text, outcome, deduped, included: injection.included };
|
|
331
|
+
return { text: injection.text, outcome, deduped, included: injection.included, slowIncluded };
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* topic_open tool path (v4 §4.1): full conclusion / open questions /
|
|
335
|
+
* recommendations plus the staleness notice timestamp (fm.generated.at —
|
|
336
|
+
* the store stamps it on every write, so it IS the last-updated instant).
|
|
337
|
+
* Logs an open record for the pointer-open-rate stat.
|
|
338
|
+
*/
|
|
339
|
+
async openTopic(slug, sessionId) {
|
|
340
|
+
const doc = await this.store.readTopic(slug).catch(() => undefined);
|
|
341
|
+
if (doc === undefined)
|
|
342
|
+
return { found: false, slug };
|
|
343
|
+
// Awaited (tool path, not hot): a settled write makes the pointer-open
|
|
344
|
+
// stat reliable; a failed log write must not fail the tool.
|
|
345
|
+
await this.store
|
|
346
|
+
.appendOpenRecord({ slug, at: new Date().toISOString(), ...(sessionId !== undefined ? { sessionId } : {}) })
|
|
347
|
+
.catch(() => undefined);
|
|
348
|
+
return {
|
|
349
|
+
found: true,
|
|
350
|
+
slug,
|
|
351
|
+
title: doc.fm.title,
|
|
352
|
+
status: doc.fm.status,
|
|
353
|
+
updatedAt: doc.fm.generated.at,
|
|
354
|
+
description: doc.fm.description,
|
|
355
|
+
conclusion: okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '',
|
|
356
|
+
openQuestions: doc.fm.open_questions,
|
|
357
|
+
recommendations: okf.sectionOf(doc.body, okf.RECOMMENDATIONS_HEADING) ?? '',
|
|
358
|
+
};
|
|
207
359
|
}
|
|
208
360
|
/** Sync roster read (same mtime cache as roster()). */
|
|
209
361
|
rosterSync() {
|
|
@@ -248,6 +400,7 @@ export class TopicsService {
|
|
|
248
400
|
description: doc.fm.description,
|
|
249
401
|
status: doc.fm.status,
|
|
250
402
|
tags: doc.fm.tags,
|
|
403
|
+
triggers: doc.fm.triggers,
|
|
251
404
|
depends: doc.fm.depends,
|
|
252
405
|
links: okf.bodyLinkSlugs(doc.body),
|
|
253
406
|
generatedAt: doc.fm.generated.at,
|
|
@@ -301,6 +454,11 @@ export class TopicsService {
|
|
|
301
454
|
};
|
|
302
455
|
if (input.description !== undefined && input.description !== '')
|
|
303
456
|
fm.description = input.description;
|
|
457
|
+
// Triggers pass through on create; on update the caller's value wins, else
|
|
458
|
+
// the stored list survives (same preserve-on-update semantics as tags).
|
|
459
|
+
const triggers = sanitizeTriggers(input.triggers ?? existing?.fm.triggers ?? []);
|
|
460
|
+
if (triggers.length > 0)
|
|
461
|
+
fm.triggers = triggers;
|
|
304
462
|
if (existing !== undefined && existing.fm.verified !== undefined)
|
|
305
463
|
fm.verified = existing.fm.verified;
|
|
306
464
|
const result = await this.store.saveTopic({ slug, doc: { fm, body } }, { message: existing === undefined ? `topics(topic): create ${slug}` : `topics(topic): update ${slug}`, created: existing === undefined });
|
|
@@ -319,6 +477,9 @@ export class TopicsService {
|
|
|
319
477
|
tagBoost: cfg.tagBoost,
|
|
320
478
|
graphDepth: cfg.graphDepth,
|
|
321
479
|
recencyWindowDays: cfg.recencyWindowDays,
|
|
480
|
+
// Explicit tool: the model asked for recall, so the injection-only
|
|
481
|
+
// structural gate must not hide results.
|
|
482
|
+
structuralGate: false,
|
|
322
483
|
conflicts: await this.store.getConflicts(),
|
|
323
484
|
});
|
|
324
485
|
}
|
|
@@ -373,3 +534,15 @@ function dedupeLower(tags) {
|
|
|
373
534
|
}
|
|
374
535
|
return out;
|
|
375
536
|
}
|
|
537
|
+
function sanitizeTriggers(triggers) {
|
|
538
|
+
const out = [];
|
|
539
|
+
for (const t of triggers) {
|
|
540
|
+
if (typeof t !== 'string')
|
|
541
|
+
continue;
|
|
542
|
+
const key = t.trim();
|
|
543
|
+
if (key === '')
|
|
544
|
+
continue;
|
|
545
|
+
out.push(key);
|
|
546
|
+
}
|
|
547
|
+
return out;
|
|
548
|
+
}
|
package/lib/store.d.ts
CHANGED
|
@@ -135,6 +135,19 @@ export declare class BundleStore {
|
|
|
135
135
|
appendInjectionRecord(record: unknown): Promise<void>;
|
|
136
136
|
private compactInjectionsIfNeeded;
|
|
137
137
|
readInjectionRecords(limit?: number): Promise<InjectionRecord[]>;
|
|
138
|
+
private opensPath;
|
|
139
|
+
appendOpenRecord(record: {
|
|
140
|
+
slug: string;
|
|
141
|
+
at: string;
|
|
142
|
+
sessionId?: string;
|
|
143
|
+
}): Promise<void>;
|
|
144
|
+
private compactOpensIfNeeded;
|
|
145
|
+
/** Opens in the recent window; tolerant of a torn tail like the other JSONLs. */
|
|
146
|
+
readOpenRecords(limit?: number): Promise<{
|
|
147
|
+
slug: string;
|
|
148
|
+
at: string;
|
|
149
|
+
sessionId?: string;
|
|
150
|
+
}[]>;
|
|
138
151
|
getConflicts(): Promise<Set<string>>;
|
|
139
152
|
/** Sync variant for the injection hot path. */
|
|
140
153
|
getConflictsSync(): Set<string>;
|
package/lib/store.js
CHANGED
|
@@ -425,6 +425,60 @@ export class BundleStore {
|
|
|
425
425
|
return out.slice(-limit);
|
|
426
426
|
}
|
|
427
427
|
// ------------------------------------------------------------------
|
|
428
|
+
// Pointer-open log (v4 §4.3) — one line per topic_open call; feeds the
|
|
429
|
+
// 「指针打开率」 stat. Features only (slug/time), never conversation text.
|
|
430
|
+
// ------------------------------------------------------------------
|
|
431
|
+
opensPath() {
|
|
432
|
+
return join(this.metaDir(), 'opens.jsonl');
|
|
433
|
+
}
|
|
434
|
+
async appendOpenRecord(record) {
|
|
435
|
+
await mkdir(this.metaDir(), { recursive: true });
|
|
436
|
+
await appendFile(this.opensPath(), `${JSON.stringify(record)}\n`, 'utf8');
|
|
437
|
+
await this.compactOpensIfNeeded();
|
|
438
|
+
}
|
|
439
|
+
async compactOpensIfNeeded() {
|
|
440
|
+
const file = this.opensPath();
|
|
441
|
+
let size = 0;
|
|
442
|
+
try {
|
|
443
|
+
size = (await readFile(file, 'utf8')).length;
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (size <= 512 * 1024)
|
|
449
|
+
return;
|
|
450
|
+
const lines = (await readFile(file, 'utf8')).split('\n').filter((l) => l.trim() !== '');
|
|
451
|
+
await atomicWrite(file, lines.slice(-Math.max(1, Math.floor(lines.length / 4))).map((l) => `${l}\n`).join(''));
|
|
452
|
+
}
|
|
453
|
+
/** Opens in the recent window; tolerant of a torn tail like the other JSONLs. */
|
|
454
|
+
async readOpenRecords(limit = 2000) {
|
|
455
|
+
let raw;
|
|
456
|
+
try {
|
|
457
|
+
raw = await readFile(this.opensPath(), 'utf8');
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
return [];
|
|
461
|
+
}
|
|
462
|
+
const out = [];
|
|
463
|
+
for (const line of raw.split('\n')) {
|
|
464
|
+
if (line.trim() === '')
|
|
465
|
+
continue;
|
|
466
|
+
try {
|
|
467
|
+
const parsed = JSON.parse(line);
|
|
468
|
+
if (typeof parsed.slug === 'string' && typeof parsed.at === 'string') {
|
|
469
|
+
const rec = { slug: parsed.slug, at: parsed.at };
|
|
470
|
+
if (typeof parsed.sessionId === 'string')
|
|
471
|
+
rec.sessionId = parsed.sessionId;
|
|
472
|
+
out.push(rec);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
// Torn tail line tolerated.
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return out.slice(-limit);
|
|
480
|
+
}
|
|
481
|
+
// ------------------------------------------------------------------
|
|
428
482
|
// Conflicted topics (ADR 0003) — retrieval demotes these
|
|
429
483
|
// ------------------------------------------------------------------
|
|
430
484
|
async getConflicts() {
|
package/lib/tools.js
CHANGED
|
@@ -22,6 +22,7 @@ export function buildTopicTools(service) {
|
|
|
22
22
|
conclusion: { type: 'string', required: true, description: 'The current best conclusion, self-contained markdown prose' },
|
|
23
23
|
description: { type: 'string', description: 'One-line summary for indexes and snippets' },
|
|
24
24
|
tags: { ...STRING_ARRAY, description: 'Cross-cutting tags (project, domain, component); lowercased on save' },
|
|
25
|
+
triggers: { ...STRING_ARRAY, description: 'Short recall phrases that should bring this topic back (specific nouns/terms, no generic words)' },
|
|
25
26
|
depends: { ...STRING_ARRAY, description: 'Slugs of prerequisite topics (topics this one builds on)' },
|
|
26
27
|
open_questions: { ...STRING_ARRAY, description: 'Unresolved questions this topic still carries' },
|
|
27
28
|
impact: { ...STRING_ARRAY, description: 'What this conclusion affects: topics, projects, decisions' },
|
|
@@ -53,6 +54,7 @@ export function buildTopicTools(service) {
|
|
|
53
54
|
conclusion: args.conclusion,
|
|
54
55
|
description: args.description,
|
|
55
56
|
tags: args.tags,
|
|
57
|
+
triggers: args.triggers,
|
|
56
58
|
depends: args.depends,
|
|
57
59
|
openQuestions: args.open_questions,
|
|
58
60
|
impact: args.impact,
|
|
@@ -64,6 +66,52 @@ export function buildTopicTools(service) {
|
|
|
64
66
|
return { slug: result.slug, path: result.path, created: result.created, committed: result.committed };
|
|
65
67
|
},
|
|
66
68
|
});
|
|
69
|
+
const topicOpen = defineTool({
|
|
70
|
+
name: 'topic_open',
|
|
71
|
+
description: 'Open one topic from the long-term memory in full: conclusion, open questions, recommendations, ' +
|
|
72
|
+
'plus a staleness notice. Use when a <topic-memory> pointer (or a search hit) is actually relevant — ' +
|
|
73
|
+
'pointers are one-line hints; this fetches the real content.',
|
|
74
|
+
parameters: {
|
|
75
|
+
slug: { type: 'string', required: true, description: 'Topic slug (from a pointer or topic_search)' },
|
|
76
|
+
},
|
|
77
|
+
output: {
|
|
78
|
+
schema: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
additionalProperties: false,
|
|
81
|
+
properties: {
|
|
82
|
+
found: { type: 'boolean', required: true },
|
|
83
|
+
slug: { type: 'string', required: true },
|
|
84
|
+
title: { type: 'string' },
|
|
85
|
+
status: { type: 'string' },
|
|
86
|
+
updatedAt: { type: 'string' },
|
|
87
|
+
description: { type: 'string' },
|
|
88
|
+
conclusion: { type: 'string' },
|
|
89
|
+
openQuestions: { type: 'array', items: { type: 'string' } },
|
|
90
|
+
recommendations: { type: 'string' },
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
render: (_args, value) => [
|
|
94
|
+
{
|
|
95
|
+
type: 'text',
|
|
96
|
+
text: !value.found
|
|
97
|
+
? `Topic \`${value.slug}\` 不存在(可能已合并或改名;用 topic_search 找)。`
|
|
98
|
+
: [
|
|
99
|
+
`快照于 ${value.updatedAt},代码事实以源码为准。`,
|
|
100
|
+
`### ${value.title} [${value.status}] (topics:${value.slug})`,
|
|
101
|
+
value.description ?? '',
|
|
102
|
+
value.conclusion !== '' ? `# Conclusion\n\n${value.conclusion}` : '',
|
|
103
|
+
value.openQuestions !== undefined && value.openQuestions.length > 0 ? `待决: ${value.openQuestions.join(';')}` : '',
|
|
104
|
+
value.recommendations !== '' ? `# Recommendations\n\n${value.recommendations}` : '',
|
|
105
|
+
]
|
|
106
|
+
.filter((l) => l !== '')
|
|
107
|
+
.join('\n\n'),
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
async execute(args) {
|
|
112
|
+
return await service.openTopic(args.slug);
|
|
113
|
+
},
|
|
114
|
+
});
|
|
67
115
|
const topicSearch = defineTool({
|
|
68
116
|
name: 'topic_search',
|
|
69
117
|
description: 'Search the long-term topic memory by keywords (hot path, no LLM). ' +
|
|
@@ -199,7 +247,7 @@ export function buildTopicTools(service) {
|
|
|
199
247
|
return { entries: entries.map((e) => ({ ...e, conclusion: e.conclusion })) };
|
|
200
248
|
},
|
|
201
249
|
});
|
|
202
|
-
return [topicSave, topicSearch, topicObserve, topicHistory];
|
|
250
|
+
return [topicSave, topicOpen, topicSearch, topicObserve, topicHistory];
|
|
203
251
|
}
|
|
204
252
|
function extractConclusion(body) {
|
|
205
253
|
// Local copy to avoid importing okf section helpers into the hot render path.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwayds/dsh-topics-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "dsh-plugin: OKF v0.2 topic memory for dsh - local-first git-tracked bundle, hot-path LLM-free injection, two-stage observer with background distill",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -38,16 +38,16 @@
|
|
|
38
38
|
"build": "tsc -p tsconfig.json",
|
|
39
39
|
"check": "tsc --noEmit -p tsconfig.json",
|
|
40
40
|
"pretest": "tsc -p tsconfig.json",
|
|
41
|
-
"test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/settings-migration.test.mjs"
|
|
41
|
+
"test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/settings-migration.test.mjs test/quality.test.mjs test/replay-gate.test.mjs"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
46
|
-
"@deepseek-ai/dsh-tools": ">=0.1.2-
|
|
47
|
-
"@deepseek-ai/dsh-llm": ">=0.1.2-
|
|
48
|
-
"@deepseek-ai/dsh-settings": ">=0.1.2-
|
|
49
|
-
"@deepseek-ai/dsh-commands": ">=0.1.2-
|
|
50
|
-
"@deepseek-ai/dsh-util-values": ">=0.1.2-
|
|
46
|
+
"@deepseek-ai/dsh-tools": ">=0.1.2-rc.1",
|
|
47
|
+
"@deepseek-ai/dsh-llm": ">=0.1.2-rc.1",
|
|
48
|
+
"@deepseek-ai/dsh-settings": ">=0.1.2-rc.1",
|
|
49
|
+
"@deepseek-ai/dsh-commands": ">=0.1.2-rc.1",
|
|
50
|
+
"@deepseek-ai/dsh-util-values": ">=0.1.2-rc.1",
|
|
51
51
|
"@deepseek-ai/schemastery": "^3.18.2"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
@@ -75,21 +75,21 @@
|
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
77
|
"@deepseek-ai/cordis": "4.0.2",
|
|
78
|
-
"@deepseek-ai/dsh-agent": "0.1.2-
|
|
79
|
-
"@deepseek-ai/dsh-attachment": "0.1.2-
|
|
80
|
-
"@deepseek-ai/dsh-brand": "0.1.2-
|
|
81
|
-
"@deepseek-ai/dsh-code-runtime": "0.1.2-
|
|
82
|
-
"@deepseek-ai/dsh-commands": "0.1.2-
|
|
83
|
-
"@deepseek-ai/dsh-invariants": "0.1.2-
|
|
84
|
-
"@deepseek-ai/dsh-llm": "0.1.2-
|
|
85
|
-
"@deepseek-ai/dsh-scope": "0.1.2-
|
|
86
|
-
"@deepseek-ai/dsh-session": "0.1.2-
|
|
87
|
-
"@deepseek-ai/dsh-settings": "0.1.2-
|
|
88
|
-
"@deepseek-ai/dsh-system-prompt": "0.1.2-
|
|
89
|
-
"@deepseek-ai/dsh-timeout": "0.1.2-
|
|
90
|
-
"@deepseek-ai/dsh-tools": "0.1.2-
|
|
91
|
-
"@deepseek-ai/dsh-user-approval": "0.1.2-
|
|
92
|
-
"@deepseek-ai/dsh-util-values": "0.1.2-
|
|
78
|
+
"@deepseek-ai/dsh-agent": "0.1.2-rc.1",
|
|
79
|
+
"@deepseek-ai/dsh-attachment": "0.1.2-rc.1",
|
|
80
|
+
"@deepseek-ai/dsh-brand": "0.1.2-rc.1",
|
|
81
|
+
"@deepseek-ai/dsh-code-runtime": "0.1.2-rc.1",
|
|
82
|
+
"@deepseek-ai/dsh-commands": "0.1.2-rc.1",
|
|
83
|
+
"@deepseek-ai/dsh-invariants": "0.1.2-rc.1",
|
|
84
|
+
"@deepseek-ai/dsh-llm": "0.1.2-rc.1",
|
|
85
|
+
"@deepseek-ai/dsh-scope": "0.1.2-rc.1",
|
|
86
|
+
"@deepseek-ai/dsh-session": "0.1.2-rc.1",
|
|
87
|
+
"@deepseek-ai/dsh-settings": "0.1.2-rc.1",
|
|
88
|
+
"@deepseek-ai/dsh-system-prompt": "0.1.2-rc.1",
|
|
89
|
+
"@deepseek-ai/dsh-timeout": "0.1.2-rc.1",
|
|
90
|
+
"@deepseek-ai/dsh-tools": "0.1.2-rc.1",
|
|
91
|
+
"@deepseek-ai/dsh-user-approval": "0.1.2-rc.1",
|
|
92
|
+
"@deepseek-ai/dsh-util-values": "0.1.2-rc.1",
|
|
93
93
|
"@deepseek-ai/schemastery": "3.18.2",
|
|
94
94
|
"@types/node": "^22.19.0",
|
|
95
95
|
"typescript": "^5.9.2"
|
package/src/commands.ts
CHANGED
|
@@ -122,8 +122,9 @@ async function renderStatus(service: TopicsService): Promise<string> {
|
|
|
122
122
|
` 冲突:${s.conflicts.length === 0 ? '无' : s.conflicts.join('、')}`,
|
|
123
123
|
` 损坏文件:${s.broken.length === 0 ? '无' : s.broken.join('、')}`,
|
|
124
124
|
` git:${s.git ? `是(HEAD ${s.head?.slice(0, 10) ?? '??'})` : '否'}`,
|
|
125
|
-
` 注入:${cfg.autoInject ?
|
|
125
|
+
` 注入:${cfg.autoInject ? `${cfg.injectMode === 'digest' ? 'digest' : 'pointer'} 模式(topK ${cfg.topK},预算 ${cfg.injectMode === 'digest' ? cfg.totalBudget : Math.min(cfg.totalBudget, 600)} tok,阈值 ${cfg.matchThreshold})` : '关'}`,
|
|
126
126
|
` 去重:${cfg.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'}`,
|
|
127
|
+
` 慢道:${cfg.qualityLane === 'off' ? '关' : cfg.qualityLane === 'always' ? `每轮(${cfg.distillProvider}/${cfg.distillModel})` : `采样 1/3(${cfg.distillProvider}/${cfg.distillModel})`}`,
|
|
127
128
|
` 蒸馏:${cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'}`,
|
|
128
129
|
]
|
|
129
130
|
// Last lane outcome (distill-state summary) — what "checkable via
|
|
@@ -181,12 +182,35 @@ async function renderStats(service: TopicsService): Promise<string> {
|
|
|
181
182
|
` 零命中轮:${stats.zeroHitRounds};平均命中 ${stats.avgHitsPerRound} 条/轮`,
|
|
182
183
|
` 平均预算占用:${stats.avgBudgetUtilization} tok`,
|
|
183
184
|
]
|
|
185
|
+
// v4 lane split — how much of the injection traffic each lane carries.
|
|
186
|
+
const slowRounds = records.filter((r) => r.lane === 'slow' || r.lane === 'mixed').length
|
|
187
|
+
if (slowRounds > 0) {
|
|
188
|
+
const consumed = records.filter((r) => r.consumedAt !== undefined && r.computedAt !== undefined)
|
|
189
|
+
let medianLag = ''
|
|
190
|
+
if (consumed.length > 0) {
|
|
191
|
+
const lags = consumed
|
|
192
|
+
.map((r) => Math.max(0, Date.parse(r.consumedAt as string) - Date.parse(r.computedAt as string)))
|
|
193
|
+
.sort((a, b) => a - b)
|
|
194
|
+
medianLag = `,赶上中位时延 ${(lags[Math.floor(lags.length / 2)] / 1000).toFixed(1)}s`
|
|
195
|
+
}
|
|
196
|
+
lines.push(` 慢道参与轮:${slowRounds}(快 ${(records.length - slowRounds)} / 慢或混合 ${slowRounds}${medianLag})`)
|
|
197
|
+
}
|
|
198
|
+
// Pointer open rate (v4 §4.3): topic_open calls vs pointer entries injected.
|
|
199
|
+
const opens = await service.store.readOpenRecords()
|
|
200
|
+
if (opens.length > 0 || records.some((r) => r.lane !== undefined)) {
|
|
201
|
+
const entries = records.reduce(
|
|
202
|
+
(acc, r) => acc + r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug)).length + (r.slow?.length ?? 0),
|
|
203
|
+
0,
|
|
204
|
+
)
|
|
205
|
+
const rate = entries === 0 ? 0 : Math.min(1, opens.length / entries)
|
|
206
|
+
lines.push(` 指针打开率:${(rate * 100).toFixed(1)}%(${opens.length} 次 topic_open / ${entries} 条注入指针)`)
|
|
207
|
+
}
|
|
184
208
|
if (stats.topTopics.length > 0) {
|
|
185
209
|
lines.push(' Top-N 被注入 Topic:')
|
|
186
210
|
for (const t of stats.topTopics.slice(0, 5)) lines.push(` ${t.slug} ×${t.count}`)
|
|
187
211
|
}
|
|
188
212
|
if (stats.nearMissHistogram.length > 0) {
|
|
189
|
-
lines.push(' Near-miss
|
|
213
|
+
lines.push(' Near-miss 分布(低于阈值或被结构门挡下):')
|
|
190
214
|
for (const b of stats.nearMissHistogram) lines.push(` ${b.bucket}: ${b.count}`)
|
|
191
215
|
const hint = tuningHint(stats, service.cfg.matchThreshold)
|
|
192
216
|
if (hint !== undefined) lines.push(` 💡 ${hint}`)
|
package/src/config.ts
CHANGED
|
@@ -22,16 +22,22 @@ export const TopicsConfig = z.object({
|
|
|
22
22
|
totalBudget: z.number().default(1500),
|
|
23
23
|
/** Retrieval score threshold — tune via /topics stats near-miss evidence. */
|
|
24
24
|
matchThreshold: z.number().default(0.3),
|
|
25
|
-
/** Additive boost per tag hit. */
|
|
25
|
+
/** Additive boost per tag hit (v4: total cap = this value, was ×3). */
|
|
26
26
|
tagBoost: z.number().default(0.15),
|
|
27
|
+
/** Injection shape: pointer (default, ≤600 tok) keeps the legacy digest view. */
|
|
28
|
+
injectMode: z.string().default('pointer'),
|
|
29
|
+
/** Slow quality lane (v4 §4.2): off | sampled (1/3 of turns) | always. */
|
|
30
|
+
qualityLane: z.string().default('sampled'),
|
|
27
31
|
/** depends-graph expansion depth (0 disables). */
|
|
28
32
|
graphDepth: z.number().default(2),
|
|
29
33
|
/** Days within which a topic counts as recent (+0.2). */
|
|
30
34
|
recencyWindowDays: z.number().default(7),
|
|
31
35
|
/** Capture each turn's user/assistant text as raw observations (M2). */
|
|
32
36
|
autoObserve: z.boolean().default(true),
|
|
33
|
-
/** Whether injection and observation also engage delegated subagent sessions (ADR 0011).
|
|
34
|
-
|
|
37
|
+
/** Whether injection and observation also engage delegated subagent sessions (ADR 0011).
|
|
38
|
+
* v4 default flipped to false: one-shot subagent turns diluted the pool.
|
|
39
|
+
* The slow quality lane never runs for subagents either way (hard guard). */
|
|
40
|
+
includeSubagents: z.boolean().default(false),
|
|
35
41
|
/** Max auto-captured chars per side (user/assistant) per turn. */
|
|
36
42
|
observationMaxChars: z.number().default(2000),
|
|
37
43
|
/** Background distill cadence: every N turns of a long session. */
|
|
@@ -60,6 +66,8 @@ export type TopicsConfigValue = {
|
|
|
60
66
|
totalBudget: number
|
|
61
67
|
matchThreshold: number
|
|
62
68
|
tagBoost: number
|
|
69
|
+
injectMode: string
|
|
70
|
+
qualityLane: string
|
|
63
71
|
graphDepth: number
|
|
64
72
|
recencyWindowDays: number
|
|
65
73
|
autoObserve: boolean
|
|
@@ -83,6 +91,8 @@ export const CONFIG_KEYS = [
|
|
|
83
91
|
'totalBudget',
|
|
84
92
|
'matchThreshold',
|
|
85
93
|
'tagBoost',
|
|
94
|
+
'injectMode',
|
|
95
|
+
'qualityLane',
|
|
86
96
|
'graphDepth',
|
|
87
97
|
'recencyWindowDays',
|
|
88
98
|
'autoObserve',
|
|
@@ -136,6 +146,14 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
|
|
|
136
146
|
if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return { error: `${key} 需要非负整数` }
|
|
137
147
|
return n
|
|
138
148
|
}
|
|
149
|
+
case 'injectMode': {
|
|
150
|
+
if (raw === 'pointer' || raw === 'digest') return raw
|
|
151
|
+
return { error: 'inject-mode 取值 pointer|digest' }
|
|
152
|
+
}
|
|
153
|
+
case 'qualityLane': {
|
|
154
|
+
if (raw === 'off' || raw === 'sampled' || raw === 'always') return raw
|
|
155
|
+
return { error: 'quality-lane 取值 off|sampled|always' }
|
|
156
|
+
}
|
|
139
157
|
case 'matchThreshold':
|
|
140
158
|
case 'tagBoost': {
|
|
141
159
|
const n = Number(raw)
|