@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/lib/service.js
CHANGED
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
* @module service
|
|
7
7
|
*/
|
|
8
8
|
import { readFile, stat } from 'node:fs/promises';
|
|
9
|
-
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
9
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import * as okf from "./okf.js";
|
|
12
|
-
import { searchTopics, scoreTopic, passesGate, tokenize } from "./retrieval.js";
|
|
13
|
-
import { assembleInjection, assemblePointer, POINTER_PER_TOPIC, POINTER_TOTAL } from "./digest.js";
|
|
12
|
+
import { searchTopics, scoreTopic, passesGate, tokenize, sanitizeQuery, loadEmbeddingIndex, rerankNearFloor, ensureIndexFresh, saveEmbeddingIndex } from "./retrieval.js";
|
|
13
|
+
import { assembleInjection, assemblePointer, assemblePackage, lessonEntryText, lessonSourceTag, POINTER_PER_TOPIC, POINTER_TOTAL } from "./digest.js";
|
|
14
|
+
import { embedTexts, embedQuerySync, embedderWith, resolveEmbedModel } from "./embedding.js";
|
|
15
|
+
import { currentWorkspaceId, resolveBundleRoot } from "./paths.js";
|
|
14
16
|
import { aggregateStats, querySample, aggregateUsage, aggregateZeroOpen, ZERO_OPEN_TAIL, USAGE_WINDOW_DAYS, attributeOpen, OPEN_ATTRIBUTION_TAIL } from "./ilog.js";
|
|
17
|
+
import { recordPipelineEvent } from "./pipeline-events.js";
|
|
15
18
|
import { fileHistory, fileAtRev } from "./git.js";
|
|
16
19
|
/** Tolerant JSONL line read: undefined = unreadable file. Unlike an empty
|
|
17
20
|
* array, undefined tells the caller to fail open AND skip caching — a
|
|
@@ -111,6 +114,12 @@ export class TopicsService {
|
|
|
111
114
|
// with its survivor for the injection budget.
|
|
112
115
|
if (doc.fm.status === 'deprecated')
|
|
113
116
|
continue;
|
|
117
|
+
// Lesson-kind entries (B2) never ride the topic pipeline — they have
|
|
118
|
+
// their own gate (lessonInject), confidence rule and scope filter in
|
|
119
|
+
// lessonsSync(). Gate-independent separation: even with lessonInject
|
|
120
|
+
// off, a hint lesson must not leak through the topic lexical lane.
|
|
121
|
+
if (okf.topicKindOf(doc.fm.type) === 'lesson')
|
|
122
|
+
continue;
|
|
114
123
|
out.push({
|
|
115
124
|
slug: entry.slug,
|
|
116
125
|
title: doc.fm.title,
|
|
@@ -269,6 +278,80 @@ export class TopicsService {
|
|
|
269
278
|
return {};
|
|
270
279
|
return { zeroOpenDecay: true, zeroOpenSlugs: this.zeroOpenSlugsSync() };
|
|
271
280
|
}
|
|
281
|
+
/** mtime-cached view of meta/embeddings.json (sync read — hot-lane caller).
|
|
282
|
+
* Missing/corrupt index = {} → the reranker degrades to lexical order. */
|
|
283
|
+
embedIndexCache;
|
|
284
|
+
embeddingIndexCacheGet() {
|
|
285
|
+
const root = resolveBundleRoot();
|
|
286
|
+
const file = join(root, 'meta', 'embeddings.json');
|
|
287
|
+
try {
|
|
288
|
+
const st = statSync(file);
|
|
289
|
+
if (this.embedIndexCache !== undefined && this.embedIndexCache.mtimeMs === st.mtimeMs && this.embedIndexCache.size === st.size) {
|
|
290
|
+
return this.embedIndexCache.index;
|
|
291
|
+
}
|
|
292
|
+
const index = loadEmbeddingIndex(root);
|
|
293
|
+
this.embedIndexCache = { mtimeMs: st.mtimeMs, size: st.size, index };
|
|
294
|
+
return index;
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return this.embedIndexCache?.index ?? {};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/** Shadow bypass ledger (spec §8.3): one line per shadow round — feature
|
|
301
|
+
* fields only, never query text (jev-log discipline). Silent on failure;
|
|
302
|
+
* 512KB truncation keeps the newest three quarters. Shared by the package
|
|
303
|
+
* shadow (meta/package-shadow.jsonl) and the lesson shadow (B2,
|
|
304
|
+
* meta/lesson-shadow.jsonl). */
|
|
305
|
+
appendShadowLedger(file, entry) {
|
|
306
|
+
try {
|
|
307
|
+
let lines = [];
|
|
308
|
+
try {
|
|
309
|
+
const st = statSync(file);
|
|
310
|
+
if (st.size > 512 * 1024) {
|
|
311
|
+
const all = readFileSync(file, 'utf8').split('\n').filter((l) => l !== '');
|
|
312
|
+
lines = all.slice(Math.floor(all.length / 4));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch { /* first write */ }
|
|
316
|
+
lines.push(JSON.stringify(entry));
|
|
317
|
+
writeFileSync(file, lines.join('\n') + '\n');
|
|
318
|
+
}
|
|
319
|
+
catch { /* ledger must never break the lane */ }
|
|
320
|
+
}
|
|
321
|
+
packageShadowLedgerPath() {
|
|
322
|
+
return join(resolveBundleRoot(), 'meta', 'package-shadow.jsonl');
|
|
323
|
+
}
|
|
324
|
+
lessonShadowLedgerPath() {
|
|
325
|
+
return join(resolveBundleRoot(), 'meta', 'lesson-shadow.jsonl');
|
|
326
|
+
}
|
|
327
|
+
/** Boot-time embedding index maintenance (spec §3: boot 校验+增量重建).
|
|
328
|
+
* Provider '' or packageMode off → no-op (default-off byte-for-byte).
|
|
329
|
+
* Full-roster embed on rebuild (A1 §5: ~9.5s for 137 topics). */
|
|
330
|
+
async refreshEmbeddingIndex() {
|
|
331
|
+
const provider = (this.cfg.embeddingProvider ?? '').trim();
|
|
332
|
+
if (provider === '' || this.cfg.packageMode === undefined || this.cfg.packageMode === 'off')
|
|
333
|
+
return;
|
|
334
|
+
const embedder = embedderWith({ provider, model: this.cfg.embeddingModel });
|
|
335
|
+
const topics = await this.roster();
|
|
336
|
+
const root = resolveBundleRoot();
|
|
337
|
+
const index = loadEmbeddingIndex(root);
|
|
338
|
+
const stale = ensureIndexFresh(index, topics, embedder);
|
|
339
|
+
if (stale.length === 0)
|
|
340
|
+
return;
|
|
341
|
+
const docs = await Promise.all(stale.map(async (slug) => (await this.readDoc(slug)) ?? ''));
|
|
342
|
+
const texts = stale.map((slug, i) => `${slug}\n${docs[i] ?? ''}`);
|
|
343
|
+
const r = await embedTexts(texts, { provider, model: this.cfg.embeddingModel });
|
|
344
|
+
if (!r.ok)
|
|
345
|
+
return;
|
|
346
|
+
const next = { ...index };
|
|
347
|
+
stale.forEach((slug, i) => {
|
|
348
|
+
const vec = r.vectors[i];
|
|
349
|
+
if (vec !== undefined)
|
|
350
|
+
next[slug] = { model: r.model, dim: vec.length, vec };
|
|
351
|
+
});
|
|
352
|
+
saveEmbeddingIndex(root, next);
|
|
353
|
+
this.embedIndexCache = undefined;
|
|
354
|
+
}
|
|
272
355
|
/**
|
|
273
356
|
* Full retrieval round: search → assemble → log (ADR 0006/0007). The
|
|
274
357
|
* injection record is written even for zero-hit rounds — the hit-rate
|
|
@@ -358,7 +441,12 @@ export class TopicsService {
|
|
|
358
441
|
const cfg = this.cfg;
|
|
359
442
|
const roster = this.rosterSync();
|
|
360
443
|
const empty = { hits: [], nearMisses: [], rosterSize: roster.length };
|
|
361
|
-
|
|
444
|
+
// Lesson gate (B2): established lessons are INDEPENDENT of the topic
|
|
445
|
+
// roster — a lesson-only bundle still injects lessons. The roster read is
|
|
446
|
+
// shared with the matching block below; gate off → no read at all.
|
|
447
|
+
const lessonGate = cfg.lessonInject === 'shadow' || cfg.lessonInject === 'on' ? cfg.lessonInject : undefined;
|
|
448
|
+
const lessonRoster = lessonGate !== undefined ? this.lessonsSync() : [];
|
|
449
|
+
if (!cfg.autoInject || query.trim() === '' || (roster.length === 0 && lessonRoster.length === 0)) {
|
|
362
450
|
// A consumed/expired pending must always leave an ilog trace (ADR 0014:
|
|
363
451
|
// 过期/消费都写 ilog) — even when the fast path early-returns here
|
|
364
452
|
// because the roster vanished between production and consumption.
|
|
@@ -387,10 +475,14 @@ export class TopicsService {
|
|
|
387
475
|
}
|
|
388
476
|
void this.store.appendInjectionRecord(trace).catch(() => undefined);
|
|
389
477
|
}
|
|
390
|
-
return { text: '', outcome: empty, deduped: [], echoed: [], included: [], slowIncluded: [] };
|
|
478
|
+
return { text: '', outcome: empty, deduped: [], echoed: [], included: [], slowIncluded: [], lessonIncluded: [] };
|
|
391
479
|
}
|
|
480
|
+
// A-line gate (A3/A4): query cleaning + package + semantic rerank ride
|
|
481
|
+
// behind packageMode (default off = byte-for-byte legacy path).
|
|
482
|
+
const pkgMode = cfg.packageMode === 'shadow' || cfg.packageMode === 'on' ? cfg.packageMode : undefined;
|
|
483
|
+
const effectiveQuery = pkgMode !== undefined ? sanitizeQuery(query) : query;
|
|
392
484
|
const conflicts = this.store.getConflictsSync();
|
|
393
|
-
const outcome = searchTopics(
|
|
485
|
+
const outcome = searchTopics(effectiveQuery, roster, {
|
|
394
486
|
threshold: cfg.matchThreshold,
|
|
395
487
|
topK: cfg.topK,
|
|
396
488
|
tagBoost: cfg.tagBoost,
|
|
@@ -401,6 +493,36 @@ export class TopicsService {
|
|
|
401
493
|
usage: this.usageSignalsSync(),
|
|
402
494
|
...this.zeroOpenCfg(),
|
|
403
495
|
});
|
|
496
|
+
// ---- A-line semantic rerank (A4 决议 2, spec §3.1) ----
|
|
497
|
+
// Reorder ONLY the 0.20–0.30 nearFloor band (the 93% near-miss mass);
|
|
498
|
+
// thresholds/gateScore/hits untouched. Any failure degrades to the
|
|
499
|
+
// original order — the fast lane never blocks on the semantic layer.
|
|
500
|
+
let nearFloorRerank;
|
|
501
|
+
if (pkgMode !== undefined) {
|
|
502
|
+
const bandLo = 0.2;
|
|
503
|
+
const band = outcome.nearMisses.filter((h) => h.score >= bandLo && h.score < cfg.matchThreshold);
|
|
504
|
+
const orderBefore = band.map((h) => h.slug);
|
|
505
|
+
if (band.length > 1) {
|
|
506
|
+
const index = this.embeddingIndexCacheGet();
|
|
507
|
+
const models = new Set(Object.values(index).map((e) => e.model));
|
|
508
|
+
const embedModel = resolveEmbedModel(cfg.embeddingModel);
|
|
509
|
+
if (models.size <= 1 && (models.size === 0 || models.has(embedModel))) {
|
|
510
|
+
const qv = embedQuerySync(effectiveQuery, { provider: cfg.embeddingProvider, model: cfg.embeddingModel });
|
|
511
|
+
const rr = rerankNearFloor(effectiveQuery, qv.ok ? qv.vector : null, band, index);
|
|
512
|
+
if (rr.reranked) {
|
|
513
|
+
const inBand = (h) => h.score >= bandLo && h.score < cfg.matchThreshold;
|
|
514
|
+
const rrSlugs = new Set(rr.hits.map((h) => h.slug));
|
|
515
|
+
const merged = [...rr.hits, ...band.filter((h) => !rrSlugs.has(h.slug))];
|
|
516
|
+
let mi = 0;
|
|
517
|
+
outcome.nearMisses = outcome.nearMisses.map((h) => (inBand(h) ? merged[mi++] : h));
|
|
518
|
+
nearFloorRerank = { reranked: true, orderBefore, orderAfter: merged.map((h) => h.slug) };
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
nearFloorRerank = { reranked: false, passthroughReason: rr.passthroughReason, orderBefore, orderAfter: orderBefore };
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
404
526
|
const exclude = dedup?.exclude;
|
|
405
527
|
const echo = dedup?.echo;
|
|
406
528
|
const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug);
|
|
@@ -424,6 +546,63 @@ export class TopicsService {
|
|
|
424
546
|
}
|
|
425
547
|
entries.push({ slug: hit.slug, doc, hit });
|
|
426
548
|
}
|
|
549
|
+
// ---- Lesson gate (B2, spec 卷一 §6.3): established lessons only (MJ-3:
|
|
550
|
+
// hint never injects), trigger-channel match = 条件命中 (triggers carry
|
|
551
|
+
// the condition), top-1 by score. No hit → no lesson (教训不撒背景网).
|
|
552
|
+
// dedup/echo follow the same rules as topics. ----
|
|
553
|
+
let lessonMatch;
|
|
554
|
+
if (lessonGate !== undefined && lessonRoster.length > 0) {
|
|
555
|
+
const qTokens = new Set(tokenize(effectiveQuery));
|
|
556
|
+
let best;
|
|
557
|
+
for (const l of lessonRoster) {
|
|
558
|
+
if (exclude?.has(l.slug)) {
|
|
559
|
+
if (!deduped.includes(l.slug))
|
|
560
|
+
deduped.push(l.slug);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (echo?.has(l.slug)) {
|
|
564
|
+
if (!echoed.includes(l.slug))
|
|
565
|
+
echoed.push(l.slug);
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const v = scoreTopic(qTokens, l, {
|
|
569
|
+
threshold: cfg.matchThreshold,
|
|
570
|
+
topK: cfg.topK,
|
|
571
|
+
tagBoost: cfg.tagBoost,
|
|
572
|
+
graphDepth: 0,
|
|
573
|
+
recencyWindowDays: cfg.recencyWindowDays,
|
|
574
|
+
conflicts,
|
|
575
|
+
structuralGate: true,
|
|
576
|
+
usageBoost: cfg.usageBoost,
|
|
577
|
+
usage: this.usageSignalsSync(),
|
|
578
|
+
...this.zeroOpenCfg(),
|
|
579
|
+
});
|
|
580
|
+
if (!v.reasons.some((r) => r.startsWith('triggers:')))
|
|
581
|
+
continue;
|
|
582
|
+
if (best === undefined || v.score > best.v.score)
|
|
583
|
+
best = { l, v };
|
|
584
|
+
}
|
|
585
|
+
if (best !== undefined) {
|
|
586
|
+
lessonMatch = {
|
|
587
|
+
pointer: {
|
|
588
|
+
slug: best.l.slug,
|
|
589
|
+
title: best.l.title,
|
|
590
|
+
status: best.l.status,
|
|
591
|
+
description: best.l.description,
|
|
592
|
+
score: best.v.score,
|
|
593
|
+
source: best.l.source,
|
|
594
|
+
},
|
|
595
|
+
reasons: best.v.reasons,
|
|
596
|
+
conclusion: best.l.conclusion,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// MJ-C combination matrix: lesson injection does not depend on the A3
|
|
601
|
+
// package gray-release. While the emitted shape is a flat list (digest
|
|
602
|
+
// mode, pointer mode without the package, AND the package-shadow legacy
|
|
603
|
+
// emit), lessons ride it as flat entries with the source marker; only a
|
|
604
|
+
// live package (packageMode on) gives them the direct-layer priority seat.
|
|
605
|
+
const lessonFlat = lessonGate === 'on' && lessonMatch !== undefined ? [lessonMatch.pointer] : undefined;
|
|
427
606
|
// ---- Slow-lane merge (log-only shadow gate; consumption bookkeeping) ----
|
|
428
607
|
const slowPointers = [];
|
|
429
608
|
const slowDelivered = [];
|
|
@@ -431,7 +610,7 @@ export class TopicsService {
|
|
|
431
610
|
let slowIncluded = [];
|
|
432
611
|
const consumedAt = new Date().toISOString();
|
|
433
612
|
if (slow !== undefined) {
|
|
434
|
-
const queryTokens = new Set(tokenize(
|
|
613
|
+
const queryTokens = new Set(tokenize(effectiveQuery));
|
|
435
614
|
for (const item of slow.items) {
|
|
436
615
|
if (exclude?.has(item.slug)) {
|
|
437
616
|
if (!deduped.includes(item.slug))
|
|
@@ -482,18 +661,103 @@ export class TopicsService {
|
|
|
482
661
|
const pointerMode = cfg.injectMode !== 'digest';
|
|
483
662
|
// Pointer budget: per-entry fixed at the design's 80, total clamped to the
|
|
484
663
|
// fast-lane upper bound of 600 (I4) — totalBudget stays tunable downward.
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
664
|
+
let packageSkipped = false;
|
|
665
|
+
let packageShadow;
|
|
666
|
+
let injection;
|
|
667
|
+
if (!pointerMode) {
|
|
668
|
+
injection = assembleInjection(entries, { perTopicBudget: cfg.perTopicBudget, totalBudget: cfg.totalBudget }, slowPointers, lessonFlat);
|
|
669
|
+
}
|
|
670
|
+
else if (pkgMode === undefined) {
|
|
671
|
+
injection = assemblePointer(entries, { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) }, slowPointers, lessonFlat);
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
// A-line relevance package (A3, spec §2): direct = lexical hits,
|
|
675
|
+
// neighborhood = graph hits + slow picks, weak = reranked band head.
|
|
676
|
+
const budget = { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) };
|
|
677
|
+
const legacy = assemblePointer(entries, budget, slowPointers, lessonFlat);
|
|
678
|
+
const direct = [];
|
|
679
|
+
const neighborhood = [];
|
|
680
|
+
for (const e of entries) {
|
|
681
|
+
const meta = bySlug.get(e.slug);
|
|
682
|
+
const cand = {
|
|
683
|
+
slug: e.slug,
|
|
684
|
+
title: meta?.title ?? e.slug,
|
|
685
|
+
status: meta?.status ?? 'draft',
|
|
686
|
+
description: meta?.description,
|
|
687
|
+
conclusion: firstContentLine(e.doc.body),
|
|
688
|
+
score: e.hit.score,
|
|
689
|
+
reasons: e.hit.reasons,
|
|
690
|
+
via: e.hit.viaGraph ? 'graph' : undefined,
|
|
691
|
+
};
|
|
692
|
+
(e.hit.viaGraph ? neighborhood : direct).push(cand);
|
|
693
|
+
}
|
|
694
|
+
// Lesson priority seat (B2, spec 卷一 §6.3): 主层 ≤2 席中命中 lesson
|
|
695
|
+
// 至少 1 席 — sorts first and is exempt from the direct-seat cap
|
|
696
|
+
// (budget-bound). Only on the LIVE package leg; the legacy emit above
|
|
697
|
+
// carries the flat lesson entry instead (MJ-C).
|
|
698
|
+
if (lessonFlat !== undefined && lessonMatch !== undefined) {
|
|
699
|
+
direct.unshift({
|
|
700
|
+
slug: lessonMatch.pointer.slug,
|
|
701
|
+
title: lessonMatch.pointer.title,
|
|
702
|
+
status: lessonMatch.pointer.status,
|
|
703
|
+
description: lessonMatch.pointer.description,
|
|
704
|
+
conclusion: firstContentLine(lessonMatch.conclusion),
|
|
705
|
+
score: lessonMatch.pointer.score,
|
|
706
|
+
reasons: lessonMatch.reasons,
|
|
707
|
+
prioritySeat: true,
|
|
708
|
+
sourceTag: lessonSourceTag(lessonMatch.pointer.source),
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
for (const sp of slowPointers) {
|
|
712
|
+
neighborhood.push({ slug: sp.slug, title: sp.title, status: sp.status, description: sp.description, score: 0, reasons: [], via: 'slow-pick' });
|
|
713
|
+
}
|
|
714
|
+
const weak = outcome.nearMisses
|
|
715
|
+
.filter((h) => h.score >= 0.2 && h.score < cfg.matchThreshold)
|
|
716
|
+
.slice(0, 2)
|
|
717
|
+
.map((h) => {
|
|
718
|
+
const meta = bySlug.get(h.slug);
|
|
719
|
+
return { slug: h.slug, title: meta?.title ?? h.slug, status: meta?.status ?? 'draft', score: h.score, reasons: h.reasons, via: 'weak' };
|
|
720
|
+
});
|
|
721
|
+
const usage = this.usageSignalsSync();
|
|
722
|
+
const usageBySlug = new Map([...usage].map(([slug, sig]) => [slug, sig.opens * 3 + sig.hits]));
|
|
723
|
+
const pkgInput = { query: effectiveQuery, queryTokens: tokenize(effectiveQuery), direct, neighborhood, weak, usageBySlug };
|
|
724
|
+
const pkg = assemblePackage(pkgInput);
|
|
725
|
+
if (pkgMode === 'shadow') {
|
|
726
|
+
packageShadow = pkg;
|
|
727
|
+
injection = legacy;
|
|
728
|
+
}
|
|
729
|
+
else if (pkg === null) {
|
|
730
|
+
// Trivial-query downgrade (A3 决议 5): emit nothing, log the why.
|
|
731
|
+
packageSkipped = true;
|
|
732
|
+
injection = { text: '', usedTokens: 0, included: [], dropped: [] };
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
const slowSet = new Set(slowPointers.map((sp) => sp.slug));
|
|
736
|
+
injection = {
|
|
737
|
+
text: pkg.text,
|
|
738
|
+
usedTokens: pkg.usedTokens,
|
|
739
|
+
included: pkg.included,
|
|
740
|
+
dropped: pkg.dropped,
|
|
741
|
+
slowIncluded: pkg.included.filter((slug) => slowSet.has(slug)),
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
}
|
|
488
745
|
slowIncluded = injection.slowIncluded ?? [];
|
|
489
746
|
for (const pointer of slowPointers) {
|
|
490
747
|
if (slowIncluded.includes(pointer.slug))
|
|
491
748
|
slowDelivered.push({ slug: pointer.slug, why: pointer.why });
|
|
492
749
|
}
|
|
750
|
+
// ---- Lesson gate accounting (B2): what entered the context, the shadow
|
|
751
|
+
// bypass ledger (meta/lesson-shadow.jsonl — lesson seat vs no-lesson
|
|
752
|
+
// emitted disposition, same discipline as the package shadow), and the
|
|
753
|
+
// record fields (absent when the gate is off or nothing matched). ----
|
|
754
|
+
const lessonIncluded = lessonGate === 'on' && lessonMatch !== undefined
|
|
755
|
+
? injection.lessonIncluded ?? (injection.included.includes(lessonMatch.pointer.slug) ? [lessonMatch.pointer.slug] : [])
|
|
756
|
+
: [];
|
|
493
757
|
const record = {
|
|
494
758
|
at: new Date().toISOString(),
|
|
495
|
-
queryTokenCount: tokenize(
|
|
496
|
-
querySample: querySample(
|
|
759
|
+
queryTokenCount: tokenize(effectiveQuery).length,
|
|
760
|
+
querySample: querySample(effectiveQuery),
|
|
497
761
|
rosterSize: outcome.rosterSize,
|
|
498
762
|
hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph, strong: h.strong, bodyHits: h.bodyHits })),
|
|
499
763
|
// Reasons ride along: the gate-blocked marker is the replay evidence
|
|
@@ -508,10 +772,59 @@ export class TopicsService {
|
|
|
508
772
|
record.deduped = deduped;
|
|
509
773
|
if (echoed.length > 0)
|
|
510
774
|
record.echoed = echoed;
|
|
775
|
+
if (packageSkipped)
|
|
776
|
+
record.why = 'package-skipped-trivial';
|
|
511
777
|
if (unreadable.length > 0) {
|
|
512
778
|
record.dropped = [...(record.dropped ?? []), ...unreadable.map((slug) => ({ slug, reason: 'doc-unreadable' }))];
|
|
513
779
|
}
|
|
780
|
+
if (pkgMode === 'shadow' && pointerMode) {
|
|
781
|
+
this.appendShadowLedger(this.packageShadowLedgerPath(), {
|
|
782
|
+
at: record.at,
|
|
783
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
784
|
+
queryTokenCount: record.queryTokenCount,
|
|
785
|
+
emitted: { tokens: injection.usedTokens, included: injection.included },
|
|
786
|
+
package: packageShadow === undefined
|
|
787
|
+
? null
|
|
788
|
+
: packageShadow === null
|
|
789
|
+
? { skipped: 'trivial-or-empty' }
|
|
790
|
+
: { tokens: packageShadow.usedTokens, included: packageShadow.included, layers: packageShadow.layers },
|
|
791
|
+
nearFloorRerank: nearFloorRerank ?? null,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
if (lessonGate === 'shadow' && lessonMatch !== undefined) {
|
|
795
|
+
const rendered = lessonEntryText(lessonMatch.pointer, POINTER_PER_TOPIC);
|
|
796
|
+
const budgetTotal = pointerMode ? Math.min(cfg.totalBudget, POINTER_TOTAL) : cfg.totalBudget;
|
|
797
|
+
this.appendShadowLedger(this.lessonShadowLedgerPath(), {
|
|
798
|
+
at: record.at,
|
|
799
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
800
|
+
queryTokenCount: record.queryTokenCount,
|
|
801
|
+
lessonInject: 'shadow',
|
|
802
|
+
lessons: [
|
|
803
|
+
{
|
|
804
|
+
slug: lessonMatch.pointer.slug,
|
|
805
|
+
score: lessonMatch.pointer.score,
|
|
806
|
+
source: lessonMatch.pointer.source,
|
|
807
|
+
entryTokens: rendered.usedTokens,
|
|
808
|
+
wouldFit: injection.usedTokens + rendered.usedTokens <= budgetTotal,
|
|
809
|
+
},
|
|
810
|
+
],
|
|
811
|
+
emitted: { tokens: injection.usedTokens, included: injection.included },
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
if (lessonGate !== undefined && lessonMatch !== undefined) {
|
|
815
|
+
record.lessonInject = lessonGate;
|
|
816
|
+
record.lessons = [
|
|
817
|
+
{
|
|
818
|
+
slug: lessonMatch.pointer.slug,
|
|
819
|
+
score: lessonMatch.pointer.score,
|
|
820
|
+
source: lessonMatch.pointer.source,
|
|
821
|
+
included: lessonIncluded.length > 0,
|
|
822
|
+
},
|
|
823
|
+
];
|
|
824
|
+
}
|
|
514
825
|
if (slow !== undefined) {
|
|
826
|
+
if (slow.outcome !== undefined)
|
|
827
|
+
record.slowOutcome = slow.outcome;
|
|
515
828
|
// Any round whose pending was consumed is a slow-participating round —
|
|
516
829
|
// lane never stays undefined when the lane family is present (a
|
|
517
830
|
// delivered pick with fast pointers is 'mixed'; a consumed-but-empty
|
|
@@ -547,7 +860,7 @@ export class TopicsService {
|
|
|
547
860
|
if (injection.dropped.length > 0)
|
|
548
861
|
record.dropped = [...(record.dropped ?? []), ...injection.dropped];
|
|
549
862
|
void this.store.appendInjectionRecord(record).catch(() => undefined);
|
|
550
|
-
return { text: injection.text, outcome, deduped, echoed, included: injection.included, slowIncluded };
|
|
863
|
+
return { text: injection.text, outcome, deduped, echoed, included: injection.included, slowIncluded, lessonIncluded };
|
|
551
864
|
}
|
|
552
865
|
/**
|
|
553
866
|
* topic_open tool path (v4 §4.1): full conclusion / open questions /
|
|
@@ -647,6 +960,10 @@ export class TopicsService {
|
|
|
647
960
|
// Same retirement rule as roster(): deprecated knowledge never injects.
|
|
648
961
|
if (doc.fm.status === 'deprecated')
|
|
649
962
|
continue;
|
|
963
|
+
// Same lesson separation as roster() (B2): lessons inject only through
|
|
964
|
+
// the lessonInject gate, never the topic lane.
|
|
965
|
+
if (okf.topicKindOf(doc.fm.type) === 'lesson')
|
|
966
|
+
continue;
|
|
650
967
|
out.push({
|
|
651
968
|
slug: entry.slug,
|
|
652
969
|
title: doc.fm.title,
|
|
@@ -662,6 +979,76 @@ export class TopicsService {
|
|
|
662
979
|
}
|
|
663
980
|
return out;
|
|
664
981
|
}
|
|
982
|
+
/** Established, scope-passing lesson entries (B2, spec 卷一 §6.3) — sync,
|
|
983
|
+
* sharing the roster's mtime cache. Injection reads ONLY established
|
|
984
|
+
* lessons here (MJ-3: hint 仅供审批队列展示,永不注入); scope filter =
|
|
985
|
+
* workspace lessons must carry the current workspace id (MJ-2); scope=global
|
|
986
|
+
* and legacy entries without a workspaceId pass (fail-open — the filter's
|
|
987
|
+
* default is permissive, the confidence gate is the hard one). */
|
|
988
|
+
lessonsSync() {
|
|
989
|
+
const ws = currentWorkspaceId();
|
|
990
|
+
let files;
|
|
991
|
+
try {
|
|
992
|
+
files = readdirSync(this.store.topicsDir());
|
|
993
|
+
}
|
|
994
|
+
catch {
|
|
995
|
+
return [];
|
|
996
|
+
}
|
|
997
|
+
const out = [];
|
|
998
|
+
for (const file of files) {
|
|
999
|
+
if (!file.endsWith('.md') || file === 'index.md')
|
|
1000
|
+
continue;
|
|
1001
|
+
const path = join(this.store.topicsDir(), file);
|
|
1002
|
+
let st;
|
|
1003
|
+
try {
|
|
1004
|
+
st = statSync(path);
|
|
1005
|
+
}
|
|
1006
|
+
catch {
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
const cached = this.cache.get(file);
|
|
1010
|
+
let entry = cached;
|
|
1011
|
+
if (cached === undefined || cached.mtimeMs !== st.mtimeMs || cached.size !== st.size) {
|
|
1012
|
+
try {
|
|
1013
|
+
const doc = okf.parseTopicDoc(readFileSync(path, 'utf8'));
|
|
1014
|
+
entry = { mtimeMs: st.mtimeMs, size: st.size, doc, slug: file.slice(0, -3) };
|
|
1015
|
+
this.cache.set(file, entry);
|
|
1016
|
+
}
|
|
1017
|
+
catch {
|
|
1018
|
+
this.cache.delete(file);
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
if (entry === undefined)
|
|
1023
|
+
continue;
|
|
1024
|
+
const fm = entry.doc.fm;
|
|
1025
|
+
if (okf.topicKindOf(fm.type) !== 'lesson')
|
|
1026
|
+
continue;
|
|
1027
|
+
// Confidence 事实源:established ⇄ stable;deprecated 已被上面的通用
|
|
1028
|
+
// 退休规则挡住,这里再兜一层(hint↔draft 永不注入)。
|
|
1029
|
+
if (fm.confidence !== 'established' || fm.status === 'deprecated')
|
|
1030
|
+
continue;
|
|
1031
|
+
if (fm.scope === 'workspace' && fm.workspaceId !== undefined && fm.workspaceId !== ws)
|
|
1032
|
+
continue;
|
|
1033
|
+
out.push({
|
|
1034
|
+
slug: entry.slug,
|
|
1035
|
+
title: fm.title,
|
|
1036
|
+
description: fm.description,
|
|
1037
|
+
status: fm.status,
|
|
1038
|
+
tags: fm.tags,
|
|
1039
|
+
triggers: fm.triggers,
|
|
1040
|
+
depends: fm.depends,
|
|
1041
|
+
links: okf.bodyLinkSlugs(entry.doc.body),
|
|
1042
|
+
generatedAt: fm.generated.at,
|
|
1043
|
+
conclusion: okf.sectionOf(entry.doc.body, okf.CONCLUSION_HEADING) ?? '',
|
|
1044
|
+
confidence: fm.confidence,
|
|
1045
|
+
source: fm.source ?? 'manual',
|
|
1046
|
+
scope: fm.scope ?? 'workspace',
|
|
1047
|
+
...(fm.workspaceId !== undefined ? { workspaceId: fm.workspaceId } : {}),
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
return out;
|
|
1051
|
+
}
|
|
665
1052
|
readDocSync(slug) {
|
|
666
1053
|
try {
|
|
667
1054
|
return okf.parseTopicDoc(readFileSync(join(this.store.topicsDir(), `${slug}.md`), 'utf8'));
|
|
@@ -678,7 +1065,11 @@ export class TopicsService {
|
|
|
678
1065
|
return undefined;
|
|
679
1066
|
}
|
|
680
1067
|
}
|
|
681
|
-
/** topic_save tool path: create or update with actor stamping.
|
|
1068
|
+
/** topic_save tool path: create or update with actor stamping. `kind:
|
|
1069
|
+
* 'lesson'` (B2 data plane) switches the OKF type to Lesson and stamps the
|
|
1070
|
+
* lesson frontmatter (scope/confidence/source/workspaceId) with the
|
|
1071
|
+
* confidence↔status derivation (hint↔draft, established↔stable —
|
|
1072
|
+
* confidence is the fact source). */
|
|
682
1073
|
async saveTopic(input) {
|
|
683
1074
|
const now = new Date().toISOString();
|
|
684
1075
|
const existing = input.slug !== undefined ? await this.store.readTopic(input.slug) : undefined;
|
|
@@ -694,9 +1085,10 @@ export class TopicsService {
|
|
|
694
1085
|
}
|
|
695
1086
|
const baseSlug = input.slug ?? okf.slugify(input.title);
|
|
696
1087
|
const slug = existing === undefined ? await this.store.uniqueSlug(baseSlug) : okf.slugify(input.slug ?? baseSlug);
|
|
1088
|
+
const isLesson = input.kind === 'lesson' || (existing !== undefined && okf.topicKindOf(existing.fm.type) === 'lesson');
|
|
697
1089
|
// store.saveTopic stamps generated {by, at} itself (write-through actor).
|
|
698
1090
|
const fm = {
|
|
699
|
-
type: '
|
|
1091
|
+
type: isLesson ? okf.kindTypeValue('lesson') : okf.kindTypeValue('topic'),
|
|
700
1092
|
title: input.title,
|
|
701
1093
|
tags: dedupeLower(input.tags ?? existing?.fm.tags ?? []),
|
|
702
1094
|
// normalizeDependsEntry, never bare slugToPath: the preserved-on-update
|
|
@@ -716,9 +1108,34 @@ export class TopicsService {
|
|
|
716
1108
|
const triggers = sanitizeTriggers(input.triggers ?? existing?.fm.triggers ?? []);
|
|
717
1109
|
if (triggers.length > 0)
|
|
718
1110
|
fm.triggers = triggers;
|
|
1111
|
+
if (isLesson) {
|
|
1112
|
+
// B2 frontmatter (spec 卷一 §6.2): preserve-on-update like tags; the
|
|
1113
|
+
// workspace id is stamped AT WRITE TIME (MJ-2, cwd hash) and survives
|
|
1114
|
+
// updates; confidence is the FACT source → status derives from it,
|
|
1115
|
+
// except a deprecated lesson stays deprecated unless confidence is
|
|
1116
|
+
// explicitly re-driven (no silent revival through topic_save).
|
|
1117
|
+
fm.scope = input.scope ?? existing?.fm.scope ?? 'workspace';
|
|
1118
|
+
fm.confidence = input.confidence ?? existing?.fm.confidence ?? 'hint';
|
|
1119
|
+
fm.source = input.lessonSource ?? existing?.fm.source ?? 'manual';
|
|
1120
|
+
if (fm.scope === 'workspace') {
|
|
1121
|
+
const wsid = existing?.fm.workspaceId ?? currentWorkspaceId();
|
|
1122
|
+
if (wsid !== undefined)
|
|
1123
|
+
fm.workspaceId = wsid;
|
|
1124
|
+
}
|
|
1125
|
+
else {
|
|
1126
|
+
delete fm.workspaceId;
|
|
1127
|
+
}
|
|
1128
|
+
fm.status =
|
|
1129
|
+
existing?.fm.status === 'deprecated' && input.confidence === undefined
|
|
1130
|
+
? 'deprecated'
|
|
1131
|
+
: okf.deriveLessonStatus(fm.confidence);
|
|
1132
|
+
}
|
|
719
1133
|
if (existing !== undefined && existing.fm.verified !== undefined)
|
|
720
1134
|
fm.verified = existing.fm.verified;
|
|
721
|
-
const result = await this.store.saveTopic({ slug, doc: { fm, body }
|
|
1135
|
+
const result = await this.store.saveTopic({ slug, doc: { fm, body }, links: input.links }, {
|
|
1136
|
+
message: existing === undefined ? `topics(${isLesson ? 'lesson' : 'topic'}): create ${slug}` : `topics(${isLesson ? 'lesson' : 'topic'}): update ${slug}`,
|
|
1137
|
+
created: existing === undefined,
|
|
1138
|
+
});
|
|
722
1139
|
this.invalidate();
|
|
723
1140
|
void this.sync?.schedulePush();
|
|
724
1141
|
return { ...result, slug: result.slug };
|
|
@@ -726,6 +1143,127 @@ export class TopicsService {
|
|
|
726
1143
|
async observe(input) {
|
|
727
1144
|
return this.store.appendObservation({ kind: input.kind, source: input.source ?? 'model', text: input.text, sessionId: input.sessionId });
|
|
728
1145
|
}
|
|
1146
|
+
// -------------------------------------------------------------------------
|
|
1147
|
+
// Dual-write seams (卷二 §6.2 MJ-5) — the new repo's only write surface.
|
|
1148
|
+
// Both run behind the store lock / index recompute; both audit the caller
|
|
1149
|
+
// identity (opts.actor REQUIRED — a blank actor refuses the whole call and
|
|
1150
|
+
// lands a `seam-*` audit row) into meta/pipeline-events.jsonl.
|
|
1151
|
+
// -------------------------------------------------------------------------
|
|
1152
|
+
/**
|
|
1153
|
+
* ① 观察投喂: structured observations (B1 §2.1 whitelist, FEATURES ONLY —
|
|
1154
|
+
* 对话原文禁入, 红线⑤) appended to the observations stream with the `fed`
|
|
1155
|
+
* provenance marker and `kind: 'signal'`; each line carries `by: actor`.
|
|
1156
|
+
* The whitelist copy drops unknown caller fields, caps detail entries and
|
|
1157
|
+
* string sizes, and renders a sorted feature line as the observation text.
|
|
1158
|
+
* Duplicates (same type+session+detail in the recent tail) are counted, not
|
|
1159
|
+
* appended (重复观察由原仓去重). Fail-soft: one malformed entry is counted
|
|
1160
|
+
* as rejected, never sinks the batch.
|
|
1161
|
+
*/
|
|
1162
|
+
async feedObservations(obs, opts) {
|
|
1163
|
+
const actor = typeof opts?.actor === 'string' ? opts.actor.trim() : '';
|
|
1164
|
+
const total = Array.isArray(obs) ? obs.length : 0;
|
|
1165
|
+
if (actor === '') {
|
|
1166
|
+
await recordPipelineEvent(this.store.root, {
|
|
1167
|
+
at: new Date().toISOString(),
|
|
1168
|
+
kind: 'seam-observations-fed',
|
|
1169
|
+
detail: { actor: '<missing>', accepted: 0, duplicates: 0, rejected: total, refused: true },
|
|
1170
|
+
});
|
|
1171
|
+
return { accepted: 0, duplicates: 0, rejected: total, ids: [], error: 'unauthorized' };
|
|
1172
|
+
}
|
|
1173
|
+
const accepted = [];
|
|
1174
|
+
let duplicates = 0;
|
|
1175
|
+
let rejected = 0;
|
|
1176
|
+
// Dedup window: the recent observation tail — the signal layer may retry
|
|
1177
|
+
// on its own fail-open local queue (spec 卷二 §2.1) and identical feature
|
|
1178
|
+
// tuples must not double-distill.
|
|
1179
|
+
const recent = await this.store.allObservations(500);
|
|
1180
|
+
const seenKeys = new Set(recent.map((o) => observationKey(o.kind, o.source, o.sessionId, o.text)));
|
|
1181
|
+
for (const raw of obs) {
|
|
1182
|
+
const clean = sanitizeStructuredObservation(raw);
|
|
1183
|
+
if (clean === undefined) {
|
|
1184
|
+
rejected += 1;
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
const text = renderFeatureLine(clean);
|
|
1188
|
+
const key = observationKey('signal', 'fed', clean.sessionId, text);
|
|
1189
|
+
if (seenKeys.has(key)) {
|
|
1190
|
+
duplicates += 1;
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
seenKeys.add(key);
|
|
1194
|
+
const saved = await this.store.appendObservation({
|
|
1195
|
+
kind: 'signal',
|
|
1196
|
+
source: 'fed',
|
|
1197
|
+
text,
|
|
1198
|
+
sessionId: clean.sessionId,
|
|
1199
|
+
by: actor,
|
|
1200
|
+
});
|
|
1201
|
+
accepted.push(saved.id);
|
|
1202
|
+
}
|
|
1203
|
+
await recordPipelineEvent(this.store.root, {
|
|
1204
|
+
at: new Date().toISOString(),
|
|
1205
|
+
kind: 'seam-observations-fed',
|
|
1206
|
+
detail: { actor, accepted: accepted.length, duplicates, rejected },
|
|
1207
|
+
});
|
|
1208
|
+
return { accepted: accepted.length, duplicates, rejected, ids: accepted };
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* ② lesson 状态变更 (B2/卷二 §6.2): hint→established / →deprecated /
|
|
1212
|
+
* →removed. established flips confidence (the FACT source) and derives
|
|
1213
|
+
* status (established↔stable); deprecated flips status only (the TTL sweep
|
|
1214
|
+
* owns the rest); removed = archive FIRST (byte-exact copy to
|
|
1215
|
+
* `archive/lessons/` inside the bundle, git-revertable — 红线④) then out of
|
|
1216
|
+
* the roster, one commit. Each call lands a `seam-lesson-status` audit row.
|
|
1217
|
+
*/
|
|
1218
|
+
async setLessonStatus(slug, to, opts) {
|
|
1219
|
+
const actor = typeof opts?.actor === 'string' ? opts.actor.trim() : '';
|
|
1220
|
+
const clean = okf.slugify(slug ?? '');
|
|
1221
|
+
const audit = async (ok, reason, extra) => {
|
|
1222
|
+
await recordPipelineEvent(this.store.root, {
|
|
1223
|
+
at: new Date().toISOString(),
|
|
1224
|
+
kind: 'seam-lesson-status',
|
|
1225
|
+
detail: { actor: actor === '' ? '<missing>' : actor, slug: clean, to, ok, ...(reason !== undefined ? { reason } : {}), ...(extra ?? {}) },
|
|
1226
|
+
});
|
|
1227
|
+
};
|
|
1228
|
+
if (actor === '') {
|
|
1229
|
+
await audit(false, 'unauthorized');
|
|
1230
|
+
return { ok: false, slug: clean, reason: 'unauthorized' };
|
|
1231
|
+
}
|
|
1232
|
+
if (to !== 'established' && to !== 'deprecated' && to !== 'removed') {
|
|
1233
|
+
await audit(false, 'invalid-state');
|
|
1234
|
+
return { ok: false, slug: clean, reason: 'invalid-state' };
|
|
1235
|
+
}
|
|
1236
|
+
const doc = await this.store.readTopic(clean).catch(() => undefined);
|
|
1237
|
+
if (doc === undefined) {
|
|
1238
|
+
await audit(false, 'not-found');
|
|
1239
|
+
return { ok: false, slug: clean, reason: 'not-found' };
|
|
1240
|
+
}
|
|
1241
|
+
if (okf.topicKindOf(doc.fm.type) !== 'lesson') {
|
|
1242
|
+
await audit(false, 'not-a-lesson');
|
|
1243
|
+
return { ok: false, slug: clean, reason: 'not-a-lesson' };
|
|
1244
|
+
}
|
|
1245
|
+
const from = doc.fm.confidence ?? 'hint';
|
|
1246
|
+
if (to === 'removed') {
|
|
1247
|
+
const r = await this.store.archiveLesson(clean, {
|
|
1248
|
+
message: `topics(lesson): remove ${clean} (archive-first, by ${actor})`,
|
|
1249
|
+
});
|
|
1250
|
+
if (r === undefined) {
|
|
1251
|
+
await audit(false, 'not-found');
|
|
1252
|
+
return { ok: false, slug: clean, reason: 'not-found' };
|
|
1253
|
+
}
|
|
1254
|
+
await audit(true, undefined, { archivedTo: r.archivedTo, committed: r.committed });
|
|
1255
|
+
this.invalidate();
|
|
1256
|
+
return { ok: true, slug: clean, from, archivedTo: r.archivedTo, committed: r.committed };
|
|
1257
|
+
}
|
|
1258
|
+
if (to === 'established')
|
|
1259
|
+
doc.fm.confidence = 'established';
|
|
1260
|
+
doc.fm.status = to === 'established' ? okf.deriveLessonStatus('established') : 'deprecated';
|
|
1261
|
+
const result = await this.store.saveTopic({ slug: clean, doc }, { message: `topics(lesson): ${to} ${clean} (by ${actor})`, actor });
|
|
1262
|
+
this.invalidate();
|
|
1263
|
+
void this.sync?.schedulePush();
|
|
1264
|
+
await audit(true, undefined, { committed: result.committed });
|
|
1265
|
+
return { ok: true, slug: clean, from, committed: result.committed };
|
|
1266
|
+
}
|
|
729
1267
|
async search(query, topK) {
|
|
730
1268
|
const cfg = this.cfg;
|
|
731
1269
|
return searchTopics(query, await this.roster(), {
|
|
@@ -771,6 +1309,66 @@ export class TopicsService {
|
|
|
771
1309
|
function inclusionDropped(injection) {
|
|
772
1310
|
return injection.dropped.length > 0;
|
|
773
1311
|
}
|
|
1312
|
+
// --- feedObservations whitelist machinery (B-line seam ①) -------------------
|
|
1313
|
+
// jev/log REDACTION + pipeline-events discipline: whitelist copy on write,
|
|
1314
|
+
// bounded scalar features only — 对话原文(tool arguments、message content)
|
|
1315
|
+
// 结构上进不来(红线⑤)。
|
|
1316
|
+
const SEAM_TYPE_MAX_CHARS = 64;
|
|
1317
|
+
const SEAM_SESSION_MAX_CHARS = 128;
|
|
1318
|
+
const SEAM_DETAIL_MAX_ENTRIES = 16;
|
|
1319
|
+
const SEAM_DETAIL_KEY_MAX_CHARS = 64;
|
|
1320
|
+
const SEAM_DETAIL_VALUE_MAX_CHARS = 256;
|
|
1321
|
+
/** Whitelist copy of one structured observation; undefined = reject. A raw
|
|
1322
|
+
* entry whose detail existed but sanitizes to EMPTY (object/array smuggle,
|
|
1323
|
+
* garbage values) is rejected outright — a type with no survivable features
|
|
1324
|
+
* is a malformed signal, not a featureless one. */
|
|
1325
|
+
function sanitizeStructuredObservation(raw) {
|
|
1326
|
+
if (raw === null || typeof raw !== 'object')
|
|
1327
|
+
return undefined;
|
|
1328
|
+
if (typeof raw.type !== 'string' || raw.type.trim() === '')
|
|
1329
|
+
return undefined;
|
|
1330
|
+
const out = { type: raw.type.trim().slice(0, SEAM_TYPE_MAX_CHARS) };
|
|
1331
|
+
if (typeof raw.at === 'string' && raw.at !== '')
|
|
1332
|
+
out.at = raw.at;
|
|
1333
|
+
if (typeof raw.sessionId === 'string' && raw.sessionId !== '')
|
|
1334
|
+
out.sessionId = raw.sessionId.slice(0, SEAM_SESSION_MAX_CHARS);
|
|
1335
|
+
const hadDetail = raw.detail !== undefined && typeof raw.detail === 'object' && !Array.isArray(raw.detail);
|
|
1336
|
+
if (hadDetail) {
|
|
1337
|
+
const detail = {};
|
|
1338
|
+
for (const [k, v] of Object.entries(raw.detail)) {
|
|
1339
|
+
if (Object.keys(detail).length >= SEAM_DETAIL_MAX_ENTRIES)
|
|
1340
|
+
break;
|
|
1341
|
+
if (typeof v === 'string') {
|
|
1342
|
+
if (v !== '')
|
|
1343
|
+
detail[k.slice(0, SEAM_DETAIL_KEY_MAX_CHARS)] = v.slice(0, SEAM_DETAIL_VALUE_MAX_CHARS);
|
|
1344
|
+
}
|
|
1345
|
+
else if (typeof v === 'number') {
|
|
1346
|
+
if (Number.isFinite(v))
|
|
1347
|
+
detail[k.slice(0, SEAM_DETAIL_KEY_MAX_CHARS)] = v;
|
|
1348
|
+
}
|
|
1349
|
+
else if (typeof v === 'boolean') {
|
|
1350
|
+
detail[k.slice(0, SEAM_DETAIL_KEY_MAX_CHARS)] = v;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
if (Object.keys(detail).length === 0)
|
|
1354
|
+
return undefined;
|
|
1355
|
+
out.detail = detail;
|
|
1356
|
+
}
|
|
1357
|
+
return out;
|
|
1358
|
+
}
|
|
1359
|
+
/** Sorted feature line as the observation text: `[type] k=v k=v` — stable
|
|
1360
|
+
* (dedup-keyable), feature-only, bounded. */
|
|
1361
|
+
function renderFeatureLine(o) {
|
|
1362
|
+
const parts = Object.keys(o.detail ?? {})
|
|
1363
|
+
.sort()
|
|
1364
|
+
.map((k) => `${k}=${String(o.detail[k])}`);
|
|
1365
|
+
return `[${o.type}]${parts.length > 0 ? ` ${parts.join(' ')}` : ''}`.slice(0, 512);
|
|
1366
|
+
}
|
|
1367
|
+
/** Dedup key over the stored observation identity (kind/source/session/feature
|
|
1368
|
+
* line) — candidates key the same way they are stored. */
|
|
1369
|
+
function observationKey(kind, source, sessionId, text) {
|
|
1370
|
+
return `${kind}|${source}|${sessionId ?? ''}|${text}`;
|
|
1371
|
+
}
|
|
774
1372
|
function firstLine(text) {
|
|
775
1373
|
return firstParagraphOf(text);
|
|
776
1374
|
}
|
|
@@ -805,6 +1403,15 @@ function dedupePaths(paths) {
|
|
|
805
1403
|
}
|
|
806
1404
|
return out;
|
|
807
1405
|
}
|
|
1406
|
+
function firstContentLine(doc) {
|
|
1407
|
+
for (const line of doc.split('\n')) {
|
|
1408
|
+
const t = line.trim();
|
|
1409
|
+
if (t === '' || t.startsWith('#'))
|
|
1410
|
+
continue;
|
|
1411
|
+
return t.slice(0, 200);
|
|
1412
|
+
}
|
|
1413
|
+
return '';
|
|
1414
|
+
}
|
|
808
1415
|
function sanitizeTriggers(triggers) {
|
|
809
1416
|
const out = [];
|
|
810
1417
|
for (const t of triggers) {
|