@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/retrieval.js CHANGED
@@ -11,6 +11,8 @@
11
11
  *
12
12
  * @module retrieval
13
13
  */
14
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
14
16
  import { dependsSlugs } from "./okf.js";
15
17
  /** Hard cap for the usage bonus — below threshold 0.3 so usage can never
16
18
  * carry a candidate across the gate on its own (ADR 0015). */
@@ -256,3 +258,281 @@ export function searchTopics(query, roster, cfg = {}) {
256
258
  }
257
259
  return { hits, nearMisses, rosterSize: roster.length };
258
260
  }
261
+ // ---------------------------------------------------------------------------
262
+ // Injection-quality line (wave/injection-quality, spec §2.1/§3.1/§3.3) —
263
+ // CAPABILITY ONLY. None of this is wired into searchTopics or any service
264
+ // path; the caller gates it on packageMode ≠ 'off' (Phase 2 wiring). With
265
+ // the config keys at their defaults every code path below is unreachable
266
+ // from the hot lane, so default-off behavior stays byte-for-byte.
267
+ // ---------------------------------------------------------------------------
268
+ // --- ① Query sanitization (A4 决议 4, spec §3.3) ---------------------------
269
+ // Pure, LLM-free, millisecond-scale. Treats the queryTokenCount p90=462
270
+ // paste-pollution tail: fenced code blocks, URLs, line-leading shell
271
+ // prompts, over-long lines, and a hard token cap.
272
+ /**
273
+ * Token cap in tokenize() units — the SAME unit injections.jsonl logs as
274
+ * queryTokenCount (p90=462 baseline; real-query p50=153 stays untouched).
275
+ * Raised or lowered via SanitizeOptions.maxTokens.
276
+ */
277
+ export const QUERY_TOKEN_CAP = 96;
278
+ /**
279
+ * Lines longer than this are folded to their head (log-paste guard). Sized
280
+ * so the 96-token cap is the binding constraint, not the fold: 96 latin
281
+ * tokens ≈ 650+ chars, so a legitimate long single-line query passes the
282
+ * fold intact and only the token cap trims it. (实现期定 constant.)
283
+ */
284
+ export const MAX_LINE_CHARS = 720;
285
+ const FENCE_OPEN = /^[ \t]*(```|~~~)/;
286
+ // Line-leading shell prompt: `user@host:~/repo$ `, `C:\> `, or a bare
287
+ // `$ `/`# `/`% `/`> `/`❯ ` marker. Requires trailing whitespace so words
288
+ // like `$PATH` or `#tag` survive untouched.
289
+ const LINE_PROMPT = /^[ \t]*(?:[\w.@:/\\~-]*[$#%>❯➜])[ \t]+/;
290
+ const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+/gi;
291
+ // Chunks the cap walks: one latin/digit word (or hyphenated compound) per
292
+ // match, or one CJK run (sliced when a run alone busts the cap).
293
+ const CHUNK_RUN = new RegExp(`${WORD_RUN.source}|${CJK_RUN.source}`, 'gu');
294
+ /** Remove fenced code blocks wholesale (markers AND body): fenced code is
295
+ * the paste-pollution payload; the prose around it is the actual query. */
296
+ function stripFencedBlocks(text) {
297
+ const out = [];
298
+ let open = null;
299
+ for (const line of text.split('\n')) {
300
+ const m = line.match(FENCE_OPEN);
301
+ if (open === null) {
302
+ if (m !== null)
303
+ open = m[1];
304
+ else
305
+ out.push(line);
306
+ }
307
+ else if (m !== null && m[1] === open) {
308
+ open = null; // closing fence — block fully dropped
309
+ }
310
+ // inside an open fence: drop the line
311
+ }
312
+ return out.join('\n');
313
+ }
314
+ /** Cap the sanitized text to ≤ cap distinct retrieval tokens. Fast path:
315
+ * at or under the cap the text passes through VERBATIM (no re-chunking —
316
+ * hyphens, punctuation and spacing survive byte-exact; retrieval scores
317
+ * tokens, not bytes, but the string may be echoed into logs/injection).
318
+ * Only an over-cap text rebuilds from chunks: head-first (a query's intent
319
+ * leads; pollution trails), with CJK runs sliced at the character level so
320
+ * a single giant run truncates instead of being dropped whole. */
321
+ function capTokens(text, cap) {
322
+ const total = tokenize(text).length;
323
+ if (total === 0)
324
+ return ''; // nothing lexical survives — caller sees a zero-token query
325
+ if (total <= cap)
326
+ return text;
327
+ const seen = new Set();
328
+ const kept = [];
329
+ for (const chunk of text.match(CHUNK_RUN) ?? []) {
330
+ const fresh = tokenize(chunk).filter((t) => !seen.has(t));
331
+ if (fresh.length === 0)
332
+ continue; // already-covered chunk adds nothing
333
+ if (seen.size + fresh.length <= cap) {
334
+ for (const t of fresh)
335
+ seen.add(t);
336
+ kept.push(chunk);
337
+ continue;
338
+ }
339
+ const room = cap - seen.size;
340
+ if (room <= 0)
341
+ break;
342
+ // Over budget: CJK runs truncate exactly (slice room+1 chars → ≤ room
343
+ // fresh bigrams, so the cap invariant holds). Latin chunks are single
344
+ // tokens by construction — they never reach here with room > 0, but if
345
+ // one ever does, dropping it beats busting the cap.
346
+ if (/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(chunk)) {
347
+ kept.push(chunk.slice(0, room + 1));
348
+ }
349
+ break;
350
+ }
351
+ return kept.join(' ');
352
+ }
353
+ /**
354
+ * Sanitize a raw user query for retrieval (spec §3.3): strip fenced code
355
+ * blocks and URLs, fold line-leading shell prompts, fold over-long lines to
356
+ * their head, cap the distinct-token count. Returns '' when nothing lexical
357
+ * survives — the caller treats that as a zero-token query (natural skip).
358
+ * Pure function; the hot lane only reaches it behind packageMode.
359
+ */
360
+ export function sanitizeQuery(raw, opts = {}) {
361
+ const cap = opts.maxTokens ?? QUERY_TOKEN_CAP;
362
+ const maxLineChars = opts.maxLineChars ?? MAX_LINE_CHARS;
363
+ if (raw === '')
364
+ return '';
365
+ let text = stripFencedBlocks(raw);
366
+ text = text.replace(URL_RE, ' ');
367
+ text = text
368
+ .split('\n')
369
+ .map((line) => {
370
+ let l = line.replace(LINE_PROMPT, '');
371
+ if (l.length > maxLineChars)
372
+ l = l.slice(0, maxLineChars);
373
+ return l;
374
+ })
375
+ .join('\n');
376
+ text = text.replace(/\s+/g, ' ').trim();
377
+ if (text === '')
378
+ return '';
379
+ return capTokens(text, cap);
380
+ }
381
+ export function embeddingIndexPath(bundleDir) {
382
+ return join(bundleDir, 'meta', 'embeddings.json');
383
+ }
384
+ function parseEmbeddingIndexEntry(value) {
385
+ if (typeof value !== 'object' || value === null)
386
+ return null;
387
+ const e = value;
388
+ if (typeof e.model !== 'string' || e.model === '')
389
+ return null;
390
+ if (typeof e.dim !== 'number' || !Number.isInteger(e.dim) || e.dim <= 0)
391
+ return null;
392
+ if (!Array.isArray(e.vec) || e.vec.length !== e.dim)
393
+ return null;
394
+ if (!e.vec.every((n) => typeof n === 'number' && Number.isFinite(n)))
395
+ return null;
396
+ return { model: e.model, dim: e.dim, vec: e.vec };
397
+ }
398
+ /** Load meta/embeddings.json from a bundle dir. Missing file, corrupt JSON,
399
+ * or malformed entries are skipped/empty — never throws (a broken index
400
+ * must degrade to the lexical path, not break retrieval). */
401
+ export function loadEmbeddingIndex(bundleDir) {
402
+ let raw;
403
+ try {
404
+ raw = readFileSync(embeddingIndexPath(bundleDir), 'utf8');
405
+ }
406
+ catch {
407
+ return {};
408
+ }
409
+ try {
410
+ const json = JSON.parse(raw);
411
+ if (typeof json !== 'object' || json === null || Array.isArray(json))
412
+ return {};
413
+ const out = {};
414
+ for (const [slug, entry] of Object.entries(json)) {
415
+ if (slug === '')
416
+ continue;
417
+ const parsed = parseEmbeddingIndexEntry(entry);
418
+ if (parsed !== null)
419
+ out[slug] = parsed;
420
+ }
421
+ return out;
422
+ }
423
+ catch {
424
+ return {};
425
+ }
426
+ }
427
+ /** Persist the index in the canonical shape (the rebuild's write side —
428
+ * embedding/rebuilding itself stays with the caller). Offline rebuild
429
+ * path, not the hot lane. */
430
+ export function saveEmbeddingIndex(bundleDir, index, mkdir = true) {
431
+ if (mkdir)
432
+ mkdirSync(join(bundleDir, 'meta'), { recursive: true });
433
+ writeFileSync(embeddingIndexPath(bundleDir), JSON.stringify(index));
434
+ }
435
+ /**
436
+ * Freshness audit: which slugs need (re)embedding — missing from the index,
437
+ * built by a different model, or shape-broken (dim/vec disagree). Returns
438
+ * them in roster order; an EMPTY list means the index covers the roster.
439
+ * A '' embedder model means the semantic layer is off → nothing to maintain.
440
+ *
441
+ * NOTE the freshness boundary: content-change invalidation needs a stored
442
+ * content hash, which the pinned {model,dim,vec} entry shape does not carry
443
+ * — the rebuild trigger policy is spec-实现期定 and stays with the caller.
444
+ */
445
+ export function ensureIndexFresh(index, topics, embedFn) {
446
+ if ((embedFn.model ?? '') === '')
447
+ return [];
448
+ const stale = [];
449
+ for (const t of topics) {
450
+ const e = index[t.slug];
451
+ if (e === undefined || e.model !== embedFn.model || e.dim !== e.vec.length || !(e.dim > 0))
452
+ stale.push(t.slug);
453
+ }
454
+ return stale;
455
+ }
456
+ // --- ③ nearFloor semantic rerank (A4 决议 2, spec §3.1) ---------------------
457
+ // Cosine reordering of the nearFloor band ONLY (0.20–0.30 — the 93%
458
+ // near-miss mass). Thresholds, gateScore and the structural gate are
459
+ // untouched: the reranker reorders and tags, never re-scores.
460
+ export const RERANK_FLOOR = 0.2;
461
+ export const RERANK_CEILING = 0.3;
462
+ export const SEMANTIC_RERANK_REASON = 'semantic-rerank';
463
+ /** Cosine similarity; 0 for dim mismatch or zero vectors (never throws). */
464
+ export function cosineSimilarity(a, b) {
465
+ if (a.length === 0 || a.length !== b.length)
466
+ return 0;
467
+ let dot = 0;
468
+ let na = 0;
469
+ let nb = 0;
470
+ for (let i = 0; i < a.length; i += 1) {
471
+ dot += a[i] * b[i];
472
+ na += a[i] * a[i];
473
+ nb += b[i] * b[i];
474
+ }
475
+ if (na === 0 || nb === 0)
476
+ return 0;
477
+ return dot / Math.sqrt(na * nb);
478
+ }
479
+ /**
480
+ * Reorder the nearFloor band (0.20–0.30 by gateScore) by cosine similarity
481
+ * against the query vector. Band members with a usable index vector sort by
482
+ * similarity (stable — ties keep input order) and gain the
483
+ * `semantic-rerank` reason plus their similarity; everyone else — out of
484
+ * band, unindexed, model-mismatched — passes through UNCHANGED at the tail
485
+ * in input order. Scores are never touched.
486
+ *
487
+ * Degradation is explicit and total: provider/model unconfigured, probe
488
+ * failure, missing query vector or missing index all return the input as-is
489
+ * with `reranked:false` and a passthroughReason (原序透传,不许静默 — the
490
+ * caller logs it into the degraded status). `queryText` rides the signature
491
+ * for provenance/debug parity with the caller's logging; the v1 math is
492
+ * vector-only.
493
+ */
494
+ export function rerankNearFloor(queryText, queryVec, nearFloorCandidates, index, opts = {}) {
495
+ void queryText; // v1 math is vector-only — see doc above
496
+ if (nearFloorCandidates.length === 0) {
497
+ return { hits: [], reranked: false, passthroughReason: 'no-candidates' };
498
+ }
499
+ if (!Array.isArray(queryVec) || queryVec.length === 0) {
500
+ return { hits: [...nearFloorCandidates], reranked: false, passthroughReason: 'no-query-vector' };
501
+ }
502
+ if (Object.keys(index).length === 0) {
503
+ return { hits: [...nearFloorCandidates], reranked: false, passthroughReason: 'empty-index' };
504
+ }
505
+ const floor = opts.floor ?? RERANK_FLOOR;
506
+ const ceiling = opts.ceiling ?? RERANK_CEILING;
507
+ const simOf = new Map();
508
+ for (const hit of nearFloorCandidates) {
509
+ const entry = index[hit.slug];
510
+ const usable = entry !== undefined &&
511
+ entry.dim === entry.vec.length &&
512
+ entry.dim > 0 &&
513
+ entry.dim === queryVec.length &&
514
+ (opts.expectedModel === undefined || entry.model === opts.expectedModel);
515
+ simOf.set(hit, usable ? cosineSimilarity(queryVec, entry.vec) : null);
516
+ }
517
+ const band = nearFloorCandidates.filter((h) => {
518
+ const sim = simOf.get(h);
519
+ return sim !== null && sim !== undefined && h.gateScore !== undefined && h.gateScore >= floor && h.gateScore < ceiling;
520
+ });
521
+ if (band.length === 0) {
522
+ return { hits: [...nearFloorCandidates], reranked: false, passthroughReason: 'no-rerankable-candidates' };
523
+ }
524
+ const rerankedSet = new Set(band);
525
+ const tail = nearFloorCandidates.filter((h) => !rerankedSet.has(h));
526
+ // Stable sort: V8's Array#sort keeps input order for equal similarities.
527
+ const ordered = [...band].sort((x, y) => simOf.get(y) - simOf.get(x));
528
+ return {
529
+ hits: [
530
+ ...ordered.map((h) => {
531
+ const sim = simOf.get(h);
532
+ return { ...h, reasons: [...h.reasons, SEMANTIC_RERANK_REASON], similarity: Math.round(sim * 10_000) / 10_000 };
533
+ }),
534
+ ...tail.map((h) => ({ ...h })),
535
+ ],
536
+ reranked: true,
537
+ };
538
+ }
package/lib/service.d.ts CHANGED
@@ -10,7 +10,7 @@ import { type RetrievableTopic, type SearchOutcome } from './retrieval.ts';
10
10
  import { type AssembleResult } from './digest.ts';
11
11
  import type { BundleStore, Observation, SaveResult } from './store.ts';
12
12
  import type { TopicsConfigValue } from './config.ts';
13
- import { type AggregateStats, type QueryBuildShape, type UsageSignal } from './ilog.ts';
13
+ import { type AggregateStats, type QueryBuildShape, type SlowOutcome, type UsageSignal } from './ilog.ts';
14
14
  import type { Sync } from './sync.ts';
15
15
  export interface RetrieveOutcome {
16
16
  outcome: SearchOutcome;
@@ -33,6 +33,42 @@ export interface SlowDelivery {
33
33
  queryBuild: QueryBuildShape;
34
34
  model: string;
35
35
  ms: number;
36
+ /** Producing run's disposition (A4 全留痕) — copied into ilog.slowOutcome. */
37
+ outcome?: SlowOutcome;
38
+ }
39
+ /**
40
+ * One structured observation from the new repo's signal layer (B1 §2.1
41
+ * whitelist): FEATURES ONLY, never conversation text (红线⑤). Unknown caller
42
+ * fields cannot pass — the whitelist copy in feedObservations is the gate.
43
+ */
44
+ export interface StructuredObservation {
45
+ /** Signal type from the B1 closed list (`tool-error`, `turn-end-negative`,
46
+ * `unused-injection`, `repeat-call`, `interruption-pair`, `jev-calibration`). */
47
+ type: string;
48
+ /** ISO instant; defaults to now. */
49
+ at?: string;
50
+ sessionId?: string;
51
+ /** Bounded scalar feature fields (toolName/errorName/count/band/...). */
52
+ detail?: Record<string, string | number | boolean>;
53
+ }
54
+ export interface FeedResult {
55
+ accepted: number;
56
+ duplicates: number;
57
+ rejected: number;
58
+ ids: string[];
59
+ /** Present when the call itself was refused (missing/blank actor). */
60
+ error?: 'unauthorized';
61
+ }
62
+ export interface StoreResult {
63
+ ok: boolean;
64
+ slug: string;
65
+ /** Refusal reason; absent on success. */
66
+ reason?: 'unauthorized' | 'not-found' | 'not-a-lesson' | 'invalid-state';
67
+ /** confidence before the transition (when the lesson was read). */
68
+ from?: okf.LessonConfidence;
69
+ /** removed only: bundle-relative archive path (git-revertable). */
70
+ archivedTo?: string;
71
+ committed?: boolean;
36
72
  }
37
73
  export declare class TopicsService {
38
74
  private cache;
@@ -101,6 +137,22 @@ export declare class TopicsService {
101
137
  * the slow lane's recall band stays untouched, mirroring usageBoost).
102
138
  */
103
139
  private zeroOpenCfg;
140
+ /** mtime-cached view of meta/embeddings.json (sync read — hot-lane caller).
141
+ * Missing/corrupt index = {} → the reranker degrades to lexical order. */
142
+ private embedIndexCache;
143
+ private embeddingIndexCacheGet;
144
+ /** Shadow bypass ledger (spec §8.3): one line per shadow round — feature
145
+ * fields only, never query text (jev-log discipline). Silent on failure;
146
+ * 512KB truncation keeps the newest three quarters. Shared by the package
147
+ * shadow (meta/package-shadow.jsonl) and the lesson shadow (B2,
148
+ * meta/lesson-shadow.jsonl). */
149
+ private appendShadowLedger;
150
+ private packageShadowLedgerPath;
151
+ private lessonShadowLedgerPath;
152
+ /** Boot-time embedding index maintenance (spec §3: boot 校验+增量重建).
153
+ * Provider '' or packageMode off → no-op (default-off byte-for-byte).
154
+ * Full-roster embed on rebuild (A1 §5: ~9.5s for 137 topics). */
155
+ refreshEmbeddingIndex(): Promise<void>;
104
156
  /**
105
157
  * Full retrieval round: search → assemble → log (ADR 0006/0007). The
106
158
  * injection record is written even for zero-hit rounds — the hit-rate
@@ -140,6 +192,7 @@ export declare class TopicsService {
140
192
  echoed: string[];
141
193
  included: string[];
142
194
  slowIncluded: string[];
195
+ lessonIncluded: string[];
143
196
  };
144
197
  /**
145
198
  * topic_open tool path (v4 §4.1): full conclusion / open questions /
@@ -167,9 +220,25 @@ export declare class TopicsService {
167
220
  private attributeOpen;
168
221
  /** Sync roster read (same mtime cache as roster()). */
169
222
  rosterSync(): RetrievableTopic[];
223
+ /** Established, scope-passing lesson entries (B2, spec 卷一 §6.3) — sync,
224
+ * sharing the roster's mtime cache. Injection reads ONLY established
225
+ * lessons here (MJ-3: hint 仅供审批队列展示,永不注入); scope filter =
226
+ * workspace lessons must carry the current workspace id (MJ-2); scope=global
227
+ * and legacy entries without a workspaceId pass (fail-open — the filter's
228
+ * default is permissive, the confidence gate is the hard one). */
229
+ lessonsSync(): (RetrievableTopic & {
230
+ confidence: okf.LessonConfidence;
231
+ source: okf.LessonSource;
232
+ scope: okf.LessonScope;
233
+ workspaceId?: string;
234
+ })[];
170
235
  private readDocSync;
171
236
  private readDoc;
172
- /** topic_save tool path: create or update with actor stamping. */
237
+ /** topic_save tool path: create or update with actor stamping. `kind:
238
+ * 'lesson'` (B2 data plane) switches the OKF type to Lesson and stamps the
239
+ * lesson frontmatter (scope/confidence/source/workspaceId) with the
240
+ * confidence↔status derivation (hint↔draft, established↔stable —
241
+ * confidence is the fact source). */
173
242
  saveTopic(input: {
174
243
  title: string;
175
244
  description?: string;
@@ -178,11 +247,19 @@ export declare class TopicsService {
178
247
  depends?: string[];
179
248
  openQuestions?: string[];
180
249
  impact?: string[];
250
+ /** A2 semantic-link targets; store.saveTopic lands them as body wikilinks. */
251
+ links?: string[];
181
252
  status?: okf.TopicStatus;
182
253
  conclusion?: string;
183
254
  recommendations?: string;
184
255
  source?: 'model' | 'distill';
185
256
  slug?: string;
257
+ /** B2 lesson data plane: entry kind (default topic). */
258
+ kind?: okf.TopicKind;
259
+ scope?: okf.LessonScope;
260
+ confidence?: okf.LessonConfidence;
261
+ /** Lesson provenance (input.source above is the tool-path tag). */
262
+ lessonSource?: okf.LessonSource;
186
263
  }): Promise<SaveResult & {
187
264
  slug: string;
188
265
  }>;
@@ -192,6 +269,30 @@ export declare class TopicsService {
192
269
  sessionId?: string;
193
270
  source?: 'model' | 'auto';
194
271
  }): Promise<Observation>;
272
+ /**
273
+ * ① 观察投喂: structured observations (B1 §2.1 whitelist, FEATURES ONLY —
274
+ * 对话原文禁入, 红线⑤) appended to the observations stream with the `fed`
275
+ * provenance marker and `kind: 'signal'`; each line carries `by: actor`.
276
+ * The whitelist copy drops unknown caller fields, caps detail entries and
277
+ * string sizes, and renders a sorted feature line as the observation text.
278
+ * Duplicates (same type+session+detail in the recent tail) are counted, not
279
+ * appended (重复观察由原仓去重). Fail-soft: one malformed entry is counted
280
+ * as rejected, never sinks the batch.
281
+ */
282
+ feedObservations(obs: readonly StructuredObservation[], opts: {
283
+ actor: string;
284
+ }): Promise<FeedResult>;
285
+ /**
286
+ * ② lesson 状态变更 (B2/卷二 §6.2): hint→established / →deprecated /
287
+ * →removed. established flips confidence (the FACT source) and derives
288
+ * status (established↔stable); deprecated flips status only (the TTL sweep
289
+ * owns the rest); removed = archive FIRST (byte-exact copy to
290
+ * `archive/lessons/` inside the bundle, git-revertable — 红线④) then out of
291
+ * the roster, one commit. Each call lands a `seam-lesson-status` audit row.
292
+ */
293
+ setLessonStatus(slug: string, to: 'established' | 'deprecated' | 'removed', opts: {
294
+ actor: string;
295
+ }): Promise<StoreResult>;
195
296
  search(query: string, topK?: number): Promise<SearchOutcome>;
196
297
  history(slug: string, limit?: number): Promise<{
197
298
  entries: {