@aiwayds/dsh-topics-memory 0.21.0 → 0.23.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 +5 -0
- package/README.zh.md +5 -0
- package/lib/commands.js +27 -11
- package/lib/config.d.ts +54 -1
- package/lib/config.js +38 -1
- package/lib/digest.d.ts +73 -0
- package/lib/digest.js +384 -0
- package/lib/distill.d.ts +39 -0
- package/lib/distill.js +250 -15
- package/lib/embedding.d.ts +113 -0
- package/lib/embedding.js +208 -0
- package/lib/ilog.d.ts +145 -0
- package/lib/ilog.js +236 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +23 -6
- package/lib/pipeline-events.d.ts +36 -0
- package/lib/pipeline-events.js +121 -0
- package/lib/quality.d.ts +47 -7
- package/lib/quality.js +124 -20
- package/lib/retrieval.d.ts +119 -0
- package/lib/retrieval.js +290 -0
- package/lib/service.d.ts +60 -3
- package/lib/service.js +320 -44
- package/lib/store.d.ts +15 -12
- package/lib/store.js +43 -3
- package/lib/tools.js +6 -2
- package/package.json +2 -2
- package/src/commands.ts +35 -16
- package/src/config.ts +47 -1
- package/src/digest.ts +416 -0
- package/src/distill.ts +275 -14
- package/src/embedding.ts +294 -0
- package/src/ilog.ts +306 -0
- package/src/index.ts +23 -5
- package/src/pipeline-events.ts +132 -0
- package/src/quality.ts +145 -21
- package/src/retrieval.ts +357 -0
- package/src/service.ts +326 -52
- package/src/store.ts +46 -10
- package/src/tools.ts +6 -2
package/src/service.ts
CHANGED
|
@@ -7,14 +7,16 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { readFile, stat } from 'node:fs/promises'
|
|
10
|
-
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
10
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
11
11
|
import { join } from 'node:path'
|
|
12
12
|
import * as okf from './okf.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'
|
|
13
|
+
import { searchTopics, scoreTopic, passesGate, tokenize, sanitizeQuery, loadEmbeddingIndex, rerankNearFloor, ensureIndexFresh, saveEmbeddingIndex, type EmbeddingIndex, type RetrievableTopic, type RetrievalConfig, type SearchOutcome } from './retrieval.ts'
|
|
14
|
+
import { assembleInjection, assemblePointer, assemblePackage, POINTER_PER_TOPIC, POINTER_TOTAL, type AssembleResult, type DigestInput, type PackageCandidate, type PackageInput, type PackageResult, type SlowPointerInput } from './digest.ts'
|
|
15
|
+
import { embedTexts, embedQuerySync, embedderWith, resolveEmbedModel } from './embedding.ts'
|
|
16
|
+
import { resolveBundleRoot } from './paths.ts'
|
|
15
17
|
import type { BundleStore, Observation, SaveResult } from './store.ts'
|
|
16
18
|
import type { TopicsConfigValue } from './config.ts'
|
|
17
|
-
import { aggregateStats, querySample, aggregateUsage, USAGE_WINDOW_DAYS, type AggregateStats, type InjectionRecord, type QueryBuildShape, type ShadowVerdict, type SlowItem, type UsageSignal } from './ilog.ts'
|
|
19
|
+
import { aggregateStats, querySample, aggregateUsage, aggregateZeroOpen, ZERO_OPEN_TAIL, USAGE_WINDOW_DAYS, attributeOpen, OPEN_ATTRIBUTION_TAIL, type AggregateStats, type InjectionRecord, type OpenRecord, type QueryBuildShape, type ShadowVerdict, type SlowItem, type SlowOutcome, type UsageSignal } from './ilog.ts'
|
|
18
20
|
import { fileHistory, fileAtRev } from './git.ts'
|
|
19
21
|
import type { Sync } from './sync.ts'
|
|
20
22
|
|
|
@@ -25,6 +27,37 @@ interface CacheEntry {
|
|
|
25
27
|
slug: string
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/** Tolerant JSONL line read: undefined = unreadable file. Unlike an empty
|
|
31
|
+
* array, undefined tells the caller to fail open AND skip caching — a
|
|
32
|
+
* successful stat paired with a failed read must not freeze an empty
|
|
33
|
+
* snapshot into the cache until the next append. */
|
|
34
|
+
function readLogLinesSync(file: string): string[] | undefined {
|
|
35
|
+
try {
|
|
36
|
+
return readFileSync(file, 'utf8').split('\n')
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Parse already-split lines back-to-front, keeping the last `tail` parsed
|
|
43
|
+
* records in order: blanks and torn JSON lines are skipped (and never
|
|
44
|
+
* consume tail budget), so a torn tail line cannot crowd out a good one.
|
|
45
|
+
* Shared by the usage/zero-open cache — one tail parse feeds both. */
|
|
46
|
+
function parseJsonlLinesSync<T>(lines: readonly string[], tail = Number.POSITIVE_INFINITY): T[] {
|
|
47
|
+
const out: T[] = []
|
|
48
|
+
for (let i = lines.length - 1; i >= 0 && out.length < tail; i -= 1) {
|
|
49
|
+
const line = lines[i]
|
|
50
|
+
if (line.trim() === '') continue
|
|
51
|
+
try {
|
|
52
|
+
out.push(JSON.parse(line) as T)
|
|
53
|
+
} catch {
|
|
54
|
+
continue // torn tail line
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
out.reverse()
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
28
61
|
export interface RetrieveOutcome {
|
|
29
62
|
outcome: SearchOutcome
|
|
30
63
|
injection: AssembleResult
|
|
@@ -44,19 +77,30 @@ export interface SlowDelivery {
|
|
|
44
77
|
queryBuild: QueryBuildShape
|
|
45
78
|
model: string
|
|
46
79
|
ms: number
|
|
80
|
+
/** Producing run's disposition (A4 全留痕) — copied into ilog.slowOutcome. */
|
|
81
|
+
outcome?: SlowOutcome
|
|
47
82
|
}
|
|
48
83
|
|
|
49
84
|
export class TopicsService {
|
|
50
85
|
private cache = new Map<string, CacheEntry>()
|
|
51
86
|
/** mtime-keyed parse of the observations log, grouped by session. */
|
|
52
87
|
private echoCache: { mtimeMs: number; size: number; bySession: Map<string, Set<string>> } | undefined
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
88
|
+
/** Single shared cache over the two feedback JSONL logs (S2): the key is
|
|
89
|
+
* the (mtime,size) quartet of injections.jsonl + opens.jsonl, one read
|
|
90
|
+
* and one tail parse feed BOTH aggregates — usageSignalsSync and
|
|
91
|
+
* zeroOpenSlugsSync used to each keep a full-parse cache of their own.
|
|
92
|
+
* Invalidation stays append-driven: the store only ever appends, so any
|
|
93
|
+
* write moves mtime+size. Never populated when a read failed (N1). */
|
|
94
|
+
private logSignalsCache:
|
|
95
|
+
| {
|
|
96
|
+
injMs: number
|
|
97
|
+
injSize: number
|
|
98
|
+
opensMs: number
|
|
99
|
+
opensSize: number
|
|
100
|
+
signals: Map<string, UsageSignal>
|
|
101
|
+
zeroOpenSlugs: ReadonlySet<string>
|
|
102
|
+
}
|
|
103
|
+
| undefined
|
|
60
104
|
readonly store: BundleStore
|
|
61
105
|
private readonly getConfig: () => TopicsConfigValue
|
|
62
106
|
readonly sync?: Sync
|
|
@@ -131,7 +175,7 @@ export class TopicsService {
|
|
|
131
175
|
invalidate(): void {
|
|
132
176
|
this.cache.clear()
|
|
133
177
|
this.echoCache = undefined
|
|
134
|
-
this.
|
|
178
|
+
this.logSignalsCache = undefined
|
|
135
179
|
}
|
|
136
180
|
|
|
137
181
|
/**
|
|
@@ -186,10 +230,36 @@ export class TopicsService {
|
|
|
186
230
|
* Usage signals (ADR 0015): mtime-cached aggregate over the injection log
|
|
187
231
|
* and the opens log, rolling USAGE_WINDOW_DAYS. Synchronous like
|
|
188
232
|
* echoSlugsSync — the retrieval hot path is sync — and cheap when warm:
|
|
189
|
-
* a fresh cache costs two stat() calls
|
|
233
|
+
* a fresh cache costs two stat() calls, one read of each log, and feeds
|
|
234
|
+
* the zero-open set from the same parse. Unreadable logs fail open to an
|
|
190
235
|
* empty map (no boost), never an error.
|
|
191
236
|
*/
|
|
192
237
|
usageSignalsSync(): Map<string, UsageSignal> {
|
|
238
|
+
return this.logSignalsSync().signals
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Zero-open slug set for the decay (v5): the shared aggregateZeroOpen
|
|
243
|
+
* rule over the injection tail and the whole opens log (compacted, so a
|
|
244
|
+
* full read stays small). Shares one cache and one parse with
|
|
245
|
+
* usageSignalsSync; unreadable logs fail open to an empty set (no
|
|
246
|
+
* decay), never an error. Only called when zeroOpenDecay is on.
|
|
247
|
+
*/
|
|
248
|
+
zeroOpenSlugsSync(): ReadonlySet<string> {
|
|
249
|
+
return this.logSignalsSync().zeroOpenSlugs
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The one shared feedback-log read behind both signals: stat both files
|
|
254
|
+
* (mtime+size quartet key), parse the injections TAIL only
|
|
255
|
+
* (ZERO_OPEN_TAIL rounds comfortably cover both 30-day windows) and the
|
|
256
|
+
* opens log whole (the zero-open rule is whole-history), then feed the
|
|
257
|
+
* two pure aggregates from that single parse. A failed read still fails
|
|
258
|
+
* open to empty inputs but NEVER populates the cache (N1) — otherwise a
|
|
259
|
+
* stat-success/read-failure race would freeze an empty snapshot until
|
|
260
|
+
* the next append.
|
|
261
|
+
*/
|
|
262
|
+
private logSignalsSync(): { signals: Map<string, UsageSignal>; zeroOpenSlugs: ReadonlySet<string> } {
|
|
193
263
|
const injFile = join(this.store.metaDir(), 'injections.jsonl')
|
|
194
264
|
const opensFile = join(this.store.metaDir(), 'opens.jsonl')
|
|
195
265
|
const statOf = (file: string): { mtimeMs: number; size: number } => {
|
|
@@ -202,7 +272,7 @@ export class TopicsService {
|
|
|
202
272
|
}
|
|
203
273
|
const injSt = statOf(injFile)
|
|
204
274
|
const opensSt = statOf(opensFile)
|
|
205
|
-
const cached = this.
|
|
275
|
+
const cached = this.logSignalsCache
|
|
206
276
|
if (
|
|
207
277
|
cached !== undefined &&
|
|
208
278
|
cached.injMs === injSt.mtimeMs &&
|
|
@@ -210,38 +280,102 @@ export class TopicsService {
|
|
|
210
280
|
cached.opensMs === opensSt.mtimeMs &&
|
|
211
281
|
cached.opensSize === opensSt.size
|
|
212
282
|
) {
|
|
213
|
-
return cached
|
|
283
|
+
return cached
|
|
214
284
|
}
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
285
|
+
const injLines = readLogLinesSync(injFile)
|
|
286
|
+
const opensLines = readLogLinesSync(opensFile)
|
|
287
|
+
const injections = parseJsonlLinesSync<InjectionRecord>(injLines ?? [], ZERO_OPEN_TAIL)
|
|
288
|
+
const opens = parseJsonlLinesSync<OpenRecord>(opensLines ?? [])
|
|
289
|
+
const now = Date.now()
|
|
290
|
+
const signals = aggregateUsage(injections, opens, USAGE_WINDOW_DAYS, now)
|
|
291
|
+
const zeroOpenSlugs: ReadonlySet<string> = new Set(aggregateZeroOpen(injections, opens, now).keys())
|
|
292
|
+
if (injLines !== undefined && opensLines !== undefined) {
|
|
293
|
+
this.logSignalsCache = {
|
|
294
|
+
injMs: injSt.mtimeMs,
|
|
295
|
+
injSize: injSt.size,
|
|
296
|
+
opensMs: opensSt.mtimeMs,
|
|
297
|
+
opensSize: opensSt.size,
|
|
298
|
+
signals,
|
|
299
|
+
zeroOpenSlugs,
|
|
228
300
|
}
|
|
229
|
-
return out
|
|
230
301
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
302
|
+
return { signals, zeroOpenSlugs }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* zeroOpenDecay fields for scoring calls: empty when the gate is off —
|
|
307
|
+
* no file reads, zero behavior change — and the shared mtime-cached set
|
|
308
|
+
* when on. Spread into every searchTopics/scoreTopic cfg that already
|
|
309
|
+
* carries the usage signals (injection paths + explicit topic_search;
|
|
310
|
+
* the slow lane's recall band stays untouched, mirroring usageBoost).
|
|
311
|
+
*/
|
|
312
|
+
private zeroOpenCfg(): Pick<RetrievalConfig, 'zeroOpenDecay' | 'zeroOpenSlugs'> {
|
|
313
|
+
if (this.cfg.zeroOpenDecay !== true) return {}
|
|
314
|
+
return { zeroOpenDecay: true, zeroOpenSlugs: this.zeroOpenSlugsSync() }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** mtime-cached view of meta/embeddings.json (sync read — hot-lane caller).
|
|
318
|
+
* Missing/corrupt index = {} → the reranker degrades to lexical order. */
|
|
319
|
+
private embedIndexCache: { mtimeMs: number; size: number; index: EmbeddingIndex } | undefined
|
|
320
|
+
|
|
321
|
+
private embeddingIndexCacheGet(): EmbeddingIndex {
|
|
322
|
+
const root = resolveBundleRoot()
|
|
323
|
+
const file = join(root, 'meta', 'embeddings.json')
|
|
324
|
+
try {
|
|
325
|
+
const st = statSync(file)
|
|
326
|
+
if (this.embedIndexCache !== undefined && this.embedIndexCache.mtimeMs === st.mtimeMs && this.embedIndexCache.size === st.size) {
|
|
327
|
+
return this.embedIndexCache.index
|
|
328
|
+
}
|
|
329
|
+
const index = loadEmbeddingIndex(root)
|
|
330
|
+
this.embedIndexCache = { mtimeMs: st.mtimeMs, size: st.size, index }
|
|
331
|
+
return index
|
|
332
|
+
} catch {
|
|
333
|
+
return this.embedIndexCache?.index ?? {}
|
|
243
334
|
}
|
|
244
|
-
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Shadow bypass ledger (spec §8.3): one line per shadow round — feature
|
|
338
|
+
* fields only, never query text (jev-log discipline). Silent on failure;
|
|
339
|
+
* 512KB truncation keeps the newest three quarters. */
|
|
340
|
+
private appendPackageShadowLedger(entry: Record<string, unknown>): void {
|
|
341
|
+
try {
|
|
342
|
+
const file = join(resolveBundleRoot(), 'meta', 'package-shadow.jsonl')
|
|
343
|
+
let lines: string[] = []
|
|
344
|
+
try {
|
|
345
|
+
const st = statSync(file)
|
|
346
|
+
if (st.size > 512 * 1024) {
|
|
347
|
+
const all = readFileSync(file, 'utf8').split('\n').filter((l) => l !== '')
|
|
348
|
+
lines = all.slice(Math.floor(all.length / 4))
|
|
349
|
+
}
|
|
350
|
+
} catch { /* first write */ }
|
|
351
|
+
lines.push(JSON.stringify(entry))
|
|
352
|
+
writeFileSync(file, lines.join('\n') + '\n')
|
|
353
|
+
} catch { /* ledger must never break the lane */ }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Boot-time embedding index maintenance (spec §3: boot 校验+增量重建).
|
|
357
|
+
* Provider '' or packageMode off → no-op (default-off byte-for-byte).
|
|
358
|
+
* Full-roster embed on rebuild (A1 §5: ~9.5s for 137 topics). */
|
|
359
|
+
async refreshEmbeddingIndex(): Promise<void> {
|
|
360
|
+
const provider = (this.cfg.embeddingProvider ?? '').trim()
|
|
361
|
+
if (provider === '' || this.cfg.packageMode === undefined || this.cfg.packageMode === 'off') return
|
|
362
|
+
const embedder = embedderWith({ provider, model: this.cfg.embeddingModel })
|
|
363
|
+
const topics = await this.roster()
|
|
364
|
+
const root = resolveBundleRoot()
|
|
365
|
+
const index = loadEmbeddingIndex(root)
|
|
366
|
+
const stale = ensureIndexFresh(index, topics, embedder)
|
|
367
|
+
if (stale.length === 0) return
|
|
368
|
+
const docs = await Promise.all(stale.map(async (slug) => (await this.readDoc(slug)) ?? ''))
|
|
369
|
+
const texts = stale.map((slug, i) => `${slug}\n${docs[i] ?? ''}`)
|
|
370
|
+
const r = await embedTexts(texts, { provider, model: this.cfg.embeddingModel })
|
|
371
|
+
if (!r.ok) return
|
|
372
|
+
const next: EmbeddingIndex = { ...index }
|
|
373
|
+
stale.forEach((slug, i) => {
|
|
374
|
+
const vec = r.vectors[i]
|
|
375
|
+
if (vec !== undefined) next[slug] = { model: r.model, dim: vec.length, vec }
|
|
376
|
+
})
|
|
377
|
+
saveEmbeddingIndex(root, next)
|
|
378
|
+
this.embedIndexCache = undefined
|
|
245
379
|
}
|
|
246
380
|
|
|
247
381
|
/**
|
|
@@ -269,6 +403,7 @@ export class TopicsService {
|
|
|
269
403
|
conflicts,
|
|
270
404
|
usageBoost: cfg.usageBoost,
|
|
271
405
|
usage: this.usageSignalsSync(),
|
|
406
|
+
...this.zeroOpenCfg(),
|
|
272
407
|
})
|
|
273
408
|
const bySlug = new Map(roster.map((r) => [r.slug, r]))
|
|
274
409
|
const entries: DigestInput[] = []
|
|
@@ -358,8 +493,13 @@ export class TopicsService {
|
|
|
358
493
|
}
|
|
359
494
|
return { text: '', outcome: empty, deduped: [], echoed: [], included: [], slowIncluded: [] }
|
|
360
495
|
}
|
|
496
|
+
// A-line gate (A3/A4): query cleaning + package + semantic rerank ride
|
|
497
|
+
// behind packageMode (default off = byte-for-byte legacy path).
|
|
498
|
+
const pkgMode: 'shadow' | 'on' | undefined =
|
|
499
|
+
cfg.packageMode === 'shadow' || cfg.packageMode === 'on' ? cfg.packageMode : undefined
|
|
500
|
+
const effectiveQuery = pkgMode !== undefined ? sanitizeQuery(query) : query
|
|
361
501
|
const conflicts = this.store.getConflictsSync()
|
|
362
|
-
const outcome = searchTopics(
|
|
502
|
+
const outcome = searchTopics(effectiveQuery, roster, {
|
|
363
503
|
threshold: cfg.matchThreshold,
|
|
364
504
|
topK: cfg.topK,
|
|
365
505
|
tagBoost: cfg.tagBoost,
|
|
@@ -368,7 +508,37 @@ export class TopicsService {
|
|
|
368
508
|
conflicts,
|
|
369
509
|
usageBoost: cfg.usageBoost,
|
|
370
510
|
usage: this.usageSignalsSync(),
|
|
511
|
+
...this.zeroOpenCfg(),
|
|
371
512
|
})
|
|
513
|
+
// ---- A-line semantic rerank (A4 决议 2, spec §3.1) ----
|
|
514
|
+
// Reorder ONLY the 0.20–0.30 nearFloor band (the 93% near-miss mass);
|
|
515
|
+
// thresholds/gateScore/hits untouched. Any failure degrades to the
|
|
516
|
+
// original order — the fast lane never blocks on the semantic layer.
|
|
517
|
+
let nearFloorRerank: { reranked: boolean; passthroughReason?: string; orderBefore: string[]; orderAfter: string[] } | undefined
|
|
518
|
+
if (pkgMode !== undefined) {
|
|
519
|
+
const bandLo = 0.2
|
|
520
|
+
const band = outcome.nearMisses.filter((h) => h.score >= bandLo && h.score < cfg.matchThreshold)
|
|
521
|
+
const orderBefore = band.map((h) => h.slug)
|
|
522
|
+
if (band.length > 1) {
|
|
523
|
+
const index = this.embeddingIndexCacheGet()
|
|
524
|
+
const models = new Set(Object.values(index).map((e) => e.model))
|
|
525
|
+
const embedModel = resolveEmbedModel(cfg.embeddingModel)
|
|
526
|
+
if (models.size <= 1 && (models.size === 0 || models.has(embedModel))) {
|
|
527
|
+
const qv = embedQuerySync(effectiveQuery, { provider: cfg.embeddingProvider, model: cfg.embeddingModel })
|
|
528
|
+
const rr = rerankNearFloor(effectiveQuery, qv.ok ? qv.vector : null, band, index)
|
|
529
|
+
if (rr.reranked) {
|
|
530
|
+
const inBand = (h: { score: number }) => h.score >= bandLo && h.score < cfg.matchThreshold
|
|
531
|
+
const rrSlugs = new Set(rr.hits.map((h) => h.slug))
|
|
532
|
+
const merged = [...rr.hits, ...band.filter((h) => !rrSlugs.has(h.slug))]
|
|
533
|
+
let mi = 0
|
|
534
|
+
outcome.nearMisses = outcome.nearMisses.map((h) => (inBand(h) ? merged[mi++]! : h))
|
|
535
|
+
nearFloorRerank = { reranked: true, orderBefore, orderAfter: merged.map((h) => h.slug) }
|
|
536
|
+
} else {
|
|
537
|
+
nearFloorRerank = { reranked: false, passthroughReason: rr.passthroughReason, orderBefore, orderAfter: orderBefore }
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
372
542
|
const exclude = dedup?.exclude
|
|
373
543
|
const echo = dedup?.echo
|
|
374
544
|
const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug)
|
|
@@ -398,7 +568,7 @@ export class TopicsService {
|
|
|
398
568
|
let slowIncluded: string[] = []
|
|
399
569
|
const consumedAt = new Date().toISOString()
|
|
400
570
|
if (slow !== undefined) {
|
|
401
|
-
const queryTokens = new Set(tokenize(
|
|
571
|
+
const queryTokens = new Set(tokenize(effectiveQuery))
|
|
402
572
|
for (const item of slow.items) {
|
|
403
573
|
if (exclude?.has(item.slug)) {
|
|
404
574
|
if (!deduped.includes(item.slug)) deduped.push(item.slug)
|
|
@@ -427,6 +597,7 @@ export class TopicsService {
|
|
|
427
597
|
structuralGate: true,
|
|
428
598
|
usageBoost: cfg.usageBoost,
|
|
429
599
|
usage: this.usageSignalsSync(),
|
|
600
|
+
...this.zeroOpenCfg(),
|
|
430
601
|
})
|
|
431
602
|
shadow.push({
|
|
432
603
|
slug: item.slug,
|
|
@@ -445,17 +616,74 @@ export class TopicsService {
|
|
|
445
616
|
const pointerMode = cfg.injectMode !== 'digest'
|
|
446
617
|
// Pointer budget: per-entry fixed at the design's 80, total clamped to the
|
|
447
618
|
// fast-lane upper bound of 600 (I4) — totalBudget stays tunable downward.
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
619
|
+
let packageSkipped = false
|
|
620
|
+
let packageShadow: PackageResult | null | undefined
|
|
621
|
+
let injection: AssembleResult
|
|
622
|
+
if (!pointerMode) {
|
|
623
|
+
injection = assembleInjection(entries, { perTopicBudget: cfg.perTopicBudget, totalBudget: cfg.totalBudget }, slowPointers)
|
|
624
|
+
} else if (pkgMode === undefined) {
|
|
625
|
+
injection = assemblePointer(entries, { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) }, slowPointers)
|
|
626
|
+
} else {
|
|
627
|
+
// A-line relevance package (A3, spec §2): direct = lexical hits,
|
|
628
|
+
// neighborhood = graph hits + slow picks, weak = reranked band head.
|
|
629
|
+
const budget = { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) }
|
|
630
|
+
const legacy = assemblePointer(entries, budget, slowPointers)
|
|
631
|
+
const direct: PackageCandidate[] = []
|
|
632
|
+
const neighborhood: PackageCandidate[] = []
|
|
633
|
+
for (const e of entries) {
|
|
634
|
+
const meta = bySlug.get(e.slug)
|
|
635
|
+
const cand: PackageCandidate = {
|
|
636
|
+
slug: e.slug,
|
|
637
|
+
title: meta?.title ?? e.slug,
|
|
638
|
+
status: meta?.status ?? 'draft',
|
|
639
|
+
description: meta?.description,
|
|
640
|
+
conclusion: firstContentLine(e.doc.body),
|
|
641
|
+
score: e.hit.score,
|
|
642
|
+
reasons: e.hit.reasons,
|
|
643
|
+
via: e.hit.viaGraph ? 'graph' : undefined,
|
|
644
|
+
}
|
|
645
|
+
;(e.hit.viaGraph ? neighborhood : direct).push(cand)
|
|
646
|
+
}
|
|
647
|
+
for (const sp of slowPointers) {
|
|
648
|
+
neighborhood.push({ slug: sp.slug, title: sp.title, status: sp.status, description: sp.description, score: 0, reasons: [], via: 'slow-pick' })
|
|
649
|
+
}
|
|
650
|
+
const weak = outcome.nearMisses
|
|
651
|
+
.filter((h) => h.score >= 0.2 && h.score < cfg.matchThreshold)
|
|
652
|
+
.slice(0, 2)
|
|
653
|
+
.map((h) => {
|
|
654
|
+
const meta = bySlug.get(h.slug)
|
|
655
|
+
return { slug: h.slug, title: meta?.title ?? h.slug, status: meta?.status ?? 'draft', score: h.score, reasons: h.reasons, via: 'weak' }
|
|
656
|
+
})
|
|
657
|
+
const usage = this.usageSignalsSync()
|
|
658
|
+
const usageBySlug = new Map<string, number>([...usage].map(([slug, sig]) => [slug, sig.opens * 3 + sig.hits]))
|
|
659
|
+
const pkgInput: PackageInput = { query: effectiveQuery, queryTokens: tokenize(effectiveQuery), direct, neighborhood, weak, usageBySlug }
|
|
660
|
+
const pkg = assemblePackage(pkgInput)
|
|
661
|
+
if (pkgMode === 'shadow') {
|
|
662
|
+
packageShadow = pkg
|
|
663
|
+
injection = legacy
|
|
664
|
+
} else if (pkg === null) {
|
|
665
|
+
// Trivial-query downgrade (A3 决议 5): emit nothing, log the why.
|
|
666
|
+
packageSkipped = true
|
|
667
|
+
injection = { text: '', usedTokens: 0, included: [], dropped: [] }
|
|
668
|
+
} else {
|
|
669
|
+
const slowSet = new Set(slowPointers.map((sp) => sp.slug))
|
|
670
|
+
injection = {
|
|
671
|
+
text: pkg.text,
|
|
672
|
+
usedTokens: pkg.usedTokens,
|
|
673
|
+
included: pkg.included,
|
|
674
|
+
dropped: pkg.dropped,
|
|
675
|
+
slowIncluded: pkg.included.filter((slug) => slowSet.has(slug)),
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
451
679
|
slowIncluded = injection.slowIncluded ?? []
|
|
452
680
|
for (const pointer of slowPointers) {
|
|
453
681
|
if (slowIncluded.includes(pointer.slug)) slowDelivered.push({ slug: pointer.slug, why: pointer.why })
|
|
454
682
|
}
|
|
455
683
|
const record: InjectionRecord = {
|
|
456
684
|
at: new Date().toISOString(),
|
|
457
|
-
queryTokenCount: tokenize(
|
|
458
|
-
querySample: querySample(
|
|
685
|
+
queryTokenCount: tokenize(effectiveQuery).length,
|
|
686
|
+
querySample: querySample(effectiveQuery),
|
|
459
687
|
rosterSize: outcome.rosterSize,
|
|
460
688
|
hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph, strong: h.strong, bodyHits: h.bodyHits })),
|
|
461
689
|
// Reasons ride along: the gate-blocked marker is the replay evidence
|
|
@@ -467,10 +695,27 @@ export class TopicsService {
|
|
|
467
695
|
if (sessionId !== undefined) record.sessionId = sessionId
|
|
468
696
|
if (deduped.length > 0) record.deduped = deduped
|
|
469
697
|
if (echoed.length > 0) record.echoed = echoed
|
|
698
|
+
if (packageSkipped) record.why = 'package-skipped-trivial'
|
|
470
699
|
if (unreadable.length > 0) {
|
|
471
700
|
record.dropped = [...(record.dropped ?? []), ...unreadable.map((slug) => ({ slug, reason: 'doc-unreadable' }))]
|
|
472
701
|
}
|
|
702
|
+
if (pkgMode === 'shadow' && pointerMode) {
|
|
703
|
+
this.appendPackageShadowLedger({
|
|
704
|
+
at: record.at,
|
|
705
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
706
|
+
queryTokenCount: record.queryTokenCount,
|
|
707
|
+
emitted: { tokens: injection.usedTokens, included: injection.included },
|
|
708
|
+
package:
|
|
709
|
+
packageShadow === undefined
|
|
710
|
+
? null
|
|
711
|
+
: packageShadow === null
|
|
712
|
+
? { skipped: 'trivial-or-empty' }
|
|
713
|
+
: { tokens: packageShadow.usedTokens, included: packageShadow.included, layers: packageShadow.layers },
|
|
714
|
+
nearFloorRerank: nearFloorRerank ?? null,
|
|
715
|
+
})
|
|
716
|
+
}
|
|
473
717
|
if (slow !== undefined) {
|
|
718
|
+
if (slow.outcome !== undefined) record.slowOutcome = slow.outcome
|
|
474
719
|
// Any round whose pending was consumed is a slow-participating round —
|
|
475
720
|
// lane never stays undefined when the lane family is present (a
|
|
476
721
|
// delivered pick with fast pointers is 'mixed'; a consumed-but-empty
|
|
@@ -532,9 +777,14 @@ export class TopicsService {
|
|
|
532
777
|
const doc = slug === '' ? undefined : await this.store.readTopic(slug).catch(() => undefined)
|
|
533
778
|
if (doc === undefined) return { found: false, slug }
|
|
534
779
|
// Awaited (tool path, not hot): a settled write makes the pointer-open
|
|
535
|
-
// stat reliable; a failed log write must not fail the tool.
|
|
780
|
+
// stat reliable; a failed log write must not fail the tool. Source
|
|
781
|
+
// attribution rides the same write: a cheap injections-tail scan decides
|
|
782
|
+
// pointer vs search and, for pointers, carries the hit score and the
|
|
783
|
+
// open→injection lag (ilog.attributeOpen).
|
|
784
|
+
const at = new Date().toISOString()
|
|
785
|
+
const attribution = await this.attributeOpen(slug, sessionId, Date.parse(at))
|
|
536
786
|
await this.store
|
|
537
|
-
.appendOpenRecord({ slug, at
|
|
787
|
+
.appendOpenRecord({ slug, at, ...(sessionId !== undefined ? { sessionId } : {}), ...attribution })
|
|
538
788
|
.catch(() => undefined)
|
|
539
789
|
// Optional fields are OMITTED, never present-as-undefined: the host's
|
|
540
790
|
// tool-output validation rejects any object carrying an undefined-valued
|
|
@@ -564,6 +814,18 @@ export class TopicsService {
|
|
|
564
814
|
return found
|
|
565
815
|
}
|
|
566
816
|
|
|
817
|
+
/**
|
|
818
|
+
* Resolve the open's source attribution: the injections tail (bounded by
|
|
819
|
+
* OPEN_ATTRIBUTION_TAIL) scanned for a same-session hit within the
|
|
820
|
+
* attribution window. Read failures degrade to 'search' — attribution is
|
|
821
|
+
* telemetry, never a blocker.
|
|
822
|
+
*/
|
|
823
|
+
private async attributeOpen(slug: string, sessionId: string | undefined, nowMs: number): Promise<{ source: 'pointer' | 'search'; score?: number; sinceInjectionMs?: number }> {
|
|
824
|
+
if (sessionId === undefined) return { source: 'search' }
|
|
825
|
+
const tail = await this.store.readInjectionRecords(OPEN_ATTRIBUTION_TAIL).catch(() => [])
|
|
826
|
+
return attributeOpen(tail, slug, sessionId, nowMs)
|
|
827
|
+
}
|
|
828
|
+
|
|
567
829
|
/** Sync roster read (same mtime cache as roster()). */
|
|
568
830
|
rosterSync(): RetrievableTopic[] {
|
|
569
831
|
let files: string[]
|
|
@@ -639,6 +901,8 @@ export class TopicsService {
|
|
|
639
901
|
depends?: string[]
|
|
640
902
|
openQuestions?: string[]
|
|
641
903
|
impact?: string[]
|
|
904
|
+
/** A2 semantic-link targets; store.saveTopic lands them as body wikilinks. */
|
|
905
|
+
links?: string[]
|
|
642
906
|
status?: okf.TopicStatus
|
|
643
907
|
conclusion?: string
|
|
644
908
|
recommendations?: string
|
|
@@ -680,7 +944,7 @@ export class TopicsService {
|
|
|
680
944
|
if (triggers.length > 0) fm.triggers = triggers
|
|
681
945
|
if (existing !== undefined && existing.fm.verified !== undefined) fm.verified = existing.fm.verified
|
|
682
946
|
const result = await this.store.saveTopic(
|
|
683
|
-
{ slug, doc: { fm, body } },
|
|
947
|
+
{ slug, doc: { fm, body }, links: input.links },
|
|
684
948
|
{ message: existing === undefined ? `topics(topic): create ${slug}` : `topics(topic): update ${slug}`, created: existing === undefined },
|
|
685
949
|
)
|
|
686
950
|
this.invalidate()
|
|
@@ -706,6 +970,7 @@ export class TopicsService {
|
|
|
706
970
|
conflicts: await this.store.getConflicts(),
|
|
707
971
|
usageBoost: cfg.usageBoost,
|
|
708
972
|
usage: this.usageSignalsSync(),
|
|
973
|
+
...this.zeroOpenCfg(),
|
|
709
974
|
})
|
|
710
975
|
}
|
|
711
976
|
|
|
@@ -773,6 +1038,15 @@ function dedupePaths(paths: string[]): string[] {
|
|
|
773
1038
|
return out
|
|
774
1039
|
}
|
|
775
1040
|
|
|
1041
|
+
function firstContentLine(doc: string): string {
|
|
1042
|
+
for (const line of doc.split('\n')) {
|
|
1043
|
+
const t = line.trim()
|
|
1044
|
+
if (t === '' || t.startsWith('#')) continue
|
|
1045
|
+
return t.slice(0, 200)
|
|
1046
|
+
}
|
|
1047
|
+
return ''
|
|
1048
|
+
}
|
|
1049
|
+
|
|
776
1050
|
function sanitizeTriggers(triggers: readonly string[]): string[] {
|
|
777
1051
|
const out: string[] = []
|
|
778
1052
|
for (const t of triggers) {
|
package/src/store.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
26
26
|
import * as okf from './okf.ts'
|
|
27
27
|
import * as gitmod from './git.ts'
|
|
28
28
|
import { actorFor } from './paths.ts'
|
|
29
|
-
import type { InjectionRecord } from './ilog.ts'
|
|
29
|
+
import type { InjectionRecord, OpenRecord } from './ilog.ts'
|
|
30
30
|
|
|
31
31
|
export interface TopicMeta {
|
|
32
32
|
slug: string
|
|
@@ -93,6 +93,15 @@ function sameStrings(a: readonly string[], b: readonly string[]): boolean {
|
|
|
93
93
|
return a.length === b.length && a.every((v, i) => v === b[i])
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Body heading for caller-supplied semantic links (A2). Links are rendered as
|
|
98
|
+
* `[[wikilinks]]` under this section so the EXISTING link-edge machinery
|
|
99
|
+
* (okf.bodyLinkSlugs → computeBacklinks → retrieval graph walk) indexes them
|
|
100
|
+
* as `via: 'link'` edges — the edge representation gains no new type; the
|
|
101
|
+
* body landing spot is what makes them first-class links for free.
|
|
102
|
+
*/
|
|
103
|
+
const LINKS_HEADING = 'Links'
|
|
104
|
+
|
|
96
105
|
export interface BundleStoreOptions {
|
|
97
106
|
/** Override the stamped actor (tests). Defaults to `agent:dsh-topics-memory@<host>`. */
|
|
98
107
|
actor?: string
|
|
@@ -348,9 +357,17 @@ export class BundleStore {
|
|
|
348
357
|
* Write a topic (atomic), regenerate the index, and commit both — one
|
|
349
358
|
* commit per conclusion change (ADR 0003). `slug` in the doc's depends is
|
|
350
359
|
* NOT normalized here; callers pass bundle-relative paths.
|
|
360
|
+
*
|
|
361
|
+
* `links` (A2, optional): whitelist-surviving semantic targets from the
|
|
362
|
+
* distill lane. Applied INSIDE this one write transaction — the topic and
|
|
363
|
+
* its edges land together (先落 topic 再建边); the Links section lands as
|
|
364
|
+
* body wikilinks and the index/backlinks regeneration below (same
|
|
365
|
+
* transaction) recomputes the edge index over them. Normalized and
|
|
366
|
+
* self/dedup-guarded here too, so any caller benefits from the same guard
|
|
367
|
+
* the distill lane already ran.
|
|
351
368
|
*/
|
|
352
369
|
async saveTopic(
|
|
353
|
-
input: { slug: string; doc: okf.TopicDoc },
|
|
370
|
+
input: { slug: string; doc: okf.TopicDoc; links?: readonly string[] },
|
|
354
371
|
opts: { message: string; actor?: string; created?: boolean },
|
|
355
372
|
): Promise<SaveResult> {
|
|
356
373
|
return this.enqueue(async () => {
|
|
@@ -358,9 +375,22 @@ export class BundleStore {
|
|
|
358
375
|
const file = this.topicPath(slug)
|
|
359
376
|
const created = opts.created ?? !(await existsSync(file))
|
|
360
377
|
const now = new Date().toISOString()
|
|
378
|
+
let body = input.doc.body
|
|
379
|
+
if (input.links !== undefined && input.links.length > 0) {
|
|
380
|
+
const targets: string[] = []
|
|
381
|
+
for (const raw of input.links) {
|
|
382
|
+
if (typeof raw !== 'string') continue
|
|
383
|
+
const target = okf.normalizeLinkTarget(raw)
|
|
384
|
+
if (target === undefined || target === slug || targets.includes(target)) continue
|
|
385
|
+
targets.push(target)
|
|
386
|
+
}
|
|
387
|
+
if (targets.length > 0) {
|
|
388
|
+
body = okf.setSection(body, LINKS_HEADING, targets.map((t) => `- [[${t}]]`).join('\n'))
|
|
389
|
+
}
|
|
390
|
+
}
|
|
361
391
|
const doc: okf.TopicDoc = {
|
|
362
392
|
fm: { ...input.doc.fm, generated: { by: opts.actor ?? this.actor, at: now } },
|
|
363
|
-
body
|
|
393
|
+
body,
|
|
364
394
|
}
|
|
365
395
|
const raw = okf.serializeTopicDoc(doc)
|
|
366
396
|
await atomicWrite(file, raw)
|
|
@@ -612,14 +642,15 @@ export class BundleStore {
|
|
|
612
642
|
|
|
613
643
|
// ------------------------------------------------------------------
|
|
614
644
|
// Pointer-open log (v4 §4.3) — one line per topic_open call; feeds the
|
|
615
|
-
// 「指针打开率」 stat. Features only (slug/time), never
|
|
645
|
+
// 「指针打开率」 stat. Features only (slug/time + attribution), never
|
|
646
|
+
// conversation text.
|
|
616
647
|
// ------------------------------------------------------------------
|
|
617
648
|
|
|
618
649
|
private opensPath(): string {
|
|
619
650
|
return join(this.metaDir(), 'opens.jsonl')
|
|
620
651
|
}
|
|
621
652
|
|
|
622
|
-
async appendOpenRecord(record:
|
|
653
|
+
async appendOpenRecord(record: OpenRecord): Promise<void> {
|
|
623
654
|
await mkdir(this.metaDir(), { recursive: true })
|
|
624
655
|
await appendFile(this.opensPath(), `${JSON.stringify(record)}\n`, 'utf8')
|
|
625
656
|
await this.compactOpensIfNeeded()
|
|
@@ -638,22 +669,27 @@ export class BundleStore {
|
|
|
638
669
|
await atomicWrite(file, lines.slice(-Math.max(1, Math.floor(lines.length / 4))).map((l) => `${l}\n`).join(''))
|
|
639
670
|
}
|
|
640
671
|
|
|
641
|
-
/** Opens in the recent window; tolerant of a torn tail like the other JSONLs.
|
|
642
|
-
|
|
672
|
+
/** Opens in the recent window; tolerant of a torn tail like the other JSONLs.
|
|
673
|
+
* Enrichment fields (sessionId/source/score/sinceInjectionMs) are optional:
|
|
674
|
+
* pre-enrichment lines carry only slug/at and read back without them. */
|
|
675
|
+
async readOpenRecords(limit = 2000): Promise<OpenRecord[]> {
|
|
643
676
|
let raw: string
|
|
644
677
|
try {
|
|
645
678
|
raw = await readFile(this.opensPath(), 'utf8')
|
|
646
679
|
} catch {
|
|
647
680
|
return []
|
|
648
681
|
}
|
|
649
|
-
const out:
|
|
682
|
+
const out: OpenRecord[] = []
|
|
650
683
|
for (const line of raw.split('\n')) {
|
|
651
684
|
if (line.trim() === '') continue
|
|
652
685
|
try {
|
|
653
|
-
const parsed = JSON.parse(line) as { slug?: unknown; at?: unknown; sessionId?: unknown }
|
|
686
|
+
const parsed = JSON.parse(line) as { slug?: unknown; at?: unknown; sessionId?: unknown; source?: unknown; score?: unknown; sinceInjectionMs?: unknown }
|
|
654
687
|
if (typeof parsed.slug === 'string' && typeof parsed.at === 'string') {
|
|
655
|
-
const rec:
|
|
688
|
+
const rec: OpenRecord = { slug: parsed.slug, at: parsed.at }
|
|
656
689
|
if (typeof parsed.sessionId === 'string') rec.sessionId = parsed.sessionId
|
|
690
|
+
if (parsed.source === 'pointer' || parsed.source === 'search') rec.source = parsed.source
|
|
691
|
+
if (typeof parsed.score === 'number') rec.score = parsed.score
|
|
692
|
+
if (typeof parsed.sinceInjectionMs === 'number') rec.sinceInjectionMs = parsed.sinceInjectionMs
|
|
657
693
|
out.push(rec)
|
|
658
694
|
}
|
|
659
695
|
} catch {
|