@aiwayds/dsh-topics-memory 0.6.0 → 0.7.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 +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 +2 -2
- 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/src/service.ts
CHANGED
|
@@ -10,11 +10,11 @@ import { readFile, stat } from 'node:fs/promises'
|
|
|
10
10
|
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
11
11
|
import { join } from 'node:path'
|
|
12
12
|
import * as okf from './okf.ts'
|
|
13
|
-
import { searchTopics, tokenize, type RetrievableTopic, type SearchOutcome } from './retrieval.ts'
|
|
14
|
-
import { assembleInjection, type AssembleResult, type DigestInput } from './digest.ts'
|
|
13
|
+
import { searchTopics, scoreTopic, passesGate, tokenize, type RetrievableTopic, type SearchOutcome } from './retrieval.ts'
|
|
14
|
+
import { assembleInjection, assemblePointer, POINTER_PER_TOPIC, POINTER_TOTAL, type AssembleResult, type DigestInput, type SlowPointerInput } from './digest.ts'
|
|
15
15
|
import type { BundleStore, Observation, SaveResult } from './store.ts'
|
|
16
16
|
import type { TopicsConfigValue } from './config.ts'
|
|
17
|
-
import { aggregateStats, querySample, type AggregateStats, type InjectionRecord } from './ilog.ts'
|
|
17
|
+
import { aggregateStats, querySample, type AggregateStats, type InjectionRecord, type QueryBuildShape, type ShadowVerdict, type SlowItem } from './ilog.ts'
|
|
18
18
|
import { fileHistory, fileAtRev } from './git.ts'
|
|
19
19
|
import type { Sync } from './sync.ts'
|
|
20
20
|
|
|
@@ -32,6 +32,20 @@ export interface RetrieveOutcome {
|
|
|
32
32
|
recorded: boolean
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Slow-lane payload handed to the hot path at consumption time (v4 §4.2).
|
|
37
|
+
* `items` are the rerank picks (0-2, why lines included); metadata feeds the
|
|
38
|
+
* ilog lane field family. The hot path resolves titles, filters dedup,
|
|
39
|
+
* renders, and takes the log-only shadow verdicts.
|
|
40
|
+
*/
|
|
41
|
+
export interface SlowDelivery {
|
|
42
|
+
items: readonly { slug: string; why: string }[]
|
|
43
|
+
computedAt: string
|
|
44
|
+
queryBuild: QueryBuildShape
|
|
45
|
+
model: string
|
|
46
|
+
ms: number
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
export class TopicsService {
|
|
36
50
|
private cache = new Map<string, CacheEntry>()
|
|
37
51
|
readonly store: BundleStore
|
|
@@ -89,6 +103,7 @@ export class TopicsService {
|
|
|
89
103
|
description: doc.fm.description,
|
|
90
104
|
status: doc.fm.status,
|
|
91
105
|
tags: doc.fm.tags,
|
|
106
|
+
triggers: doc.fm.triggers,
|
|
92
107
|
depends: doc.fm.depends,
|
|
93
108
|
links: okf.bodyLinkSlugs(doc.body),
|
|
94
109
|
generatedAt: doc.fm.generated.at,
|
|
@@ -135,10 +150,13 @@ export class TopicsService {
|
|
|
135
150
|
const doc = await this.readDoc(hit.slug)
|
|
136
151
|
if (doc !== undefined) entries.push({ slug: hit.slug, doc, hit })
|
|
137
152
|
}
|
|
138
|
-
const injection =
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
153
|
+
const injection =
|
|
154
|
+
cfg.injectMode !== 'digest'
|
|
155
|
+
? assemblePointer(entries, { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) })
|
|
156
|
+
: assembleInjection(entries, {
|
|
157
|
+
perTopicBudget: cfg.perTopicBudget,
|
|
158
|
+
totalBudget: cfg.totalBudget,
|
|
159
|
+
})
|
|
142
160
|
const record: InjectionRecord = {
|
|
143
161
|
at: new Date().toISOString(),
|
|
144
162
|
queryTokenCount: tokenize(query).length,
|
|
@@ -163,18 +181,50 @@ export class TopicsService {
|
|
|
163
181
|
* would always lose the race and inject nothing. Reads files with sync fs
|
|
164
182
|
* behind the mtime cache; the log record is written fire-and-forget.
|
|
165
183
|
*
|
|
166
|
-
* `dedup.exclude` (session-level injection dedup) filters hits
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* as injected by the caller; budget-dropped
|
|
184
|
+
* `dedup.exclude` (session-level injection dedup) filters hits AND slow
|
|
185
|
+
* picks before assembly; excluded slugs are reported back as `deduped`
|
|
186
|
+
* (and logged as `record.deduped`), never packed. `included` mirrors the
|
|
187
|
+
* fast pointers that entered the context and `slowIncluded` the slow ones
|
|
188
|
+
* — only these may be marked as injected by the caller; budget-dropped
|
|
189
|
+
* slugs stay injectable later.
|
|
190
|
+
*
|
|
191
|
+
* `slow` (v4 §4.2) carries the consumed pending when one exists: picks are
|
|
192
|
+
* packed after the fast pointers under the same budget, take log-only
|
|
193
|
+
* shadow re-gate verdicts against the CURRENT query (B3: record, never
|
|
194
|
+
* block), and fill the record's lane field family.
|
|
171
195
|
*/
|
|
172
|
-
retrieveSync(query: string, sessionId?: string, dedup?: { exclude?: ReadonlySet<string> }): { text: string; outcome: SearchOutcome; deduped: string[]; included: string[] } {
|
|
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[] } {
|
|
173
197
|
const cfg = this.cfg
|
|
174
198
|
const roster = this.rosterSync()
|
|
175
199
|
const empty: SearchOutcome = { hits: [], nearMisses: [], rosterSize: roster.length }
|
|
176
200
|
if (!cfg.autoInject || query.trim() === '' || roster.length === 0) {
|
|
177
|
-
|
|
201
|
+
// A consumed/expired pending must always leave an ilog trace (ADR 0014:
|
|
202
|
+
// 过期/消费都写 ilog) — even when the fast path early-returns here
|
|
203
|
+
// because the roster vanished between production and consumption.
|
|
204
|
+
if (cfg.autoInject && query.trim() !== '' && (slow !== undefined || slowExpired !== undefined)) {
|
|
205
|
+
const trace: InjectionRecord = {
|
|
206
|
+
at: new Date().toISOString(),
|
|
207
|
+
queryTokenCount: tokenize(query).length,
|
|
208
|
+
querySample: querySample(query),
|
|
209
|
+
rosterSize: roster.length,
|
|
210
|
+
hits: [],
|
|
211
|
+
nearMisses: [],
|
|
212
|
+
injected: false,
|
|
213
|
+
why: slowExpired !== undefined ? `slow-expired-${slowExpired}` : 'slow-no-roster',
|
|
214
|
+
lane: 'slow',
|
|
215
|
+
}
|
|
216
|
+
if (sessionId !== undefined) trace.sessionId = sessionId
|
|
217
|
+
if (slowExpired !== undefined) trace.slowExpired = slowExpired
|
|
218
|
+
if (slow !== undefined) {
|
|
219
|
+
trace.computedAt = slow.computedAt
|
|
220
|
+
trace.queryBuild = slow.queryBuild
|
|
221
|
+
trace.slowModel = slow.model
|
|
222
|
+
trace.slowMs = slow.ms
|
|
223
|
+
trace.slow = []
|
|
224
|
+
}
|
|
225
|
+
void this.store.appendInjectionRecord(trace).catch(() => undefined)
|
|
226
|
+
}
|
|
227
|
+
return { text: '', outcome: empty, deduped: [], included: [], slowIncluded: [] }
|
|
178
228
|
}
|
|
179
229
|
const conflicts = this.store.getConflictsSync()
|
|
180
230
|
const outcome = searchTopics(query, roster, {
|
|
@@ -187,35 +237,154 @@ export class TopicsService {
|
|
|
187
237
|
})
|
|
188
238
|
const exclude = dedup?.exclude
|
|
189
239
|
const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug)
|
|
240
|
+
const bySlug = new Map(roster.map((r) => [r.slug, r]))
|
|
190
241
|
const entries: DigestInput[] = []
|
|
242
|
+
const unreadable: string[] = []
|
|
191
243
|
for (const hit of outcome.hits) {
|
|
192
244
|
if (exclude?.has(hit.slug)) continue
|
|
193
245
|
const doc = this.readDocSync(hit.slug)
|
|
194
|
-
if (doc
|
|
246
|
+
if (doc === undefined) {
|
|
247
|
+
// The hit has no readable doc — without this accounting it used to
|
|
248
|
+
// vanish from `included` AND `dropped` (v4 design §2 疑点).
|
|
249
|
+
unreadable.push(hit.slug)
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
entries.push({ slug: hit.slug, doc, hit })
|
|
253
|
+
}
|
|
254
|
+
// ---- Slow-lane merge (log-only shadow gate; consumption bookkeeping) ----
|
|
255
|
+
const slowPointers: SlowPointerInput[] = []
|
|
256
|
+
const slowDelivered: SlowItem[] = []
|
|
257
|
+
const shadow: ShadowVerdict[] = []
|
|
258
|
+
let slowIncluded: string[] = []
|
|
259
|
+
const consumedAt = new Date().toISOString()
|
|
260
|
+
if (slow !== undefined) {
|
|
261
|
+
const queryTokens = new Set(tokenize(query))
|
|
262
|
+
for (const item of slow.items) {
|
|
263
|
+
if (exclude?.has(item.slug)) {
|
|
264
|
+
if (!deduped.includes(item.slug)) deduped.push(item.slug)
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
const meta = bySlug.get(item.slug)
|
|
268
|
+
if (meta === undefined) continue // vanished between production and consumption
|
|
269
|
+
const verdict = scoreTopic(queryTokens, meta, {
|
|
270
|
+
threshold: cfg.matchThreshold,
|
|
271
|
+
topK: cfg.topK,
|
|
272
|
+
tagBoost: cfg.tagBoost,
|
|
273
|
+
graphDepth: cfg.graphDepth,
|
|
274
|
+
recencyWindowDays: cfg.recencyWindowDays,
|
|
275
|
+
conflicts,
|
|
276
|
+
structuralGate: true,
|
|
277
|
+
})
|
|
278
|
+
shadow.push({
|
|
279
|
+
slug: item.slug,
|
|
280
|
+
pass: passesGate(verdict),
|
|
281
|
+
why: verdict.strong ? `strong:${verdict.reasons[0] ?? 'field'}` : `body-hits:${verdict.bodyHits}`,
|
|
282
|
+
})
|
|
283
|
+
slowPointers.push({
|
|
284
|
+
slug: item.slug,
|
|
285
|
+
title: meta.title,
|
|
286
|
+
status: meta.status,
|
|
287
|
+
description: meta.description,
|
|
288
|
+
why: item.why,
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const pointerMode = cfg.injectMode !== 'digest'
|
|
293
|
+
// Pointer budget: per-entry fixed at the design's 80, total clamped to the
|
|
294
|
+
// fast-lane upper bound of 600 (I4) — totalBudget stays tunable downward.
|
|
295
|
+
const injection = pointerMode
|
|
296
|
+
? assemblePointer(entries, { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) }, slowPointers)
|
|
297
|
+
: assembleInjection(entries, { perTopicBudget: cfg.perTopicBudget, totalBudget: cfg.totalBudget }, slowPointers)
|
|
298
|
+
slowIncluded = injection.slowIncluded ?? []
|
|
299
|
+
for (const pointer of slowPointers) {
|
|
300
|
+
if (slowIncluded.includes(pointer.slug)) slowDelivered.push({ slug: pointer.slug, why: pointer.why })
|
|
195
301
|
}
|
|
196
|
-
const injection = assembleInjection(entries, {
|
|
197
|
-
perTopicBudget: cfg.perTopicBudget,
|
|
198
|
-
totalBudget: cfg.totalBudget,
|
|
199
|
-
})
|
|
200
302
|
const record: InjectionRecord = {
|
|
201
303
|
at: new Date().toISOString(),
|
|
202
304
|
queryTokenCount: tokenize(query).length,
|
|
203
305
|
querySample: querySample(query),
|
|
204
306
|
rosterSize: outcome.rosterSize,
|
|
205
|
-
hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph })),
|
|
206
|
-
|
|
307
|
+
hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph, strong: h.strong, bodyHits: h.bodyHits })),
|
|
308
|
+
// Reasons ride along: the gate-blocked marker is the replay evidence
|
|
309
|
+
// the P3 threshold decision needs (ADR 0014).
|
|
310
|
+
nearMisses: outcome.nearMisses.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons })),
|
|
207
311
|
injected: injection.text !== '',
|
|
208
312
|
usedTokens: injection.usedTokens,
|
|
209
313
|
}
|
|
210
314
|
if (sessionId !== undefined) record.sessionId = sessionId
|
|
211
315
|
if (deduped.length > 0) record.deduped = deduped
|
|
212
|
-
if (
|
|
316
|
+
if (unreadable.length > 0) {
|
|
317
|
+
record.dropped = [...(record.dropped ?? []), ...unreadable.map((slug) => ({ slug, reason: 'doc-unreadable' }))]
|
|
318
|
+
}
|
|
319
|
+
if (slow !== undefined) {
|
|
320
|
+
// Any round whose pending was consumed is a slow-participating round —
|
|
321
|
+
// lane never stays undefined when the lane family is present (a
|
|
322
|
+
// delivered pick with fast pointers is 'mixed'; a consumed-but-empty
|
|
323
|
+
// round beside fast pointers stays 'fast'; everything else 'slow').
|
|
324
|
+
record.lane = slowDelivered.length > 0 ? (injection.included.length > 0 ? 'mixed' : 'slow') : injection.included.length > 0 ? 'fast' : 'slow'
|
|
325
|
+
record.computedAt = slow.computedAt
|
|
326
|
+
record.consumedAt = consumedAt
|
|
327
|
+
if (shadow.length > 0) record.shadowVerdict = shadow
|
|
328
|
+
record.queryBuild = slow.queryBuild
|
|
329
|
+
record.slowModel = slow.model
|
|
330
|
+
record.slowMs = slow.ms
|
|
331
|
+
record.slow = slowDelivered
|
|
332
|
+
}
|
|
333
|
+
if (slowExpired !== undefined) {
|
|
334
|
+
// Expiry is recorded INDEPENDENT of whether the fast lane injected —
|
|
335
|
+
// the 赶上率 denominator must not lose rounds to a coincidental hit.
|
|
336
|
+
record.slowExpired = slowExpired
|
|
337
|
+
if (record.lane === undefined) record.lane = injection.text !== '' ? 'fast' : 'slow'
|
|
338
|
+
}
|
|
339
|
+
if (injection.text === '' && (outcome.hits.length > 0 || slowPointers.length > 0)) {
|
|
213
340
|
// All hits deduped is a different story than a budget squeeze — say so.
|
|
214
|
-
record.why = deduped.length === outcome.hits.length ? 'dedup' : 'below-budget-or-dropped'
|
|
341
|
+
record.why = deduped.length === outcome.hits.length && outcome.hits.length > 0 ? 'dedup' : 'below-budget-or-dropped'
|
|
342
|
+
} else if (injection.text === '' && slowExpired !== undefined) {
|
|
343
|
+
// The pending died at its hard bounds (TTL / turn-lag) without ever
|
|
344
|
+
// being delivered — the denominator the 赶上率 needs (v4 B4).
|
|
345
|
+
record.why = `slow-expired-${slowExpired}`
|
|
215
346
|
}
|
|
216
|
-
if (injection.
|
|
347
|
+
if (slowExpired !== undefined && injection.text === '') record.lane = 'slow'
|
|
348
|
+
if (injection.dropped.length > 0) record.dropped = [...(record.dropped ?? []), ...injection.dropped]
|
|
217
349
|
void this.store.appendInjectionRecord(record).catch(() => undefined)
|
|
218
|
-
return { text: injection.text, outcome, deduped, included: injection.included }
|
|
350
|
+
return { text: injection.text, outcome, deduped, included: injection.included, slowIncluded }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* topic_open tool path (v4 §4.1): full conclusion / open questions /
|
|
355
|
+
* recommendations plus the staleness notice timestamp (fm.generated.at —
|
|
356
|
+
* the store stamps it on every write, so it IS the last-updated instant).
|
|
357
|
+
* Logs an open record for the pointer-open-rate stat.
|
|
358
|
+
*/
|
|
359
|
+
async openTopic(slug: string, sessionId?: string): Promise<{
|
|
360
|
+
found: boolean
|
|
361
|
+
slug: string
|
|
362
|
+
title?: string
|
|
363
|
+
status?: okf.TopicStatus
|
|
364
|
+
updatedAt?: string
|
|
365
|
+
description?: string
|
|
366
|
+
conclusion?: string
|
|
367
|
+
openQuestions?: string[]
|
|
368
|
+
recommendations?: string
|
|
369
|
+
}> {
|
|
370
|
+
const doc = await this.store.readTopic(slug).catch(() => undefined)
|
|
371
|
+
if (doc === undefined) return { found: false, slug }
|
|
372
|
+
// Awaited (tool path, not hot): a settled write makes the pointer-open
|
|
373
|
+
// stat reliable; a failed log write must not fail the tool.
|
|
374
|
+
await this.store
|
|
375
|
+
.appendOpenRecord({ slug, at: new Date().toISOString(), ...(sessionId !== undefined ? { sessionId } : {}) })
|
|
376
|
+
.catch(() => undefined)
|
|
377
|
+
return {
|
|
378
|
+
found: true,
|
|
379
|
+
slug,
|
|
380
|
+
title: doc.fm.title,
|
|
381
|
+
status: doc.fm.status,
|
|
382
|
+
updatedAt: doc.fm.generated.at,
|
|
383
|
+
description: doc.fm.description,
|
|
384
|
+
conclusion: okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '',
|
|
385
|
+
openQuestions: doc.fm.open_questions,
|
|
386
|
+
recommendations: okf.sectionOf(doc.body, okf.RECOMMENDATIONS_HEADING) ?? '',
|
|
387
|
+
}
|
|
219
388
|
}
|
|
220
389
|
|
|
221
390
|
/** Sync roster read (same mtime cache as roster()). */
|
|
@@ -256,6 +425,7 @@ export class TopicsService {
|
|
|
256
425
|
description: doc.fm.description,
|
|
257
426
|
status: doc.fm.status,
|
|
258
427
|
tags: doc.fm.tags,
|
|
428
|
+
triggers: doc.fm.triggers,
|
|
259
429
|
depends: doc.fm.depends,
|
|
260
430
|
links: okf.bodyLinkSlugs(doc.body),
|
|
261
431
|
generatedAt: doc.fm.generated.at,
|
|
@@ -286,6 +456,7 @@ export class TopicsService {
|
|
|
286
456
|
title: string
|
|
287
457
|
description?: string
|
|
288
458
|
tags?: string[]
|
|
459
|
+
triggers?: string[]
|
|
289
460
|
depends?: string[]
|
|
290
461
|
openQuestions?: string[]
|
|
291
462
|
impact?: string[]
|
|
@@ -320,6 +491,10 @@ export class TopicsService {
|
|
|
320
491
|
generated: existing?.fm.generated ?? { by: 'pending', at: now },
|
|
321
492
|
}
|
|
322
493
|
if (input.description !== undefined && input.description !== '') fm.description = input.description
|
|
494
|
+
// Triggers pass through on create; on update the caller's value wins, else
|
|
495
|
+
// the stored list survives (same preserve-on-update semantics as tags).
|
|
496
|
+
const triggers = sanitizeTriggers(input.triggers ?? existing?.fm.triggers ?? [])
|
|
497
|
+
if (triggers.length > 0) fm.triggers = triggers
|
|
323
498
|
if (existing !== undefined && existing.fm.verified !== undefined) fm.verified = existing.fm.verified
|
|
324
499
|
const result = await this.store.saveTopic(
|
|
325
500
|
{ slug, doc: { fm, body } },
|
|
@@ -342,6 +517,9 @@ export class TopicsService {
|
|
|
342
517
|
tagBoost: cfg.tagBoost,
|
|
343
518
|
graphDepth: cfg.graphDepth,
|
|
344
519
|
recencyWindowDays: cfg.recencyWindowDays,
|
|
520
|
+
// Explicit tool: the model asked for recall, so the injection-only
|
|
521
|
+
// structural gate must not hide results.
|
|
522
|
+
structuralGate: false,
|
|
345
523
|
conflicts: await this.store.getConflicts(),
|
|
346
524
|
})
|
|
347
525
|
}
|
|
@@ -398,3 +576,14 @@ function dedupeLower(tags: string[]): string[] {
|
|
|
398
576
|
}
|
|
399
577
|
return out
|
|
400
578
|
}
|
|
579
|
+
|
|
580
|
+
function sanitizeTriggers(triggers: readonly string[]): string[] {
|
|
581
|
+
const out: string[] = []
|
|
582
|
+
for (const t of triggers) {
|
|
583
|
+
if (typeof t !== 'string') continue
|
|
584
|
+
const key = t.trim()
|
|
585
|
+
if (key === '') continue
|
|
586
|
+
out.push(key)
|
|
587
|
+
}
|
|
588
|
+
return out
|
|
589
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -493,6 +493,59 @@ export class BundleStore {
|
|
|
493
493
|
return out.slice(-limit)
|
|
494
494
|
}
|
|
495
495
|
|
|
496
|
+
// ------------------------------------------------------------------
|
|
497
|
+
// Pointer-open log (v4 §4.3) — one line per topic_open call; feeds the
|
|
498
|
+
// 「指针打开率」 stat. Features only (slug/time), never conversation text.
|
|
499
|
+
// ------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
private opensPath(): string {
|
|
502
|
+
return join(this.metaDir(), 'opens.jsonl')
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async appendOpenRecord(record: { slug: string; at: string; sessionId?: string }): Promise<void> {
|
|
506
|
+
await mkdir(this.metaDir(), { recursive: true })
|
|
507
|
+
await appendFile(this.opensPath(), `${JSON.stringify(record)}\n`, 'utf8')
|
|
508
|
+
await this.compactOpensIfNeeded()
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private async compactOpensIfNeeded(): Promise<void> {
|
|
512
|
+
const file = this.opensPath()
|
|
513
|
+
let size = 0
|
|
514
|
+
try {
|
|
515
|
+
size = (await readFile(file, 'utf8')).length
|
|
516
|
+
} catch {
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
if (size <= 512 * 1024) return
|
|
520
|
+
const lines = (await readFile(file, 'utf8')).split('\n').filter((l) => l.trim() !== '')
|
|
521
|
+
await atomicWrite(file, lines.slice(-Math.max(1, Math.floor(lines.length / 4))).map((l) => `${l}\n`).join(''))
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Opens in the recent window; tolerant of a torn tail like the other JSONLs. */
|
|
525
|
+
async readOpenRecords(limit = 2000): Promise<{ slug: string; at: string; sessionId?: string }[]> {
|
|
526
|
+
let raw: string
|
|
527
|
+
try {
|
|
528
|
+
raw = await readFile(this.opensPath(), 'utf8')
|
|
529
|
+
} catch {
|
|
530
|
+
return []
|
|
531
|
+
}
|
|
532
|
+
const out: { slug: string; at: string; sessionId?: string }[] = []
|
|
533
|
+
for (const line of raw.split('\n')) {
|
|
534
|
+
if (line.trim() === '') continue
|
|
535
|
+
try {
|
|
536
|
+
const parsed = JSON.parse(line) as { slug?: unknown; at?: unknown; sessionId?: unknown }
|
|
537
|
+
if (typeof parsed.slug === 'string' && typeof parsed.at === 'string') {
|
|
538
|
+
const rec: { slug: string; at: string; sessionId?: string } = { slug: parsed.slug, at: parsed.at }
|
|
539
|
+
if (typeof parsed.sessionId === 'string') rec.sessionId = parsed.sessionId
|
|
540
|
+
out.push(rec)
|
|
541
|
+
}
|
|
542
|
+
} catch {
|
|
543
|
+
// Torn tail line tolerated.
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return out.slice(-limit)
|
|
547
|
+
}
|
|
548
|
+
|
|
496
549
|
// ------------------------------------------------------------------
|
|
497
550
|
// Conflicted topics (ADR 0003) — retrieval demotes these
|
|
498
551
|
// ------------------------------------------------------------------
|
package/src/tools.ts
CHANGED
|
@@ -28,6 +28,7 @@ export function buildTopicTools(service: TopicsService) {
|
|
|
28
28
|
conclusion: { type: 'string', required: true, description: 'The current best conclusion, self-contained markdown prose' },
|
|
29
29
|
description: { type: 'string', description: 'One-line summary for indexes and snippets' },
|
|
30
30
|
tags: { ...STRING_ARRAY, description: 'Cross-cutting tags (project, domain, component); lowercased on save' },
|
|
31
|
+
triggers: { ...STRING_ARRAY, description: 'Short recall phrases that should bring this topic back (specific nouns/terms, no generic words)' },
|
|
31
32
|
depends: { ...STRING_ARRAY, description: 'Slugs of prerequisite topics (topics this one builds on)' },
|
|
32
33
|
open_questions: { ...STRING_ARRAY, description: 'Unresolved questions this topic still carries' },
|
|
33
34
|
impact: { ...STRING_ARRAY, description: 'What this conclusion affects: topics, projects, decisions' },
|
|
@@ -59,6 +60,7 @@ export function buildTopicTools(service: TopicsService) {
|
|
|
59
60
|
conclusion: args.conclusion,
|
|
60
61
|
description: args.description,
|
|
61
62
|
tags: args.tags,
|
|
63
|
+
triggers: args.triggers,
|
|
62
64
|
depends: args.depends,
|
|
63
65
|
openQuestions: args.open_questions,
|
|
64
66
|
impact: args.impact,
|
|
@@ -71,6 +73,54 @@ export function buildTopicTools(service: TopicsService) {
|
|
|
71
73
|
},
|
|
72
74
|
})
|
|
73
75
|
|
|
76
|
+
const topicOpen = defineTool({
|
|
77
|
+
name: 'topic_open',
|
|
78
|
+
description:
|
|
79
|
+
'Open one topic from the long-term memory in full: conclusion, open questions, recommendations, ' +
|
|
80
|
+
'plus a staleness notice. Use when a <topic-memory> pointer (or a search hit) is actually relevant — ' +
|
|
81
|
+
'pointers are one-line hints; this fetches the real content.',
|
|
82
|
+
parameters: {
|
|
83
|
+
slug: { type: 'string', required: true, description: 'Topic slug (from a pointer or topic_search)' },
|
|
84
|
+
},
|
|
85
|
+
output: {
|
|
86
|
+
schema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
additionalProperties: false,
|
|
89
|
+
properties: {
|
|
90
|
+
found: { type: 'boolean', required: true },
|
|
91
|
+
slug: { type: 'string', required: true },
|
|
92
|
+
title: { type: 'string' },
|
|
93
|
+
status: { type: 'string' },
|
|
94
|
+
updatedAt: { type: 'string' },
|
|
95
|
+
description: { type: 'string' },
|
|
96
|
+
conclusion: { type: 'string' },
|
|
97
|
+
openQuestions: { type: 'array', items: { type: 'string' } },
|
|
98
|
+
recommendations: { type: 'string' },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
render: (_args, value) => [
|
|
102
|
+
{
|
|
103
|
+
type: 'text' as const,
|
|
104
|
+
text: !value.found
|
|
105
|
+
? `Topic \`${value.slug}\` 不存在(可能已合并或改名;用 topic_search 找)。`
|
|
106
|
+
: [
|
|
107
|
+
`快照于 ${value.updatedAt},代码事实以源码为准。`,
|
|
108
|
+
`### ${value.title} [${value.status}] (topics:${value.slug})`,
|
|
109
|
+
value.description ?? '',
|
|
110
|
+
value.conclusion !== '' ? `# Conclusion\n\n${value.conclusion}` : '',
|
|
111
|
+
value.openQuestions !== undefined && value.openQuestions.length > 0 ? `待决: ${value.openQuestions.join(';')}` : '',
|
|
112
|
+
value.recommendations !== '' ? `# Recommendations\n\n${value.recommendations}` : '',
|
|
113
|
+
]
|
|
114
|
+
.filter((l) => l !== '')
|
|
115
|
+
.join('\n\n'),
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
async execute(args) {
|
|
120
|
+
return await service.openTopic(args.slug)
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
|
|
74
124
|
const topicSearch = defineTool({
|
|
75
125
|
name: 'topic_search',
|
|
76
126
|
description:
|
|
@@ -215,7 +265,7 @@ export function buildTopicTools(service: TopicsService) {
|
|
|
215
265
|
},
|
|
216
266
|
})
|
|
217
267
|
|
|
218
|
-
return [topicSave, topicSearch, topicObserve, topicHistory]
|
|
268
|
+
return [topicSave, topicOpen, topicSearch, topicObserve, topicHistory]
|
|
219
269
|
}
|
|
220
270
|
|
|
221
271
|
function extractConclusion(body: string): string {
|