@aiwayds/dsh-topics-memory 0.22.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 RetrievalConfig, 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, aggregateZeroOpen, ZERO_OPEN_TAIL, USAGE_WINDOW_DAYS, attributeOpen, OPEN_ATTRIBUTION_TAIL, type AggregateStats, type InjectionRecord, type OpenRecord, 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
 
@@ -75,6 +77,8 @@ export interface SlowDelivery {
75
77
  queryBuild: QueryBuildShape
76
78
  model: string
77
79
  ms: number
80
+ /** Producing run's disposition (A4 全留痕) — copied into ilog.slowOutcome. */
81
+ outcome?: SlowOutcome
78
82
  }
79
83
 
80
84
  export class TopicsService {
@@ -310,6 +314,70 @@ export class TopicsService {
310
314
  return { zeroOpenDecay: true, zeroOpenSlugs: this.zeroOpenSlugsSync() }
311
315
  }
312
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 ?? {}
334
+ }
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
379
+ }
380
+
313
381
  /**
314
382
  * Full retrieval round: search → assemble → log (ADR 0006/0007). The
315
383
  * injection record is written even for zero-hit rounds — the hit-rate
@@ -425,8 +493,13 @@ export class TopicsService {
425
493
  }
426
494
  return { text: '', outcome: empty, deduped: [], echoed: [], included: [], slowIncluded: [] }
427
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
428
501
  const conflicts = this.store.getConflictsSync()
429
- const outcome = searchTopics(query, roster, {
502
+ const outcome = searchTopics(effectiveQuery, roster, {
430
503
  threshold: cfg.matchThreshold,
431
504
  topK: cfg.topK,
432
505
  tagBoost: cfg.tagBoost,
@@ -437,6 +510,35 @@ export class TopicsService {
437
510
  usage: this.usageSignalsSync(),
438
511
  ...this.zeroOpenCfg(),
439
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
+ }
440
542
  const exclude = dedup?.exclude
441
543
  const echo = dedup?.echo
442
544
  const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug)
@@ -466,7 +568,7 @@ export class TopicsService {
466
568
  let slowIncluded: string[] = []
467
569
  const consumedAt = new Date().toISOString()
468
570
  if (slow !== undefined) {
469
- const queryTokens = new Set(tokenize(query))
571
+ const queryTokens = new Set(tokenize(effectiveQuery))
470
572
  for (const item of slow.items) {
471
573
  if (exclude?.has(item.slug)) {
472
574
  if (!deduped.includes(item.slug)) deduped.push(item.slug)
@@ -514,17 +616,74 @@ export class TopicsService {
514
616
  const pointerMode = cfg.injectMode !== 'digest'
515
617
  // Pointer budget: per-entry fixed at the design's 80, total clamped to the
516
618
  // fast-lane upper bound of 600 (I4) — totalBudget stays tunable downward.
517
- const injection = pointerMode
518
- ? assemblePointer(entries, { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) }, slowPointers)
519
- : 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
+ }
520
679
  slowIncluded = injection.slowIncluded ?? []
521
680
  for (const pointer of slowPointers) {
522
681
  if (slowIncluded.includes(pointer.slug)) slowDelivered.push({ slug: pointer.slug, why: pointer.why })
523
682
  }
524
683
  const record: InjectionRecord = {
525
684
  at: new Date().toISOString(),
526
- queryTokenCount: tokenize(query).length,
527
- querySample: querySample(query),
685
+ queryTokenCount: tokenize(effectiveQuery).length,
686
+ querySample: querySample(effectiveQuery),
528
687
  rosterSize: outcome.rosterSize,
529
688
  hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph, strong: h.strong, bodyHits: h.bodyHits })),
530
689
  // Reasons ride along: the gate-blocked marker is the replay evidence
@@ -536,10 +695,27 @@ export class TopicsService {
536
695
  if (sessionId !== undefined) record.sessionId = sessionId
537
696
  if (deduped.length > 0) record.deduped = deduped
538
697
  if (echoed.length > 0) record.echoed = echoed
698
+ if (packageSkipped) record.why = 'package-skipped-trivial'
539
699
  if (unreadable.length > 0) {
540
700
  record.dropped = [...(record.dropped ?? []), ...unreadable.map((slug) => ({ slug, reason: 'doc-unreadable' }))]
541
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
+ }
542
717
  if (slow !== undefined) {
718
+ if (slow.outcome !== undefined) record.slowOutcome = slow.outcome
543
719
  // Any round whose pending was consumed is a slow-participating round —
544
720
  // lane never stays undefined when the lane family is present (a
545
721
  // delivered pick with fast pointers is 'mixed'; a consumed-but-empty
@@ -725,6 +901,8 @@ export class TopicsService {
725
901
  depends?: string[]
726
902
  openQuestions?: string[]
727
903
  impact?: string[]
904
+ /** A2 semantic-link targets; store.saveTopic lands them as body wikilinks. */
905
+ links?: string[]
728
906
  status?: okf.TopicStatus
729
907
  conclusion?: string
730
908
  recommendations?: string
@@ -766,7 +944,7 @@ export class TopicsService {
766
944
  if (triggers.length > 0) fm.triggers = triggers
767
945
  if (existing !== undefined && existing.fm.verified !== undefined) fm.verified = existing.fm.verified
768
946
  const result = await this.store.saveTopic(
769
- { slug, doc: { fm, body } },
947
+ { slug, doc: { fm, body }, links: input.links },
770
948
  { message: existing === undefined ? `topics(topic): create ${slug}` : `topics(topic): update ${slug}`, created: existing === undefined },
771
949
  )
772
950
  this.invalidate()
@@ -860,6 +1038,15 @@ function dedupePaths(paths: string[]): string[] {
860
1038
  return out
861
1039
  }
862
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
+
863
1050
  function sanitizeTriggers(triggers: readonly string[]): string[] {
864
1051
  const out: string[] = []
865
1052
  for (const t of triggers) {
package/src/store.ts CHANGED
@@ -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)