@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/lib/config.d.ts +38 -1
- package/lib/config.js +29 -0
- 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 +16 -0
- package/lib/index.d.ts +6 -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 +106 -0
- package/lib/retrieval.js +280 -0
- package/lib/service.d.ts +17 -1
- package/lib/service.js +202 -11
- package/lib/store.d.ts +9 -0
- package/lib/store.js +32 -1
- package/package.json +2 -2
- package/src/config.ts +34 -0
- package/src/digest.ts +416 -0
- package/src/distill.ts +275 -14
- package/src/embedding.ts +294 -0
- package/src/ilog.ts +17 -0
- package/src/index.ts +23 -5
- package/src/pipeline-events.ts +132 -0
- package/src/quality.ts +145 -21
- package/src/retrieval.ts +335 -0
- package/src/service.ts +199 -12
- package/src/store.ts +32 -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 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(
|
|
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(
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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(
|
|
527
|
-
querySample: querySample(
|
|
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
|
|
393
|
+
body,
|
|
364
394
|
}
|
|
365
395
|
const raw = okf.serializeTopicDoc(doc)
|
|
366
396
|
await atomicWrite(file, raw)
|