@aiwayds/dsh-topics-memory 0.22.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/lib/config.d.ts +55 -1
- package/lib/config.js +42 -0
- package/lib/digest.d.ts +111 -5
- package/lib/digest.js +437 -11
- 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 +30 -0
- package/lib/index.d.ts +15 -1
- package/lib/index.js +24 -6
- package/lib/okf.d.ts +34 -0
- package/lib/okf.js +41 -0
- package/lib/paths.d.ts +9 -0
- package/lib/paths.js +15 -0
- package/lib/pipeline-events.d.ts +38 -0
- package/lib/pipeline-events.js +123 -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 +103 -2
- package/lib/service.js +623 -16
- package/lib/store.d.ts +39 -3
- package/lib/store.js +60 -2
- package/package.json +2 -2
- package/src/config.ts +49 -0
- package/src/digest.ts +497 -8
- package/src/distill.ts +275 -14
- package/src/embedding.ts +294 -0
- package/src/ilog.ts +26 -0
- package/src/index.ts +32 -5
- package/src/okf.ts +68 -0
- package/src/paths.ts +15 -0
- package/src/pipeline-events.ts +136 -0
- package/src/quality.ts +145 -21
- package/src/retrieval.ts +335 -0
- package/src/service.ts +661 -19
- package/src/store.ts +77 -5
package/src/service.ts
CHANGED
|
@@ -7,14 +7,17 @@
|
|
|
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, 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
|
+
import { embedTexts, embedQuerySync, embedderWith, resolveEmbedModel } from './embedding.ts'
|
|
16
|
+
import { currentWorkspaceId, 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'
|
|
20
|
+
import { recordPipelineEvent } from './pipeline-events.ts'
|
|
18
21
|
import { fileHistory, fileAtRev } from './git.ts'
|
|
19
22
|
import type { Sync } from './sync.ts'
|
|
20
23
|
|
|
@@ -75,6 +78,51 @@ export interface SlowDelivery {
|
|
|
75
78
|
queryBuild: QueryBuildShape
|
|
76
79
|
model: string
|
|
77
80
|
ms: number
|
|
81
|
+
/** Producing run's disposition (A4 全留痕) — copied into ilog.slowOutcome. */
|
|
82
|
+
outcome?: SlowOutcome
|
|
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
|
|
78
126
|
}
|
|
79
127
|
|
|
80
128
|
export class TopicsService {
|
|
@@ -151,6 +199,11 @@ export class TopicsService {
|
|
|
151
199
|
// depends-graph walk, or a merged-away entry would keep competing
|
|
152
200
|
// with its survivor for the injection budget.
|
|
153
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
|
|
154
207
|
out.push({
|
|
155
208
|
slug: entry.slug,
|
|
156
209
|
title: doc.fm.title,
|
|
@@ -310,6 +363,79 @@ export class TopicsService {
|
|
|
310
363
|
return { zeroOpenDecay: true, zeroOpenSlugs: this.zeroOpenSlugsSync() }
|
|
311
364
|
}
|
|
312
365
|
|
|
366
|
+
/** mtime-cached view of meta/embeddings.json (sync read — hot-lane caller).
|
|
367
|
+
* Missing/corrupt index = {} → the reranker degrades to lexical order. */
|
|
368
|
+
private embedIndexCache: { mtimeMs: number; size: number; index: EmbeddingIndex } | undefined
|
|
369
|
+
|
|
370
|
+
private embeddingIndexCacheGet(): EmbeddingIndex {
|
|
371
|
+
const root = resolveBundleRoot()
|
|
372
|
+
const file = join(root, 'meta', 'embeddings.json')
|
|
373
|
+
try {
|
|
374
|
+
const st = statSync(file)
|
|
375
|
+
if (this.embedIndexCache !== undefined && this.embedIndexCache.mtimeMs === st.mtimeMs && this.embedIndexCache.size === st.size) {
|
|
376
|
+
return this.embedIndexCache.index
|
|
377
|
+
}
|
|
378
|
+
const index = loadEmbeddingIndex(root)
|
|
379
|
+
this.embedIndexCache = { mtimeMs: st.mtimeMs, size: st.size, index }
|
|
380
|
+
return index
|
|
381
|
+
} catch {
|
|
382
|
+
return this.embedIndexCache?.index ?? {}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Shadow bypass ledger (spec §8.3): one line per shadow round — feature
|
|
387
|
+
* fields only, never query text (jev-log discipline). Silent on failure;
|
|
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 {
|
|
392
|
+
try {
|
|
393
|
+
let lines: string[] = []
|
|
394
|
+
try {
|
|
395
|
+
const st = statSync(file)
|
|
396
|
+
if (st.size > 512 * 1024) {
|
|
397
|
+
const all = readFileSync(file, 'utf8').split('\n').filter((l) => l !== '')
|
|
398
|
+
lines = all.slice(Math.floor(all.length / 4))
|
|
399
|
+
}
|
|
400
|
+
} catch { /* first write */ }
|
|
401
|
+
lines.push(JSON.stringify(entry))
|
|
402
|
+
writeFileSync(file, lines.join('\n') + '\n')
|
|
403
|
+
} catch { /* ledger must never break the lane */ }
|
|
404
|
+
}
|
|
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
|
+
|
|
414
|
+
/** Boot-time embedding index maintenance (spec §3: boot 校验+增量重建).
|
|
415
|
+
* Provider '' or packageMode off → no-op (default-off byte-for-byte).
|
|
416
|
+
* Full-roster embed on rebuild (A1 §5: ~9.5s for 137 topics). */
|
|
417
|
+
async refreshEmbeddingIndex(): Promise<void> {
|
|
418
|
+
const provider = (this.cfg.embeddingProvider ?? '').trim()
|
|
419
|
+
if (provider === '' || this.cfg.packageMode === undefined || this.cfg.packageMode === 'off') return
|
|
420
|
+
const embedder = embedderWith({ provider, model: this.cfg.embeddingModel })
|
|
421
|
+
const topics = await this.roster()
|
|
422
|
+
const root = resolveBundleRoot()
|
|
423
|
+
const index = loadEmbeddingIndex(root)
|
|
424
|
+
const stale = ensureIndexFresh(index, topics, embedder)
|
|
425
|
+
if (stale.length === 0) return
|
|
426
|
+
const docs = await Promise.all(stale.map(async (slug) => (await this.readDoc(slug)) ?? ''))
|
|
427
|
+
const texts = stale.map((slug, i) => `${slug}\n${docs[i] ?? ''}`)
|
|
428
|
+
const r = await embedTexts(texts, { provider, model: this.cfg.embeddingModel })
|
|
429
|
+
if (!r.ok) return
|
|
430
|
+
const next: EmbeddingIndex = { ...index }
|
|
431
|
+
stale.forEach((slug, i) => {
|
|
432
|
+
const vec = r.vectors[i]
|
|
433
|
+
if (vec !== undefined) next[slug] = { model: r.model, dim: vec.length, vec }
|
|
434
|
+
})
|
|
435
|
+
saveEmbeddingIndex(root, next)
|
|
436
|
+
this.embedIndexCache = undefined
|
|
437
|
+
}
|
|
438
|
+
|
|
313
439
|
/**
|
|
314
440
|
* Full retrieval round: search → assemble → log (ADR 0006/0007). The
|
|
315
441
|
* injection record is written even for zero-hit rounds — the hit-rate
|
|
@@ -392,11 +518,17 @@ export class TopicsService {
|
|
|
392
518
|
* already delivers THIS round is counted as slow-delivered (it entered the
|
|
393
519
|
* context) but not packed twice.
|
|
394
520
|
*/
|
|
395
|
-
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[] } {
|
|
396
522
|
const cfg = this.cfg
|
|
397
523
|
const roster = this.rosterSync()
|
|
398
524
|
const empty: SearchOutcome = { hits: [], nearMisses: [], rosterSize: roster.length }
|
|
399
|
-
|
|
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)) {
|
|
400
532
|
// A consumed/expired pending must always leave an ilog trace (ADR 0014:
|
|
401
533
|
// 过期/消费都写 ilog) — even when the fast path early-returns here
|
|
402
534
|
// because the roster vanished between production and consumption.
|
|
@@ -423,10 +555,15 @@ export class TopicsService {
|
|
|
423
555
|
}
|
|
424
556
|
void this.store.appendInjectionRecord(trace).catch(() => undefined)
|
|
425
557
|
}
|
|
426
|
-
return { text: '', outcome: empty, deduped: [], echoed: [], included: [], slowIncluded: [] }
|
|
558
|
+
return { text: '', outcome: empty, deduped: [], echoed: [], included: [], slowIncluded: [], lessonIncluded: [] }
|
|
427
559
|
}
|
|
560
|
+
// A-line gate (A3/A4): query cleaning + package + semantic rerank ride
|
|
561
|
+
// behind packageMode (default off = byte-for-byte legacy path).
|
|
562
|
+
const pkgMode: 'shadow' | 'on' | undefined =
|
|
563
|
+
cfg.packageMode === 'shadow' || cfg.packageMode === 'on' ? cfg.packageMode : undefined
|
|
564
|
+
const effectiveQuery = pkgMode !== undefined ? sanitizeQuery(query) : query
|
|
428
565
|
const conflicts = this.store.getConflictsSync()
|
|
429
|
-
const outcome = searchTopics(
|
|
566
|
+
const outcome = searchTopics(effectiveQuery, roster, {
|
|
430
567
|
threshold: cfg.matchThreshold,
|
|
431
568
|
topK: cfg.topK,
|
|
432
569
|
tagBoost: cfg.tagBoost,
|
|
@@ -437,6 +574,35 @@ export class TopicsService {
|
|
|
437
574
|
usage: this.usageSignalsSync(),
|
|
438
575
|
...this.zeroOpenCfg(),
|
|
439
576
|
})
|
|
577
|
+
// ---- A-line semantic rerank (A4 决议 2, spec §3.1) ----
|
|
578
|
+
// Reorder ONLY the 0.20–0.30 nearFloor band (the 93% near-miss mass);
|
|
579
|
+
// thresholds/gateScore/hits untouched. Any failure degrades to the
|
|
580
|
+
// original order — the fast lane never blocks on the semantic layer.
|
|
581
|
+
let nearFloorRerank: { reranked: boolean; passthroughReason?: string; orderBefore: string[]; orderAfter: string[] } | undefined
|
|
582
|
+
if (pkgMode !== undefined) {
|
|
583
|
+
const bandLo = 0.2
|
|
584
|
+
const band = outcome.nearMisses.filter((h) => h.score >= bandLo && h.score < cfg.matchThreshold)
|
|
585
|
+
const orderBefore = band.map((h) => h.slug)
|
|
586
|
+
if (band.length > 1) {
|
|
587
|
+
const index = this.embeddingIndexCacheGet()
|
|
588
|
+
const models = new Set(Object.values(index).map((e) => e.model))
|
|
589
|
+
const embedModel = resolveEmbedModel(cfg.embeddingModel)
|
|
590
|
+
if (models.size <= 1 && (models.size === 0 || models.has(embedModel))) {
|
|
591
|
+
const qv = embedQuerySync(effectiveQuery, { provider: cfg.embeddingProvider, model: cfg.embeddingModel })
|
|
592
|
+
const rr = rerankNearFloor(effectiveQuery, qv.ok ? qv.vector : null, band, index)
|
|
593
|
+
if (rr.reranked) {
|
|
594
|
+
const inBand = (h: { score: number }) => h.score >= bandLo && h.score < cfg.matchThreshold
|
|
595
|
+
const rrSlugs = new Set(rr.hits.map((h) => h.slug))
|
|
596
|
+
const merged = [...rr.hits, ...band.filter((h) => !rrSlugs.has(h.slug))]
|
|
597
|
+
let mi = 0
|
|
598
|
+
outcome.nearMisses = outcome.nearMisses.map((h) => (inBand(h) ? merged[mi++]! : h))
|
|
599
|
+
nearFloorRerank = { reranked: true, orderBefore, orderAfter: merged.map((h) => h.slug) }
|
|
600
|
+
} else {
|
|
601
|
+
nearFloorRerank = { reranked: false, passthroughReason: rr.passthroughReason, orderBefore, orderAfter: orderBefore }
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
440
606
|
const exclude = dedup?.exclude
|
|
441
607
|
const echo = dedup?.echo
|
|
442
608
|
const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug)
|
|
@@ -459,6 +625,60 @@ export class TopicsService {
|
|
|
459
625
|
}
|
|
460
626
|
entries.push({ slug: hit.slug, doc, hit })
|
|
461
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
|
|
462
682
|
// ---- Slow-lane merge (log-only shadow gate; consumption bookkeeping) ----
|
|
463
683
|
const slowPointers: SlowPointerInput[] = []
|
|
464
684
|
const slowDelivered: SlowItem[] = []
|
|
@@ -466,7 +686,7 @@ export class TopicsService {
|
|
|
466
686
|
let slowIncluded: string[] = []
|
|
467
687
|
const consumedAt = new Date().toISOString()
|
|
468
688
|
if (slow !== undefined) {
|
|
469
|
-
const queryTokens = new Set(tokenize(
|
|
689
|
+
const queryTokens = new Set(tokenize(effectiveQuery))
|
|
470
690
|
for (const item of slow.items) {
|
|
471
691
|
if (exclude?.has(item.slug)) {
|
|
472
692
|
if (!deduped.includes(item.slug)) deduped.push(item.slug)
|
|
@@ -514,17 +734,99 @@ export class TopicsService {
|
|
|
514
734
|
const pointerMode = cfg.injectMode !== 'digest'
|
|
515
735
|
// Pointer budget: per-entry fixed at the design's 80, total clamped to the
|
|
516
736
|
// fast-lane upper bound of 600 (I4) — totalBudget stays tunable downward.
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
737
|
+
let packageSkipped = false
|
|
738
|
+
let packageShadow: PackageResult | null | undefined
|
|
739
|
+
let injection: AssembleResult
|
|
740
|
+
if (!pointerMode) {
|
|
741
|
+
injection = assembleInjection(entries, { perTopicBudget: cfg.perTopicBudget, totalBudget: cfg.totalBudget }, slowPointers, lessonFlat)
|
|
742
|
+
} else if (pkgMode === undefined) {
|
|
743
|
+
injection = assemblePointer(entries, { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) }, slowPointers, lessonFlat)
|
|
744
|
+
} else {
|
|
745
|
+
// A-line relevance package (A3, spec §2): direct = lexical hits,
|
|
746
|
+
// neighborhood = graph hits + slow picks, weak = reranked band head.
|
|
747
|
+
const budget = { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) }
|
|
748
|
+
const legacy = assemblePointer(entries, budget, slowPointers, lessonFlat)
|
|
749
|
+
const direct: PackageCandidate[] = []
|
|
750
|
+
const neighborhood: PackageCandidate[] = []
|
|
751
|
+
for (const e of entries) {
|
|
752
|
+
const meta = bySlug.get(e.slug)
|
|
753
|
+
const cand: PackageCandidate = {
|
|
754
|
+
slug: e.slug,
|
|
755
|
+
title: meta?.title ?? e.slug,
|
|
756
|
+
status: meta?.status ?? 'draft',
|
|
757
|
+
description: meta?.description,
|
|
758
|
+
conclusion: firstContentLine(e.doc.body),
|
|
759
|
+
score: e.hit.score,
|
|
760
|
+
reasons: e.hit.reasons,
|
|
761
|
+
via: e.hit.viaGraph ? 'graph' : undefined,
|
|
762
|
+
}
|
|
763
|
+
;(e.hit.viaGraph ? neighborhood : direct).push(cand)
|
|
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
|
+
}
|
|
782
|
+
for (const sp of slowPointers) {
|
|
783
|
+
neighborhood.push({ slug: sp.slug, title: sp.title, status: sp.status, description: sp.description, score: 0, reasons: [], via: 'slow-pick' })
|
|
784
|
+
}
|
|
785
|
+
const weak = outcome.nearMisses
|
|
786
|
+
.filter((h) => h.score >= 0.2 && h.score < cfg.matchThreshold)
|
|
787
|
+
.slice(0, 2)
|
|
788
|
+
.map((h) => {
|
|
789
|
+
const meta = bySlug.get(h.slug)
|
|
790
|
+
return { slug: h.slug, title: meta?.title ?? h.slug, status: meta?.status ?? 'draft', score: h.score, reasons: h.reasons, via: 'weak' }
|
|
791
|
+
})
|
|
792
|
+
const usage = this.usageSignalsSync()
|
|
793
|
+
const usageBySlug = new Map<string, number>([...usage].map(([slug, sig]) => [slug, sig.opens * 3 + sig.hits]))
|
|
794
|
+
const pkgInput: PackageInput = { query: effectiveQuery, queryTokens: tokenize(effectiveQuery), direct, neighborhood, weak, usageBySlug }
|
|
795
|
+
const pkg = assemblePackage(pkgInput)
|
|
796
|
+
if (pkgMode === 'shadow') {
|
|
797
|
+
packageShadow = pkg
|
|
798
|
+
injection = legacy
|
|
799
|
+
} else if (pkg === null) {
|
|
800
|
+
// Trivial-query downgrade (A3 决议 5): emit nothing, log the why.
|
|
801
|
+
packageSkipped = true
|
|
802
|
+
injection = { text: '', usedTokens: 0, included: [], dropped: [] }
|
|
803
|
+
} else {
|
|
804
|
+
const slowSet = new Set(slowPointers.map((sp) => sp.slug))
|
|
805
|
+
injection = {
|
|
806
|
+
text: pkg.text,
|
|
807
|
+
usedTokens: pkg.usedTokens,
|
|
808
|
+
included: pkg.included,
|
|
809
|
+
dropped: pkg.dropped,
|
|
810
|
+
slowIncluded: pkg.included.filter((slug) => slowSet.has(slug)),
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
520
814
|
slowIncluded = injection.slowIncluded ?? []
|
|
521
815
|
for (const pointer of slowPointers) {
|
|
522
816
|
if (slowIncluded.includes(pointer.slug)) slowDelivered.push({ slug: pointer.slug, why: pointer.why })
|
|
523
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
|
+
: []
|
|
524
826
|
const record: InjectionRecord = {
|
|
525
827
|
at: new Date().toISOString(),
|
|
526
|
-
queryTokenCount: tokenize(
|
|
527
|
-
querySample: querySample(
|
|
828
|
+
queryTokenCount: tokenize(effectiveQuery).length,
|
|
829
|
+
querySample: querySample(effectiveQuery),
|
|
528
830
|
rosterSize: outcome.rosterSize,
|
|
529
831
|
hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph, strong: h.strong, bodyHits: h.bodyHits })),
|
|
530
832
|
// Reasons ride along: the gate-blocked marker is the replay evidence
|
|
@@ -536,10 +838,58 @@ export class TopicsService {
|
|
|
536
838
|
if (sessionId !== undefined) record.sessionId = sessionId
|
|
537
839
|
if (deduped.length > 0) record.deduped = deduped
|
|
538
840
|
if (echoed.length > 0) record.echoed = echoed
|
|
841
|
+
if (packageSkipped) record.why = 'package-skipped-trivial'
|
|
539
842
|
if (unreadable.length > 0) {
|
|
540
843
|
record.dropped = [...(record.dropped ?? []), ...unreadable.map((slug) => ({ slug, reason: 'doc-unreadable' }))]
|
|
541
844
|
}
|
|
845
|
+
if (pkgMode === 'shadow' && pointerMode) {
|
|
846
|
+
this.appendShadowLedger(this.packageShadowLedgerPath(), {
|
|
847
|
+
at: record.at,
|
|
848
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
849
|
+
queryTokenCount: record.queryTokenCount,
|
|
850
|
+
emitted: { tokens: injection.usedTokens, included: injection.included },
|
|
851
|
+
package:
|
|
852
|
+
packageShadow === undefined
|
|
853
|
+
? null
|
|
854
|
+
: packageShadow === null
|
|
855
|
+
? { skipped: 'trivial-or-empty' }
|
|
856
|
+
: { tokens: packageShadow.usedTokens, included: packageShadow.included, layers: packageShadow.layers },
|
|
857
|
+
nearFloorRerank: nearFloorRerank ?? null,
|
|
858
|
+
})
|
|
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
|
+
}
|
|
542
891
|
if (slow !== undefined) {
|
|
892
|
+
if (slow.outcome !== undefined) record.slowOutcome = slow.outcome
|
|
543
893
|
// Any round whose pending was consumed is a slow-participating round —
|
|
544
894
|
// lane never stays undefined when the lane family is present (a
|
|
545
895
|
// delivered pick with fast pointers is 'mixed'; a consumed-but-empty
|
|
@@ -570,7 +920,7 @@ export class TopicsService {
|
|
|
570
920
|
if (slowExpired !== undefined && injection.text === '') record.lane = 'slow'
|
|
571
921
|
if (injection.dropped.length > 0) record.dropped = [...(record.dropped ?? []), ...injection.dropped]
|
|
572
922
|
void this.store.appendInjectionRecord(record).catch(() => undefined)
|
|
573
|
-
return { text: injection.text, outcome, deduped, echoed, included: injection.included, slowIncluded }
|
|
923
|
+
return { text: injection.text, outcome, deduped, echoed, included: injection.included, slowIncluded, lessonIncluded }
|
|
574
924
|
}
|
|
575
925
|
|
|
576
926
|
/**
|
|
@@ -684,6 +1034,9 @@ export class TopicsService {
|
|
|
684
1034
|
const doc = entry.doc
|
|
685
1035
|
// Same retirement rule as roster(): deprecated knowledge never injects.
|
|
686
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
|
|
687
1040
|
out.push({
|
|
688
1041
|
slug: entry.slug,
|
|
689
1042
|
title: doc.fm.title,
|
|
@@ -700,6 +1053,69 @@ export class TopicsService {
|
|
|
700
1053
|
return out
|
|
701
1054
|
}
|
|
702
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
|
+
|
|
703
1119
|
private readDocSync(slug: string): okf.TopicDoc | undefined {
|
|
704
1120
|
try {
|
|
705
1121
|
return okf.parseTopicDoc(readFileSync(join(this.store.topicsDir(), `${slug}.md`), 'utf8'))
|
|
@@ -716,7 +1132,11 @@ export class TopicsService {
|
|
|
716
1132
|
}
|
|
717
1133
|
}
|
|
718
1134
|
|
|
719
|
-
/** 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). */
|
|
720
1140
|
async saveTopic(input: {
|
|
721
1141
|
title: string
|
|
722
1142
|
description?: string
|
|
@@ -725,11 +1145,19 @@ export class TopicsService {
|
|
|
725
1145
|
depends?: string[]
|
|
726
1146
|
openQuestions?: string[]
|
|
727
1147
|
impact?: string[]
|
|
1148
|
+
/** A2 semantic-link targets; store.saveTopic lands them as body wikilinks. */
|
|
1149
|
+
links?: string[]
|
|
728
1150
|
status?: okf.TopicStatus
|
|
729
1151
|
conclusion?: string
|
|
730
1152
|
recommendations?: string
|
|
731
1153
|
source?: 'model' | 'distill'
|
|
732
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
|
|
733
1161
|
}): Promise<SaveResult & { slug: string }> {
|
|
734
1162
|
const now = new Date().toISOString()
|
|
735
1163
|
const existing = input.slug !== undefined ? await this.store.readTopic(input.slug) : undefined
|
|
@@ -744,9 +1172,10 @@ export class TopicsService {
|
|
|
744
1172
|
}
|
|
745
1173
|
const baseSlug = input.slug ?? okf.slugify(input.title)
|
|
746
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')
|
|
747
1176
|
// store.saveTopic stamps generated {by, at} itself (write-through actor).
|
|
748
1177
|
const fm: okf.TopicFrontmatter = {
|
|
749
|
-
type: '
|
|
1178
|
+
type: isLesson ? okf.kindTypeValue('lesson') : okf.kindTypeValue('topic'),
|
|
750
1179
|
title: input.title,
|
|
751
1180
|
tags: dedupeLower(input.tags ?? existing?.fm.tags ?? []),
|
|
752
1181
|
// normalizeDependsEntry, never bare slugToPath: the preserved-on-update
|
|
@@ -764,10 +1193,33 @@ export class TopicsService {
|
|
|
764
1193
|
// the stored list survives (same preserve-on-update semantics as tags).
|
|
765
1194
|
const triggers = sanitizeTriggers(input.triggers ?? existing?.fm.triggers ?? [])
|
|
766
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
|
+
}
|
|
767
1216
|
if (existing !== undefined && existing.fm.verified !== undefined) fm.verified = existing.fm.verified
|
|
768
1217
|
const result = await this.store.saveTopic(
|
|
769
|
-
{ slug, doc: { fm, body } },
|
|
770
|
-
{
|
|
1218
|
+
{ slug, doc: { fm, body }, links: input.links },
|
|
1219
|
+
{
|
|
1220
|
+
message: existing === undefined ? `topics(${isLesson ? 'lesson' : 'topic'}): create ${slug}` : `topics(${isLesson ? 'lesson' : 'topic'}): update ${slug}`,
|
|
1221
|
+
created: existing === undefined,
|
|
1222
|
+
},
|
|
771
1223
|
)
|
|
772
1224
|
this.invalidate()
|
|
773
1225
|
void this.sync?.schedulePush()
|
|
@@ -778,6 +1230,132 @@ export class TopicsService {
|
|
|
778
1230
|
return this.store.appendObservation({ kind: input.kind, source: input.source ?? 'model', text: input.text, sessionId: input.sessionId })
|
|
779
1231
|
}
|
|
780
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
|
+
|
|
781
1359
|
async search(query: string, topK?: number): Promise<SearchOutcome> {
|
|
782
1360
|
const cfg = this.cfg
|
|
783
1361
|
return searchTopics(query, await this.roster(), {
|
|
@@ -825,6 +1403,61 @@ function inclusionDropped(injection: AssembleResult): boolean {
|
|
|
825
1403
|
return injection.dropped.length > 0
|
|
826
1404
|
}
|
|
827
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
|
+
|
|
828
1461
|
function firstLine(text: string): string {
|
|
829
1462
|
return firstParagraphOf(text)
|
|
830
1463
|
}
|
|
@@ -860,6 +1493,15 @@ function dedupePaths(paths: string[]): string[] {
|
|
|
860
1493
|
return out
|
|
861
1494
|
}
|
|
862
1495
|
|
|
1496
|
+
function firstContentLine(doc: string): string {
|
|
1497
|
+
for (const line of doc.split('\n')) {
|
|
1498
|
+
const t = line.trim()
|
|
1499
|
+
if (t === '' || t.startsWith('#')) continue
|
|
1500
|
+
return t.slice(0, 200)
|
|
1501
|
+
}
|
|
1502
|
+
return ''
|
|
1503
|
+
}
|
|
1504
|
+
|
|
863
1505
|
function sanitizeTriggers(triggers: readonly string[]): string[] {
|
|
864
1506
|
const out: string[] = []
|
|
865
1507
|
for (const t of triggers) {
|