@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/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
- private usageCache: {
54
- injMs: number
55
- injSize: number
56
- opensMs: number
57
- opensSize: number
58
- signals: Map<string, UsageSignal>
59
- } | undefined
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.usageCache = undefined
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. Unreadable logs fail open to an
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.usageCache
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.signals
283
+ return cached
214
284
  }
215
- const parseLines = <T>(file: string): T[] => {
216
- const out: T[] = []
217
- try {
218
- for (const line of readFileSync(file, 'utf8').split('\n')) {
219
- if (line.trim() === '') continue
220
- try {
221
- out.push(JSON.parse(line) as T)
222
- } catch {
223
- continue // torn tail line
224
- }
225
- }
226
- } catch {
227
- // unreadable log: contribute nothing
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
- const signals = aggregateUsage(
232
- parseLines(injFile),
233
- parseLines(opensFile),
234
- USAGE_WINDOW_DAYS,
235
- Date.now(),
236
- )
237
- this.usageCache = {
238
- injMs: injSt.mtimeMs,
239
- injSize: injSt.size,
240
- opensMs: opensSt.mtimeMs,
241
- opensSize: opensSt.size,
242
- signals,
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
- return signals
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(query, roster, {
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(query))
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
- const injection = pointerMode
449
- ? assemblePointer(entries, { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) }, slowPointers)
450
- : assembleInjection(entries, { perTopicBudget: cfg.perTopicBudget, totalBudget: cfg.totalBudget }, slowPointers)
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(query).length,
458
- querySample: querySample(query),
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: new Date().toISOString(), ...(sessionId !== undefined ? { sessionId } : {}) })
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: input.doc.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 conversation text.
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: { slug: string; at: string; sessionId?: string }): Promise<void> {
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
- async readOpenRecords(limit = 2000): Promise<{ slug: string; at: string; sessionId?: string }[]> {
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: { slug: string; at: string; sessionId?: string }[] = []
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: { slug: string; at: string; sessionId?: string } = { slug: parsed.slug, at: parsed.at }
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 {