@aiwayds/dsh-topics-memory 0.23.0 → 0.24.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
@@ -10,13 +10,14 @@ import { readFile, stat } from 'node:fs/promises'
10
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, 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'
13
+ import { searchTopics, scoreTopic, passesGate, tokenize, sanitizeQuery, loadEmbeddingIndex, rerankNearFloor, ensureIndexFresh, saveEmbeddingIndex, type EmbeddingIndex, type RetrievableTopic, type RetrievalConfig, type SearchOutcome, type ScoredTopic } from './retrieval.ts'
14
+ import { assembleInjection, assemblePointer, assemblePackage, lessonEntryText, lessonSourceTag, POINTER_PER_TOPIC, POINTER_TOTAL, type AssembleResult, type DigestInput, type LessonPointerInput, type PackageCandidate, type PackageInput, type PackageResult, type SlowPointerInput } from './digest.ts'
15
15
  import { embedTexts, embedQuerySync, embedderWith, resolveEmbedModel } from './embedding.ts'
16
- import { resolveBundleRoot } from './paths.ts'
16
+ import { currentWorkspaceId, resolveBundleRoot } from './paths.ts'
17
17
  import type { BundleStore, Observation, SaveResult } from './store.ts'
18
18
  import type { TopicsConfigValue } from './config.ts'
19
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'
20
+ import { recordPipelineEvent } from './pipeline-events.ts'
20
21
  import { fileHistory, fileAtRev } from './git.ts'
21
22
  import type { Sync } from './sync.ts'
22
23
 
@@ -81,6 +82,49 @@ export interface SlowDelivery {
81
82
  outcome?: SlowOutcome
82
83
  }
83
84
 
