@aiwayds/dsh-topics-memory 0.10.0 → 0.12.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/lib/commands.js +83 -36
- package/lib/config.d.ts +12 -1
- package/lib/config.js +7 -0
- package/lib/ilog.d.ts +2 -0
- package/lib/ilog.js +6 -4
- package/lib/index.js +18 -1
- 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/tools.js +14 -1
- package/package.json +1 -1
- package/src/commands.ts +86 -35
- package/src/config.ts +8 -0
- package/src/ilog.ts +8 -4
- package/src/index.ts +15 -1
- package/src/quality.ts +15 -2
- package/src/service.ts +108 -12
- package/src/tools.ts +13 -1
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/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
|
|