@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/src/quality.ts
CHANGED
|
@@ -36,6 +36,8 @@ export const SAMPLED_EVERY = 3
|
|
|
36
36
|
const RING_CHARS = 6000
|
|
37
37
|
/** Candidate band size fed to the rerank. */
|
|
38
38
|
const CANDIDATE_LIMIT = 6
|
|
39
|
+
/** Over-fetch before the exclude filter, so excluded slugs don't thin the band. */
|
|
40
|
+
const CANDIDATE_OVERFETCH = 6
|
|
39
41
|
|
|
40
42
|
export const QUERY_BUILD_PROMPT = [
|
|
41
43
|
'你是检索查询构建器。输入是最近几轮对话。任务:判断「模型此刻需要什么背景知识」,输出一个用于关键词检索的 query。',
|
|
@@ -70,6 +72,14 @@ export type ConsumeResult = { pending: PendingInjection } | { expired: 'ttl' | '
|
|
|
70
72
|
export interface DispatchInput {
|
|
71
73
|
ring: readonly RingEntry[]
|
|
72
74
|
turnId: number
|
|
75
|
+
/**
|
|
76
|
+
* Slugs the session already carries — injected earlier this session or
|
|
77
|
+
* distilled from its own turns (echo). The candidate band drops them
|
|
78
|
+
* BEFORE the rerank: a pick the fast-lane dedup would silently swallow at
|
|
79
|
+
* consumption is a wasted aux call and a dead pending (2026-09 audit: 2 of
|
|
80
|
+
* the first 3 slow-lane outcomes died exactly this way).
|
|
81
|
+
*/
|
|
82
|
+
exclude?: ReadonlySet<string>
|
|
73
83
|
}
|
|
74
84
|
|
|
75
85
|
/** Race `p` against a timeout; `onTimeout` fires so callers can abort upstream work. */
|
|
@@ -233,13 +243,16 @@ export class SlowLane {
|
|
|
233
243
|
// candidates = threshold passers + the near-miss band beneath them.
|
|
234
244
|
const band = searchTopics(query, roster, {
|
|
235
245
|
threshold: this.service.cfg.matchThreshold,
|
|
236
|
-
topK: CANDIDATE_LIMIT,
|
|
246
|
+
topK: CANDIDATE_LIMIT + CANDIDATE_OVERFETCH,
|
|
237
247
|
tagBoost: this.service.cfg.tagBoost,
|
|
238
248
|
graphDepth: 0,
|
|
239
249
|
recencyWindowDays: this.service.cfg.recencyWindowDays,
|
|
240
250
|
structuralGate: false,
|
|
241
251
|
})
|
|
242
|
-
const
|
|
252
|
+
const exclude = input.exclude
|
|
253
|
+
const candidates = [...band.hits, ...band.nearMisses]
|
|
254
|
+
.filter((c) => !exclude?.has(c.slug))
|
|
255
|
+
.slice(0, CANDIDATE_LIMIT)
|
|
243
256
|
if (candidates.length === 0) return
|
|
244
257
|
const bySlug = new Map(roster.map((r) => [r.slug, r]))
|
|
245
258
|
const payload = candidates.flatMap((c) => {
|
package/src/service.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface SlowDelivery {
|
|
|
48
48
|
|
|
49
49
|
export class TopicsService {
|
|
50
50
|
private cache = new Map<string, CacheEntry>()
|
|
51
|
+
/** mtime-keyed parse of the observations log, grouped by session. */
|
|
52
|
+
private echoCache: { mtimeMs: number; size: number; bySession: Map<string, Set<string>> } | undefined
|
|
51
53
|
readonly store: BundleStore
|
|
52
54
|
private readonly getConfig: () => TopicsConfigValue
|
|
53
55
|
readonly sync?: Sync
|
|
@@ -116,6 +118,55 @@ export class TopicsService {
|
|
|
116
118
|
/** Drop the roster cache (tests, bulk external edits). */
|
|
117
119
|
invalidate(): void {
|
|
118
120
|
this.cache.clear()
|
|
121
|
+
this.echoCache = undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Slugs distilled from THIS session's own turns — the echo set (2026-09
|
|
126
|
+
* audit finding #3: a topic distilled from in-session knowledge injected
|
|
127
|
+
* back into the same session is a lagging echo of what the model already
|
|
128
|
+
* said, occasionally stale enough to contradict the newer in-session
|
|
129
|
+
* conclusion). Provenance is durable in the observations log: each line
|
|
130
|
+
* carries `sessionId` and, once distilled, `distilledInto`. Sync read on
|
|
131
|
+
* the injection hot path, mtime-cached like the roster.
|
|
132
|
+
*/
|
|
133
|
+
echoSlugsSync(sessionId: string): Set<string> {
|
|
134
|
+
const file = join(this.store.metaDir(), 'observations.jsonl')
|
|
135
|
+
let st
|
|
136
|
+
try {
|
|
137
|
+
st = statSync(file)
|
|
138
|
+
} catch {
|
|
139
|
+
return new Set()
|
|
140
|
+
}
|
|
141
|
+
let bySession = this.echoCache
|
|
142
|
+
if (bySession === undefined || bySession.mtimeMs !== st.mtimeMs || bySession.size !== st.size) {
|
|
143
|
+
bySession = { mtimeMs: st.mtimeMs, size: st.size, bySession: new Map() }
|
|
144
|
+
try {
|
|
145
|
+
const lines = readFileSync(file, 'utf8').split('\n')
|
|
146
|
+
for (const line of lines) {
|
|
147
|
+
if (line.trim() === '') continue
|
|
148
|
+
let obs: { sessionId?: unknown; distilled?: unknown; distilledInto?: unknown }
|
|
149
|
+
try {
|
|
150
|
+
obs = JSON.parse(line)
|
|
151
|
+
} catch {
|
|
152
|
+
continue // torn tail line
|
|
153
|
+
}
|
|
154
|
+
if (obs.distilled !== true || typeof obs.sessionId !== 'string' || !Array.isArray(obs.distilledInto)) continue
|
|
155
|
+
let set = bySession.bySession.get(obs.sessionId)
|
|
156
|
+
if (set === undefined) {
|
|
157
|
+
set = new Set()
|
|
158
|
+
bySession.bySession.set(obs.sessionId, set)
|
|
159
|
+
}
|
|
160
|
+
for (const slug of obs.distilledInto) {
|
|
161
|
+
if (typeof slug === 'string' && slug !== '') set.add(okf.slugify(slug))
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
return new Set() // unreadable log: fail open (no suppression)
|
|
166
|
+
}
|
|
167
|
+
this.echoCache = bySession
|
|
168
|
+
}
|
|
169
|
+
return bySession.bySession.get(sessionId) ?? new Set()
|
|
119
170
|
}
|
|
120
171
|
|
|
121
172
|
/**
|
|
@@ -183,17 +234,21 @@ export class TopicsService {
|
|
|
183
234
|
*
|
|
184
235
|
* `dedup.exclude` (session-level injection dedup) filters hits AND slow
|
|
185
236
|
* picks before assembly; excluded slugs are reported back as `deduped`
|
|
186
|
-
* (and logged as `record.deduped`), never packed. `
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
237
|
+
* (and logged as `record.deduped`), never packed. `dedup.echo` (蒸馏回声,
|
|
238
|
+
* v4 audit) filters the same way but reports separately as `echoed` /
|
|
239
|
+
* `record.echoed`: slugs distilled from THIS session's own turns.
|
|
240
|
+
* `included` mirrors the fast pointers that entered the context and
|
|
241
|
+
* `slowIncluded` the slow ones — only these may be marked as injected by
|
|
242
|
+
* the caller; budget-dropped slugs stay injectable later.
|
|
190
243
|
*
|
|
191
244
|
* `slow` (v4 §4.2) carries the consumed pending when one exists: picks are
|
|
192
245
|
* packed after the fast pointers under the same budget, take log-only
|
|
193
246
|
* shadow re-gate verdicts against the CURRENT query (B3: record, never
|
|
194
|
-
* block), and fill the record's lane field family.
|
|
247
|
+
* block), and fill the record's lane field family. A pick the fast lane
|
|
248
|
+
* already delivers THIS round is counted as slow-delivered (it entered the
|
|
249
|
+
* context) but not packed twice.
|
|
195
250
|
*/
|
|
196
|
-
retrieveSync(query: string, sessionId?: string, dedup?: { exclude?: ReadonlySet<string> }, slow?: SlowDelivery, slowExpired?: 'ttl' | 'turn-lag'): { text: string; outcome: SearchOutcome; deduped: string[]; included: string[]; slowIncluded: string[] } {
|
|
251
|
+
retrieveSync(query: string, sessionId?: string, dedup?: { exclude?: ReadonlySet<string>; echo?: ReadonlySet<string> }, slow?: SlowDelivery, slowExpired?: 'ttl' | 'turn-lag'): { text: string; outcome: SearchOutcome; deduped: string[]; echoed: string[]; included: string[]; slowIncluded: string[] } {
|
|
197
252
|
const cfg = this.cfg
|
|
198
253
|
const roster = this.rosterSync()
|
|
199
254
|
const empty: SearchOutcome = { hits: [], nearMisses: [], rosterSize: roster.length }
|
|
@@ -224,7 +279,7 @@ export class TopicsService {
|
|
|
224
279
|
}
|
|
225
280
|
void this.store.appendInjectionRecord(trace).catch(() => undefined)
|
|
226
281
|
}
|
|
227
|
-
return { text: '', outcome: empty, deduped: [], included: [], slowIncluded: [] }
|
|
282
|
+
return { text: '', outcome: empty, deduped: [], echoed: [], included: [], slowIncluded: [] }
|
|
228
283
|
}
|
|
229
284
|
const conflicts = this.store.getConflictsSync()
|
|
230
285
|
const outcome = searchTopics(query, roster, {
|
|
@@ -236,12 +291,18 @@ export class TopicsService {
|
|
|
236
291
|
conflicts,
|
|
237
292
|
})
|
|
238
293
|
const exclude = dedup?.exclude
|
|
294
|
+
const echo = dedup?.echo
|
|
239
295
|
const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug)
|
|
296
|
+
const echoed: string[] = []
|
|
240
297
|
const bySlug = new Map(roster.map((r) => [r.slug, r]))
|
|
241
298
|
const entries: DigestInput[] = []
|
|
242
299
|
const unreadable: string[] = []
|
|
243
300
|
for (const hit of outcome.hits) {
|
|
244
301
|
if (exclude?.has(hit.slug)) continue
|
|
302
|
+
if (echo?.has(hit.slug)) {
|
|
303
|
+
echoed.push(hit.slug)
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
245
306
|
const doc = this.readDocSync(hit.slug)
|
|
246
307
|
if (doc === undefined) {
|
|
247
308
|
// The hit has no readable doc — without this accounting it used to
|
|
@@ -264,8 +325,19 @@ export class TopicsService {
|
|
|
264
325
|
if (!deduped.includes(item.slug)) deduped.push(item.slug)
|
|
265
326
|
continue
|
|
266
327
|
}
|
|
328
|
+
if (echo?.has(item.slug)) {
|
|
329
|
+
if (!echoed.includes(item.slug)) echoed.push(item.slug)
|
|
330
|
+
continue
|
|
331
|
+
}
|
|
267
332
|
const meta = bySlug.get(item.slug)
|
|
268
333
|
if (meta === undefined) continue // vanished between production and consumption
|
|
334
|
+
if (entries.some((e) => e.slug === item.slug)) {
|
|
335
|
+
// The fast lane delivers the same slug THIS round — the content
|
|
336
|
+
// enters the context exactly once (never packed twice), and the
|
|
337
|
+
// pick still counts as delivered for the lane bookkeeping.
|
|
338
|
+
slowDelivered.push({ slug: item.slug, why: item.why })
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
269
341
|
const verdict = scoreTopic(queryTokens, meta, {
|
|
270
342
|
threshold: cfg.matchThreshold,
|
|
271
343
|
topK: cfg.topK,
|
|
@@ -313,6 +385,7 @@ export class TopicsService {
|
|
|
313
385
|
}
|
|
314
386
|
if (sessionId !== undefined) record.sessionId = sessionId
|
|
315
387
|
if (deduped.length > 0) record.deduped = deduped
|
|
388
|
+
if (echoed.length > 0) record.echoed = echoed
|
|
316
389
|
if (unreadable.length > 0) {
|
|
317
390
|
record.dropped = [...(record.dropped ?? []), ...unreadable.map((slug) => ({ slug, reason: 'doc-unreadable' }))]
|
|
318
391
|
}
|
|
@@ -347,7 +420,7 @@ export class TopicsService {
|
|
|
347
420
|
if (slowExpired !== undefined && injection.text === '') record.lane = 'slow'
|
|
348
421
|
if (injection.dropped.length > 0) record.dropped = [...(record.dropped ?? []), ...injection.dropped]
|
|
349
422
|
void this.store.appendInjectionRecord(record).catch(() => undefined)
|
|
350
|
-
return { text: injection.text, outcome, deduped, included: injection.included, slowIncluded }
|
|
423
|
+
return { text: injection.text, outcome, deduped, echoed, included: injection.included, slowIncluded }
|
|
351
424
|
}
|
|
352
425
|
|
|
353
426
|
/**
|
|
@@ -356,7 +429,7 @@ export class TopicsService {
|
|
|
356
429
|
* the store stamps it on every write, so it IS the last-updated instant).
|
|
357
430
|
* Logs an open record for the pointer-open-rate stat.
|
|
358
431
|
*/
|
|
359
|
-
async openTopic(
|
|
432
|
+
async openTopic(rawSlug: string, sessionId?: string): Promise<{
|
|
360
433
|
found: boolean
|
|
361
434
|
slug: string
|
|
362
435
|
title?: string
|
|
@@ -367,24 +440,47 @@ export class TopicsService {
|
|
|
367
440
|
openQuestions?: string[]
|
|
368
441
|
recommendations?: string
|
|
369
442
|
}> {
|
|
370
|
-
|
|
443
|
+
// Pointers render slugs as `(topics:<slug>)` and models copy that whole
|
|
444
|
+
// token back as the argument — unwrap every `topics:`/`topics/` wrap the
|
|
445
|
+
// same way the depends edges do (audit ⑨: the wrapped form read as a
|
|
446
|
+
// missing topic). Then CANONICALIZE with the save path's own slugify:
|
|
447
|
+
// unwrap alone is case-preserving, and on a case-sensitive filesystem
|
|
448
|
+
// (Linux bundles) an uppercase-carrying argument would miss the file the
|
|
449
|
+
// save path wrote under its folded slug (CI-caught, v0.11.0 round 1).
|
|
450
|
+
const slug = okf.slugify(okf.unwrapTopicRef(rawSlug))
|
|
451
|
+
const doc = slug === '' ? undefined : await this.store.readTopic(slug).catch(() => undefined)
|
|
371
452
|
if (doc === undefined) return { found: false, slug }
|
|
372
453
|
// Awaited (tool path, not hot): a settled write makes the pointer-open
|
|
373
454
|
// stat reliable; a failed log write must not fail the tool.
|
|
374
455
|
await this.store
|
|
375
456
|
.appendOpenRecord({ slug, at: new Date().toISOString(), ...(sessionId !== undefined ? { sessionId } : {}) })
|
|
376
457
|
.catch(() => undefined)
|
|
377
|
-
|
|
458
|
+
// Optional fields are OMITTED, never present-as-undefined: the host's
|
|
459
|
+
// tool-output validation rejects any object carrying an undefined-valued
|
|
460
|
+
// key as non-lossless JSON (INVALID_TOOL_OUTPUT; audit ②⑦ — topics
|
|
461
|
+
// without a description made every open of them fail).
|
|
462
|
+
const found: {
|
|
463
|
+
found: boolean
|
|
464
|
+
slug: string
|
|
465
|
+
title: string
|
|
466
|
+
status: okf.TopicStatus
|
|
467
|
+
updatedAt: string
|
|
468
|
+
description?: string
|
|
469
|
+
conclusion: string
|
|
470
|
+
openQuestions: string[]
|
|
471
|
+
recommendations: string
|
|
472
|
+
} = {
|
|
378
473
|
found: true,
|
|
379
474
|
slug,
|
|
380
475
|
title: doc.fm.title,
|
|
381
476
|
status: doc.fm.status,
|
|
382
477
|
updatedAt: doc.fm.generated.at,
|
|
383
|
-
description: doc.fm.description,
|
|
384
478
|
conclusion: okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '',
|
|
385
479
|
openQuestions: doc.fm.open_questions,
|
|
386
480
|
recommendations: okf.sectionOf(doc.body, okf.RECOMMENDATIONS_HEADING) ?? '',
|
|
387
481
|
}
|
|
482
|
+
if (doc.fm.description !== undefined) found.description = doc.fm.description
|
|
483
|
+
return found
|
|
388
484
|
}
|
|
389
485
|
|
|
390
486
|
/** Sync roster read (same mtime cache as roster()). */
|
package/src/sync.ts
CHANGED
|
@@ -51,7 +51,12 @@ export class Sync {
|
|
|
51
51
|
return this.cfg().repo !== ''
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Session start (or `/topics sync pull`): rebase onto remote, mark
|
|
56
|
+
* conflicts, then replay any push the previous session's local-only exit
|
|
57
|
+
* deferred. The replay is best-effort — a failure just marks lastError and
|
|
58
|
+
* the debounced flush (or the next boot's pull) retries.
|
|
59
|
+
*/
|
|
55
60
|
async pull(): Promise<{ ok: boolean; conflicted: string[]; message: string }> {
|
|
56
61
|
if (!this.active) return { ok: true, conflicted: [], message: 'local-only 模式,无同步' }
|
|
57
62
|
const token = await this.safeToken()
|
|
@@ -66,6 +71,21 @@ export class Sync {
|
|
|
66
71
|
// A successful rebase clears previous conflict marks.
|
|
67
72
|
const prior = await this.store.getConflicts()
|
|
68
73
|
if (prior.size > 0) await this.store.setConflicts([])
|
|
74
|
+
// Deferred-push replay: the previous exit committed locally only, so a
|
|
75
|
+
// successful rebase here is the moment the backlog lands on the remote.
|
|
76
|
+
try {
|
|
77
|
+
if ((await gitmod.unpushedCount(this.store.root)) > 0) {
|
|
78
|
+
const pushed = await gitmod.push(this.store.root, token)
|
|
79
|
+
if (pushed.ok) {
|
|
80
|
+
this.lastPushAt = new Date().toISOString()
|
|
81
|
+
this.lastError = ''
|
|
82
|
+
} else {
|
|
83
|
+
this.lastError = pushed.output.slice(-500)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// pull must stay best-effort for the session-start caller
|
|
88
|
+
}
|
|
69
89
|
return { ok: true, conflicted: [], message: '已与远端同步' }
|
|
70
90
|
}
|
|
71
91
|
const conflictedSlugs = outcome.conflicted.map((p) => p.replace(/^topics\//, '').replace(/\.md$/, ''))
|
|
@@ -73,7 +93,14 @@ export class Sync {
|
|
|
73
93
|
await this.store.setConflicts(outcome.conflicted)
|
|
74
94
|
}
|
|
75
95
|
this.lastError = outcome.output.slice(-500)
|
|
76
|
-
|
|
96
|
+
// A rebase can fail without producing conflicted paths (identity missing,
|
|
97
|
+
// fetch failure, corrupt state) — surface the git output tail instead of
|
|
98
|
+
// the misleading 「未知路径」 so a headless failure is diagnosable.
|
|
99
|
+
if (conflictedSlugs.length === 0) {
|
|
100
|
+
const tail = outcome.output.trim().split('\n').slice(-3).join(';').slice(-200)
|
|
101
|
+
return { ok: false, conflicted: [], message: `rebase 失败(无冲突文件):${tail !== '' ? tail : 'git 无输出'}` }
|
|
102
|
+
}
|
|
103
|
+
return { ok: false, conflicted: conflictedSlugs, message: `rebase 冲突:${conflictedSlugs.join('、')}(已标记降权,待手工解决)` }
|
|
77
104
|
}
|
|
78
105
|
|
|
79
106
|
/** Called after every write-through commit — schedules the debounced push. */
|
package/src/tools.ts
CHANGED
|
@@ -261,7 +261,19 @@ export function buildTopicTools(service: TopicsService) {
|
|
|
261
261
|
},
|
|
262
262
|
async execute(args) {
|
|
263
263
|
const { entries } = await service.history(args.slug, args.limit ?? 20)
|
|
264
|
-
|
|
264
|
+
// conclusion is omitted, never present-as-undefined — the host rejects
|
|
265
|
+
// undefined-valued keys as non-lossless JSON (INVALID_TOOL_OUTPUT).
|
|
266
|
+
return {
|
|
267
|
+
entries: entries.flatMap((e) => {
|
|
268
|
+
const entry: { hash: string; date: string; message: string; conclusion?: string } = {
|
|
269
|
+
hash: e.hash,
|
|
270
|
+
date: e.date,
|
|
271
|
+
message: e.message,
|
|
272
|
+
}
|
|
273
|
+
if (typeof e.conclusion === 'string') entry.conclusion = e.conclusion
|
|
274
|
+
return [entry]
|
|
275
|
+
}),
|
|
276
|
+
}
|
|
265
277
|
},
|
|
266
278
|
})
|
|
267
279
|
|