85
+ // ---------------------------------------------------------------------------
86
+ // B-line dual-write seams (卷二 §6.2 MJ-5): the new repo's ONLY write surface
87
+ // into the bundle (禁直写 bundle 文件). Both audit the caller identity
88
+ // (opts.actor required) into meta/pipeline-events.jsonl.
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * One structured observation from the new repo's signal layer (B1 §2.1
93
+ * whitelist): FEATURES ONLY, never conversation text (红线⑤). Unknown caller
94
+ * fields cannot pass — the whitelist copy in feedObservations is the gate.
95
+ */
96
+ export interface StructuredObservation {
97
+ /** Signal type from the B1 closed list (`tool-error`, `turn-end-negative`,
98
+ * `unused-injection`, `repeat-call`, `interruption-pair`, `jev-calibration`). */
99
+ type: string
100
+ /** ISO instant; defaults to now. */
101
+ at?: string
102
+ sessionId?: string
103
+ /** Bounded scalar feature fields (toolName/errorName/count/band/...). */
104
+ detail?: Record<string, string | number | boolean>
105
+ }
106
+
107
+ export interface FeedResult {
108
+ accepted: number
109
+ duplicates: number
110
+ rejected: number
111
+ ids: string[]
112
+ /** Present when the call itself was refused (missing/blank actor). */
113
+ error?: 'unauthorized'
114
+ }
115
+
116
+ export interface StoreResult {
117
+ ok: boolean
118
+ slug: string
119
+ /** Refusal reason; absent on success. */
120
+ reason?: 'unauthorized' | 'not-found' | 'not-a-lesson' | 'invalid-state'
121
+ /** confidence before the transition (when the lesson was read). */
122
+ from?: okf.LessonConfidence
123
+ /** removed only: bundle-relative archive path (git-revertable). */
124
+ archivedTo?: string
125
+ committed?: boolean
126
+ }
127
+
84
128
  export class TopicsService {
85
129
  private cache = new Map<string, CacheEntry>()
86
130
  /** mtime-keyed parse of the observations log, grouped by session. */
@@ -155,6 +199,11 @@ export class TopicsService {
155
199
  // depends-graph walk, or a merged-away entry would keep competing
156
200
  // with its survivor for the injection budget.
157
201
  if (doc.fm.status === 'deprecated') continue
202
+ // Lesson-kind entries (B2) never ride the topic pipeline — they have
203
+ // their own gate (lessonInject), confidence rule and scope filter in
204
+ // lessonsSync(). Gate-independent separation: even with lessonInject
205
+ // off, a hint lesson must not leak through the topic lexical lane.
206
+ if (okf.topicKindOf(doc.fm.type) === 'lesson') continue
158
207
  out.push({
159
208
  slug: entry.slug,
160
209
  title: doc.fm.title,
@@ -336,10 +385,11 @@ export class TopicsService {
336
385
 
337
386
  /** Shadow bypass ledger (spec §8.3): one line per shadow round — feature
338
387
  * 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 {
388
+ * 512KB truncation keeps the newest three quarters. Shared by the package
389
+ * shadow (meta/package-shadow.jsonl) and the lesson shadow (B2,
390
+ * meta/lesson-shadow.jsonl). */
391
+ private appendShadowLedger(file: string, entry: Record<string, unknown>): void {
341
392
  try {
342
- const file = join(resolveBundleRoot(), 'meta', 'package-shadow.jsonl')
343
393
  let lines: string[] = []
344
394
  try {
345
395
  const st = statSync(file)
@@ -353,6 +403,14 @@ export class TopicsService {
353
403
  } catch { /* ledger must never break the lane */ }
354
404
  }
355
405
 
406
+ private packageShadowLedgerPath(): string {
407
+ return join(resolveBundleRoot(), 'meta', 'package-shadow.jsonl')
408
+ }
409
+
410
+ private lessonShadowLedgerPath(): string {
411
+ return join(resolveBundleRoot(), 'meta', 'lesson-shadow.jsonl')
412
+ }
413
+
356
414
  /** Boot-time embedding index maintenance (spec §3: boot 校验+增量重建).
357
415
  * Provider '' or packageMode off → no-op (default-off byte-for-byte).
358
416
  * Full-roster embed on rebuild (A1 §5: ~9.5s for 137 topics). */
@@ -460,11 +518,17 @@ export class TopicsService {
460
518
  * already delivers THIS round is counted as slow-delivered (it entered the
461
519
  * context) but not packed twice.
462
520
  */
463
- retrieveSync(query: string, sessionId?: string, dedup?: { exclude?: ReadonlySet<string>; echo?: ReadonlySet<string> }, slow?: SlowDelivery, slowExpired?: 'ttl' | 'turn-lag'): { text: string; outcome: SearchOutcome; deduped: string[]; echoed: string[]; included: string[]; slowIncluded: string[] } {
521
+ retrieveSync(query: string, sessionId?: string, dedup?: { exclude?: ReadonlySet<string>; echo?: ReadonlySet<string> }, slow?: SlowDelivery, slowExpired?: 'ttl' | 'turn-lag'): { text: string; outcome: SearchOutcome; deduped: string[]; echoed: string[]; included: string[]; slowIncluded: string[]; lessonIncluded: string[] } {
464
522
  const cfg = this.cfg
465
523
  const roster = this.rosterSync()
466
524
  const empty: SearchOutcome = { hits: [], nearMisses: [], rosterSize: roster.length }
467
- if (!cfg.autoInject || query.trim() === '' || roster.length === 0) {
525
+ // Lesson gate (B2): established lessons are INDEPENDENT of the topic
526
+ // roster — a lesson-only bundle still injects lessons. The roster read is
527
+ // shared with the matching block below; gate off → no read at all.
528
+ const lessonGate: 'shadow' | 'on' | undefined =
529
+ cfg.lessonInject === 'shadow' || cfg.lessonInject === 'on' ? cfg.lessonInject : undefined
530
+ const lessonRoster = lessonGate !== undefined ? this.lessonsSync() : []
531
+ if (!cfg.autoInject || query.trim() === '' || (roster.length === 0 && lessonRoster.length === 0)) {
468
532
  // A consumed/expired pending must always leave an ilog trace (ADR 0014:
469
533
  // 过期/消费都写 ilog) — even when the fast path early-returns here
470
534
  // because the roster vanished between production and consumption.
@@ -491,7 +555,7 @@ export class TopicsService {
491
555
  }
492
556
  void this.store.appendInjectionRecord(trace).catch(() => undefined)
493
557
  }
494
- return { text: '', outcome: empty, deduped: [], echoed: [], included: [], slowIncluded: [] }
558
+ return { text: '', outcome: empty, deduped: [], echoed: [], included: [], slowIncluded: [], lessonIncluded: [] }
495
559
  }
496
560
  // A-line gate (A3/A4): query cleaning + package + semantic rerank ride
497
561
  // behind packageMode (default off = byte-for-byte legacy path).
@@ -561,6 +625,60 @@ export class TopicsService {
561
625
  }
562
626
  entries.push({ slug: hit.slug, doc, hit })
563
627
  }
628
+ // ---- Lesson gate (B2, spec 卷一 §6.3): established lessons only (MJ-3:
629
+ // hint never injects), trigger-channel match = 条件命中 (triggers carry
630
+ // the condition), top-1 by score. No hit → no lesson (教训不撒背景网).
631
+ // dedup/echo follow the same rules as topics. ----
632
+ let lessonMatch: { pointer: LessonPointerInput; reasons: string[]; conclusion: string } | undefined
633
+ if (lessonGate !== undefined && lessonRoster.length > 0) {
634
+ const qTokens = new Set(tokenize(effectiveQuery))
635
+ let best: { l: (typeof lessonRoster)[number]; v: ScoredTopic } | undefined
636
+ for (const l of lessonRoster) {
637
+ if (exclude?.has(l.slug)) {
638
+ if (!deduped.includes(l.slug)) deduped.push(l.slug)
639
+ continue
640
+ }
641
+ if (echo?.has(l.slug)) {
642
+ if (!echoed.includes(l.slug)) echoed.push(l.slug)
643
+ continue
644
+ }
645
+ const v = scoreTopic(qTokens, l, {
646
+ threshold: cfg.matchThreshold,
647
+ topK: cfg.topK,
648
+ tagBoost: cfg.tagBoost,
649
+ graphDepth: 0,
650
+ recencyWindowDays: cfg.recencyWindowDays,
651
+ conflicts,
652
+ structuralGate: true,
653
+ usageBoost: cfg.usageBoost,
654
+ usage: this.usageSignalsSync(),
655
+ ...this.zeroOpenCfg(),
656
+ })
657
+ if (!v.reasons.some((r) => r.startsWith('triggers:'))) continue
658
+ if (best === undefined || v.score > best.v.score) best = { l, v }
659
+ }
660
+ if (best !== undefined) {
661
+ lessonMatch = {
662
+ pointer: {
663
+ slug: best.l.slug,
664
+ title: best.l.title,
665
+ status: best.l.status,
666
+ description: best.l.description,
667
+ score: best.v.score,
668
+ source: best.l.source,
669
+ },
670
+ reasons: best.v.reasons,
671
+ conclusion: best.l.conclusion,
672
+ }
673
+ }
674
+ }
675
+ // MJ-C combination matrix: lesson injection does not depend on the A3
676
+ // package gray-release. While the emitted shape is a flat list (digest
677
+ // mode, pointer mode without the package, AND the package-shadow legacy
678
+ // emit), lessons ride it as flat entries with the source marker; only a
679
+ // live package (packageMode on) gives them the direct-layer priority seat.
680
+ const lessonFlat: LessonPointerInput[] | undefined =
681
+ lessonGate === 'on' && lessonMatch !== undefined ? [lessonMatch.pointer] : undefined
564
682
  // ---- Slow-lane merge (log-only shadow gate; consumption bookkeeping) ----
565
683
  const slowPointers: SlowPointerInput[] = []
566
684
  const slowDelivered: SlowItem[] = []
@@ -620,14 +738,14 @@ export class TopicsService {
620
738
  let packageShadow: PackageResult | null | undefined
621
739
  let injection: AssembleResult
622
740
  if (!pointerMode) {
623
- injection = assembleInjection(entries, { perTopicBudget: cfg.perTopicBudget, totalBudget: cfg.totalBudget }, slowPointers)
741
+ injection = assembleInjection(entries, { perTopicBudget: cfg.perTopicBudget, totalBudget: cfg.totalBudget }, slowPointers, lessonFlat)
624
742
  } else if (pkgMode === undefined) {
625
- injection = assemblePointer(entries, { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) }, slowPointers)
743
+ injection = assemblePointer(entries, { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) }, slowPointers, lessonFlat)
626
744
  } else {
627
745
  // A-line relevance package (A3, spec §2): direct = lexical hits,
628
746
  // neighborhood = graph hits + slow picks, weak = reranked band head.
629
747
  const budget = { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) }
630
- const legacy = assemblePointer(entries, budget, slowPointers)
748
+ const legacy = assemblePointer(entries, budget, slowPointers, lessonFlat)
631
749
  const direct: PackageCandidate[] = []
632
750
  const neighborhood: PackageCandidate[] = []
633
751
  for (const e of entries) {
@@ -644,6 +762,23 @@ export class TopicsService {
644
762
  }
645
763
  ;(e.hit.viaGraph ? neighborhood : direct).push(cand)
646
764
  }
765
+ // Lesson priority seat (B2, spec 卷一 §6.3): 主层 ≤2 席中命中 lesson
766
+ // 至少 1 席 — sorts first and is exempt from the direct-seat cap
767
+ // (budget-bound). Only on the LIVE package leg; the legacy emit above
768
+ // carries the flat lesson entry instead (MJ-C).
769
+ if (lessonFlat !== undefined && lessonMatch !== undefined) {
770
+ direct.unshift({
771
+ slug: lessonMatch.pointer.slug,
772
+ title: lessonMatch.pointer.title,
773
+ status: lessonMatch.pointer.status,
774
+ description: lessonMatch.pointer.description,
775
+ conclusion: firstContentLine(lessonMatch.conclusion),
776
+ score: lessonMatch.pointer.score,
777
+ reasons: lessonMatch.reasons,
778
+ prioritySeat: true,
779
+ sourceTag: lessonSourceTag(lessonMatch.pointer.source),
780
+ })
781
+ }
647
782
  for (const sp of slowPointers) {
648
783
  neighborhood.push({ slug: sp.slug, title: sp.title, status: sp.status, description: sp.description, score: 0, reasons: [], via: 'slow-pick' })
649
784
  }
@@ -680,6 +815,14 @@ export class TopicsService {
680
815
  for (const pointer of slowPointers) {
681
816
  if (slowIncluded.includes(pointer.slug)) slowDelivered.push({ slug: pointer.slug, why: pointer.why })
682
817
  }
818
+ // ---- Lesson gate accounting (B2): what entered the context, the shadow
819
+ // bypass ledger (meta/lesson-shadow.jsonl — lesson seat vs no-lesson
820
+ // emitted disposition, same discipline as the package shadow), and the
821
+ // record fields (absent when the gate is off or nothing matched). ----
822
+ const lessonIncluded: string[] =
823
+ lessonGate === 'on' && lessonMatch !== undefined
824
+ ? injection.lessonIncluded ?? (injection.included.includes(lessonMatch.pointer.slug) ? [lessonMatch.pointer.slug] : [])
825
+ : []
683
826
  const record: InjectionRecord = {
684
827
  at: new Date().toISOString(),
685
828
  queryTokenCount: tokenize(effectiveQuery).length,
@@ -700,7 +843,7 @@ export class TopicsService {
700
843
  record.dropped = [...(record.dropped ?? []), ...unreadable.map((slug) => ({ slug, reason: 'doc-unreadable' }))]
701
844
  }
702
845
  if (pkgMode === 'shadow' && pointerMode) {
703
- this.appendPackageShadowLedger({
846
+ this.appendShadowLedger(this.packageShadowLedgerPath(), {
704
847
  at: record.at,
705
848
  ...(sessionId !== undefined ? { sessionId } : {}),
706
849
  queryTokenCount: record.queryTokenCount,
@@ -714,6 +857,37 @@ export class TopicsService {
714
857
  nearFloorRerank: nearFloorRerank ?? null,
715
858
  })
716
859
  }
860
+ if (lessonGate === 'shadow' && lessonMatch !== undefined) {
861
+ const rendered = lessonEntryText(lessonMatch.pointer, POINTER_PER_TOPIC)
862
+ const budgetTotal = pointerMode ? Math.min(cfg.totalBudget, POINTER_TOTAL) : cfg.totalBudget
863
+ this.appendShadowLedger(this.lessonShadowLedgerPath(), {
864
+ at: record.at,
865
+ ...(sessionId !== undefined ? { sessionId } : {}),
866
+ queryTokenCount: record.queryTokenCount,
867
+ lessonInject: 'shadow',
868
+ lessons: [
869
+ {
870
+ slug: lessonMatch.pointer.slug,
871
+ score: lessonMatch.pointer.score,
872
+ source: lessonMatch.pointer.source,
873
+ entryTokens: rendered.usedTokens,
874
+ wouldFit: injection.usedTokens + rendered.usedTokens <= budgetTotal,
875
+ },
876
+ ],
877
+ emitted: { tokens: injection.usedTokens, included: injection.included },
878
+ })
879
+ }
880
+ if (lessonGate !== undefined && lessonMatch !== undefined) {
881
+ record.lessonInject = lessonGate
882
+ record.lessons = [
883
+ {
884
+ slug: lessonMatch.pointer.slug,
885
+ score: lessonMatch.pointer.score,
886
+ source: lessonMatch.pointer.source,
887
+ included: lessonIncluded.length > 0,
888
+ },
889
+ ]
890
+ }
717
891
  if (slow !== undefined) {
718
892
  if (slow.outcome !== undefined) record.slowOutcome = slow.outcome
719
893
  // Any round whose pending was consumed is a slow-participating round —
@@ -746,7 +920,7 @@ export class TopicsService {
746
920
  if (slowExpired !== undefined && injection.text === '') record.lane = 'slow'
747
921
  if (injection.dropped.length > 0) record.dropped = [...(record.dropped ?? []), ...injection.dropped]
748
922
  void this.store.appendInjectionRecord(record).catch(() => undefined)
749
- return { text: injection.text, outcome, deduped, echoed, included: injection.included, slowIncluded }
923
+ return { text: injection.text, outcome, deduped, echoed, included: injection.included, slowIncluded, lessonIncluded }
750
924
  }
751
925
 
752
926
  /**
@@ -860,6 +1034,9 @@ export class TopicsService {
860
1034
  const doc = entry.doc
861
1035
  // Same retirement rule as roster(): deprecated knowledge never injects.
862
1036
  if (doc.fm.status === 'deprecated') continue
1037
+ // Same lesson separation as roster() (B2): lessons inject only through
1038
+ // the lessonInject gate, never the topic lane.
1039
+ if (okf.topicKindOf(doc.fm.type) === 'lesson') continue
863
1040
  out.push({
864
1041
  slug: entry.slug,
865
1042
  title: doc.fm.title,
@@ -876,6 +1053,69 @@ export class TopicsService {
876
1053
  return out
877
1054
  }
878
1055
 
1056
+ /** Established, scope-passing lesson entries (B2, spec 卷一 §6.3) — sync,
1057
+ * sharing the roster's mtime cache. Injection reads ONLY established
1058
+ * lessons here (MJ-3: hint 仅供审批队列展示,永不注入); scope filter =
1059
+ * workspace lessons must carry the current workspace id (MJ-2); scope=global
1060
+ * and legacy entries without a workspaceId pass (fail-open — the filter's
1061
+ * default is permissive, the confidence gate is the hard one). */
1062
+ lessonsSync(): (RetrievableTopic & { confidence: okf.LessonConfidence; source: okf.LessonSource; scope: okf.LessonScope; workspaceId?: string })[] {
1063
+ const ws = currentWorkspaceId()
1064
+ let files: string[]
1065
+ try {
1066
+ files = readdirSync(this.store.topicsDir())
1067
+ } catch {
1068
+ return []
1069
+ }
1070
+ const out: (RetrievableTopic & { confidence: okf.LessonConfidence; source: okf.LessonSource; scope: okf.LessonScope; workspaceId?: string })[] = []
1071
+ for (const file of files) {
1072
+ if (!file.endsWith('.md') || file === 'index.md') continue
1073
+ const path = join(this.store.topicsDir(), file)
1074
+ let st
1075
+ try {
1076
+ st = statSync(path)
1077
+ } catch {
1078
+ continue
1079
+ }
1080
+ const cached = this.cache.get(file)
1081
+ let entry = cached
1082
+ if (cached === undefined || cached.mtimeMs !== st.mtimeMs || cached.size !== st.size) {
1083
+ try {
1084
+ const doc = okf.parseTopicDoc(readFileSync(path, 'utf8'))
1085
+ entry = { mtimeMs: st.mtimeMs, size: st.size, doc, slug: file.slice(0, -3) }
1086
+ this.cache.set(file, entry)
1087
+ } catch {
1088
+ this.cache.delete(file)
1089
+ continue
1090
+ }
1091
+ }
1092
+ if (entry === undefined) continue
1093
+ const fm = entry.doc.fm
1094
+ if (okf.topicKindOf(fm.type) !== 'lesson') continue
1095
+ // Confidence 事实源:established ⇄ stable;deprecated 已被上面的通用
1096
+ // 退休规则挡住,这里再兜一层(hint↔draft 永不注入)。
1097
+ if (fm.confidence !== 'established' || fm.status === 'deprecated') continue
1098
+ if (fm.scope === 'workspace' && fm.workspaceId !== undefined && fm.workspaceId !== ws) continue
1099
+ out.push({
1100
+ slug: entry.slug,
1101
+ title: fm.title,
1102
+ description: fm.description,
1103
+ status: fm.status,
1104
+ tags: fm.tags,
1105
+ triggers: fm.triggers,
1106
+ depends: fm.depends,
1107
+ links: okf.bodyLinkSlugs(entry.doc.body),
1108
+ generatedAt: fm.generated.at,
1109
+ conclusion: okf.sectionOf(entry.doc.body, okf.CONCLUSION_HEADING) ?? '',
1110
+ confidence: fm.confidence,
1111
+ source: fm.source ?? 'manual',
1112
+ scope: fm.scope ?? 'workspace',
1113
+ ...(fm.workspaceId !== undefined ? { workspaceId: fm.workspaceId } : {}),
1114
+ })
1115
+ }
1116
+ return out
1117
+ }
1118
+
879
1119
  private readDocSync(slug: string): okf.TopicDoc | undefined {
880
1120
  try {
881
1121
  return okf.parseTopicDoc(readFileSync(join(this.store.topicsDir(), `${slug}.md`), 'utf8'))
@@ -892,7 +1132,11 @@ export class TopicsService {
892
1132
  }
893
1133
  }
894
1134
 
895
- /** topic_save tool path: create or update with actor stamping. */
1135
+ /** topic_save tool path: create or update with actor stamping. `kind:
1136
+ * 'lesson'` (B2 data plane) switches the OKF type to Lesson and stamps the
1137
+ * lesson frontmatter (scope/confidence/source/workspaceId) with the
1138
+ * confidence↔status derivation (hint↔draft, established↔stable —
1139
+ * confidence is the fact source). */
896
1140
  async saveTopic(input: {
897
1141
  title: string
898
1142
  description?: string
@@ -908,6 +1152,12 @@ export class TopicsService {
908
1152
  recommendations?: string
909
1153
  source?: 'model' | 'distill'
910
1154
  slug?: string
1155
+ /** B2 lesson data plane: entry kind (default topic). */
1156
+ kind?: okf.TopicKind
1157
+ scope?: okf.LessonScope
1158
+ confidence?: okf.LessonConfidence
1159
+ /** Lesson provenance (input.source above is the tool-path tag). */
1160
+ lessonSource?: okf.LessonSource
911
1161
  }): Promise<SaveResult & { slug: string }> {
912
1162
  const now = new Date().toISOString()
913
1163
  const existing = input.slug !== undefined ? await this.store.readTopic(input.slug) : undefined
@@ -922,9 +1172,10 @@ export class TopicsService {
922
1172
  }
923
1173
  const baseSlug = input.slug ?? okf.slugify(input.title)
924
1174
  const slug = existing === undefined ? await this.store.uniqueSlug(baseSlug) : okf.slugify(input.slug ?? baseSlug)
1175
+ const isLesson = input.kind === 'lesson' || (existing !== undefined && okf.topicKindOf(existing.fm.type) === 'lesson')
925
1176
  // store.saveTopic stamps generated {by, at} itself (write-through actor).
926
1177
  const fm: okf.TopicFrontmatter = {
927
- type: 'Topic',
1178
+ type: isLesson ? okf.kindTypeValue('lesson') : okf.kindTypeValue('topic'),
928
1179
  title: input.title,
929
1180
  tags: dedupeLower(input.tags ?? existing?.fm.tags ?? []),
930
1181
  // normalizeDependsEntry, never bare slugToPath: the preserved-on-update
@@ -942,10 +1193,33 @@ export class TopicsService {
942
1193
  // the stored list survives (same preserve-on-update semantics as tags).
943
1194
  const triggers = sanitizeTriggers(input.triggers ?? existing?.fm.triggers ?? [])
944
1195
  if (triggers.length > 0) fm.triggers = triggers
1196
+ if (isLesson) {
1197
+ // B2 frontmatter (spec 卷一 §6.2): preserve-on-update like tags; the
1198
+ // workspace id is stamped AT WRITE TIME (MJ-2, cwd hash) and survives
1199
+ // updates; confidence is the FACT source → status derives from it,
1200
+ // except a deprecated lesson stays deprecated unless confidence is
1201
+ // explicitly re-driven (no silent revival through topic_save).
1202
+ fm.scope = input.scope ?? existing?.fm.scope ?? 'workspace'
1203
+ fm.confidence = input.confidence ?? existing?.fm.confidence ?? 'hint'
1204
+ fm.source = input.lessonSource ?? existing?.fm.source ?? 'manual'
1205
+ if (fm.scope === 'workspace') {
1206
+ const wsid = existing?.fm.workspaceId ?? currentWorkspaceId()
1207
+ if (wsid !== undefined) fm.workspaceId = wsid
1208
+ } else {
1209
+ delete fm.workspaceId
1210
+ }
1211
+ fm.status =
1212
+ existing?.fm.status === 'deprecated' && input.confidence === undefined
1213
+ ? 'deprecated'
1214
+ : okf.deriveLessonStatus(fm.confidence)
1215
+ }
945
1216
  if (existing !== undefined && existing.fm.verified !== undefined) fm.verified = existing.fm.verified
946
1217
  const result = await this.store.saveTopic(
947
1218
  { slug, doc: { fm, body }, links: input.links },
948
- { message: existing === undefined ? `topics(topic): create ${slug}` : `topics(topic): update ${slug}`, created: existing === undefined },
1219
+ {
1220
+ message: existing === undefined ? `topics(${isLesson ? 'lesson' : 'topic'}): create ${slug}` : `topics(${isLesson ? 'lesson' : 'topic'}): update ${slug}`,
1221
+ created: existing === undefined,
1222
+ },
949
1223
  )
950
1224
  this.invalidate()
951
1225
  void this.sync?.schedulePush()
@@ -956,6 +1230,132 @@ export class TopicsService {
956
1230
  return this.store.appendObservation({ kind: input.kind, source: input.source ?? 'model', text: input.text, sessionId: input.sessionId })
957
1231
  }
958
1232
 
1233
+ // -------------------------------------------------------------------------
1234
+ // Dual-write seams (卷二 §6.2 MJ-5) — the new repo's only write surface.
1235
+ // Both run behind the store lock / index recompute; both audit the caller
1236
+ // identity (opts.actor REQUIRED — a blank actor refuses the whole call and
1237
+ // lands a `seam-*` audit row) into meta/pipeline-events.jsonl.
1238
+ // -------------------------------------------------------------------------
1239
+
1240
+ /**
1241
+ * ① 观察投喂: structured observations (B1 §2.1 whitelist, FEATURES ONLY —
1242
+ * 对话原文禁入, 红线⑤) appended to the observations stream with the `fed`
1243
+ * provenance marker and `kind: 'signal'`; each line carries `by: actor`.
1244
+ * The whitelist copy drops unknown caller fields, caps detail entries and
1245
+ * string sizes, and renders a sorted feature line as the observation text.
1246
+ * Duplicates (same type+session+detail in the recent tail) are counted, not
1247
+ * appended (重复观察由原仓去重). Fail-soft: one malformed entry is counted
1248
+ * as rejected, never sinks the batch.
1249
+ */
1250
+ async feedObservations(obs: readonly StructuredObservation[], opts: { actor: string }): Promise<FeedResult> {
1251
+ const actor = typeof opts?.actor === 'string' ? opts.actor.trim() : ''
1252
+ const total = Array.isArray(obs) ? obs.length : 0
1253
+ if (actor === '') {
1254
+ await recordPipelineEvent(this.store.root, {
1255
+ at: new Date().toISOString(),
1256
+ kind: 'seam-observations-fed',
1257
+ detail: { actor: '<missing>', accepted: 0, duplicates: 0, rejected: total, refused: true },
1258
+ })
1259
+ return { accepted: 0, duplicates: 0, rejected: total, ids: [], error: 'unauthorized' }
1260
+ }
1261
+ const accepted: string[] = []
1262
+ let duplicates = 0
1263
+ let rejected = 0
1264
+ // Dedup window: the recent observation tail — the signal layer may retry
1265
+ // on its own fail-open local queue (spec 卷二 §2.1) and identical feature
1266
+ // tuples must not double-distill.
1267
+ const recent = await this.store.allObservations(500)
1268
+ const seenKeys = new Set(recent.map((o) => observationKey(o.kind, o.source, o.sessionId, o.text)))
1269
+ for (const raw of obs) {
1270
+ const clean = sanitizeStructuredObservation(raw)
1271
+ if (clean === undefined) {
1272
+ rejected += 1
1273
+ continue
1274
+ }
1275
+ const text = renderFeatureLine(clean)
1276
+ const key = observationKey('signal', 'fed', clean.sessionId, text)
1277
+ if (seenKeys.has(key)) {
1278
+ duplicates += 1
1279
+ continue
1280
+ }
1281
+ seenKeys.add(key)
1282
+ const saved = await this.store.appendObservation({
1283
+ kind: 'signal',
1284
+ source: 'fed',
1285
+ text,
1286
+ sessionId: clean.sessionId,
1287
+ by: actor,
1288
+ })
1289
+ accepted.push(saved.id)
1290
+ }
1291
+ await recordPipelineEvent(this.store.root, {
1292
+ at: new Date().toISOString(),
1293
+ kind: 'seam-observations-fed',
1294
+ detail: { actor, accepted: accepted.length, duplicates, rejected },
1295
+ })
1296
+ return { accepted: accepted.length, duplicates, rejected, ids: accepted }
1297
+ }
1298
+
1299
+ /**
1300
+ * ② lesson 状态变更 (B2/卷二 §6.2): hint→established / →deprecated /
1301
+ * →removed. established flips confidence (the FACT source) and derives
1302
+ * status (established↔stable); deprecated flips status only (the TTL sweep
1303
+ * owns the rest); removed = archive FIRST (byte-exact copy to
1304
+ * `archive/lessons/` inside the bundle, git-revertable — 红线④) then out of
1305
+ * the roster, one commit. Each call lands a `seam-lesson-status` audit row.
1306
+ */
1307
+ async setLessonStatus(slug: string, to: 'established' | 'deprecated' | 'removed', opts: { actor: string }): Promise<StoreResult> {
1308
+ const actor = typeof opts?.actor === 'string' ? opts.actor.trim() : ''
1309
+ const clean = okf.slugify(slug ?? '')
1310
+ const audit = async (ok: boolean, reason?: string, extra?: Record<string, string | number | boolean>): Promise<void> => {
1311
+ await recordPipelineEvent(this.store.root, {
1312
+ at: new Date().toISOString(),
1313
+ kind: 'seam-lesson-status',
1314
+ detail: { actor: actor === '' ? '<missing>' : actor, slug: clean, to, ok, ...(reason !== undefined ? { reason } : {}), ...(extra ?? {}) },
1315
+ })
1316
+ }
1317
+ if (actor === '') {
1318
+ await audit(false, 'unauthorized')
1319
+ return { ok: false, slug: clean, reason: 'unauthorized' }
1320
+ }
1321
+ if (to !== 'established' && to !== 'deprecated' && to !== 'removed') {
1322
+ await audit(false, 'invalid-state')
1323
+ return { ok: false, slug: clean, reason: 'invalid-state' }
1324
+ }
1325
+ const doc = await this.store.readTopic(clean).catch(() => undefined)
1326
+ if (doc === undefined) {
1327
+ await audit(false, 'not-found')
1328
+ return { ok: false, slug: clean, reason: 'not-found' }
1329
+ }
1330
+ if (okf.topicKindOf(doc.fm.type) !== 'lesson') {
1331
+ await audit(false, 'not-a-lesson')
1332
+ return { ok: false, slug: clean, reason: 'not-a-lesson' }
1333
+ }
1334
+ const from = doc.fm.confidence ?? 'hint'
1335
+ if (to === 'removed') {
1336
+ const r = await this.store.archiveLesson(clean, {
1337
+ message: `topics(lesson): remove ${clean} (archive-first, by ${actor})`,
1338
+ })
1339
+ if (r === undefined) {
1340
+ await audit(false, 'not-found')
1341
+ return { ok: false, slug: clean, reason: 'not-found' }
1342
+ }
1343
+ await audit(true, undefined, { archivedTo: r.archivedTo, committed: r.committed })
1344
+ this.invalidate()
1345
+ return { ok: true, slug: clean, from, archivedTo: r.archivedTo, committed: r.committed }
1346
+ }
1347
+ if (to === 'established') doc.fm.confidence = 'established'
1348
+ doc.fm.status = to === 'established' ? okf.deriveLessonStatus('established') : 'deprecated'
1349
+ const result = await this.store.saveTopic(
1350
+ { slug: clean, doc },
1351
+ { message: `topics(lesson): ${to} ${clean} (by ${actor})`, actor },
1352
+ )
1353
+ this.invalidate()
1354
+ void this.sync?.schedulePush()
1355
+ await audit(true, undefined, { committed: result.committed })
1356
+ return { ok: true, slug: clean, from, committed: result.committed }
1357
+ }
1358
+
959
1359
  async search(query: string, topK?: number): Promise<SearchOutcome> {
960
1360
  const cfg = this.cfg
961
1361
  return searchTopics(query, await this.roster(), {
@@ -1003,6 +1403,61 @@ function inclusionDropped(injection: AssembleResult): boolean {
1003
1403
  return injection.dropped.length > 0
1004
1404
  }
1005
1405
 
1406
+ // --- feedObservations whitelist machinery (B-line seam ①) -------------------
1407
+ // jev/log REDACTION + pipeline-events discipline: whitelist copy on write,
1408
+ // bounded scalar features only — 对话原文(tool arguments、message content)
1409
+ // 结构上进不来(红线⑤)。
1410
+
1411
+ const SEAM_TYPE_MAX_CHARS = 64
1412
+ const SEAM_SESSION_MAX_CHARS = 128
1413
+ const SEAM_DETAIL_MAX_ENTRIES = 16
1414
+ const SEAM_DETAIL_KEY_MAX_CHARS = 64
1415
+ const SEAM_DETAIL_VALUE_MAX_CHARS = 256
1416
+
1417
+ /** Whitelist copy of one structured observation; undefined = reject. A raw
1418
+ * entry whose detail existed but sanitizes to EMPTY (object/array smuggle,
1419
+ * garbage values) is rejected outright — a type with no survivable features
1420
+ * is a malformed signal, not a featureless one. */
1421
+ function sanitizeStructuredObservation(raw: StructuredObservation): StructuredObservation | undefined {
1422
+ if (raw === null || typeof raw !== 'object') return undefined
1423
+ if (typeof raw.type !== 'string' || raw.type.trim() === '') return undefined
1424
+ const out: StructuredObservation = { type: raw.type.trim().slice(0, SEAM_TYPE_MAX_CHARS) }
1425
+ if (typeof raw.at === 'string' && raw.at !== '') out.at = raw.at
1426
+ if (typeof raw.sessionId === 'string' && raw.sessionId !== '') out.sessionId = raw.sessionId.slice(0, SEAM_SESSION_MAX_CHARS)
1427
+ const hadDetail = raw.detail !== undefined && typeof raw.detail === 'object' && !Array.isArray(raw.detail)
1428
+ if (hadDetail) {
1429
+ const detail: Record<string, string | number | boolean> = {}
1430
+ for (const [k, v] of Object.entries(raw.detail as Record<string, unknown>)) {
1431
+ if (Object.keys(detail).length >= SEAM_DETAIL_MAX_ENTRIES) break
1432
+ if (typeof v === 'string') {
1433
+ if (v !== '') detail[k.slice(0, SEAM_DETAIL_KEY_MAX_CHARS)] = v.slice(0, SEAM_DETAIL_VALUE_MAX_CHARS)
1434
+ } else if (typeof v === 'number') {
1435
+ if (Number.isFinite(v)) detail[k.slice(0, SEAM_DETAIL_KEY_MAX_CHARS)] = v
1436
+ } else if (typeof v === 'boolean') {
1437
+ detail[k.slice(0, SEAM_DETAIL_KEY_MAX_CHARS)] = v
1438
+ }
1439
+ }
1440
+ if (Object.keys(detail).length === 0) return undefined
1441
+ out.detail = detail
1442
+ }
1443
+ return out
1444
+ }
1445
+
1446
+ /** Sorted feature line as the observation text: `[type] k=v k=v` — stable
1447
+ * (dedup-keyable), feature-only, bounded. */
1448
+ function renderFeatureLine(o: StructuredObservation): string {
1449
+ const parts = Object.keys(o.detail ?? {})
1450
+ .sort()
1451
+ .map((k) => `${k}=${String((o.detail as Record<string, string | number | boolean>)[k])}`)
1452
+ return `[${o.type}]${parts.length > 0 ? ` ${parts.join(' ')}` : ''}`.slice(0, 512)
1453
+ }
1454
+
1455
+ /** Dedup key over the stored observation identity (kind/source/session/feature
1456
+ * line) — candidates key the same way they are stored. */
1457
+ function observationKey(kind: string, source: string, sessionId: string | undefined, text: string): string {
1458
+ return `${kind}|${source}|${sessionId ?? ''}|${text}`
1459
+ }
1460
+
1006
1461
  function firstLine(text: string): string {
1007
1462
  return firstParagraphOf(text)
1008
1463
  }