@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/store.d.ts CHANGED
@@ -19,18 +19,31 @@ export interface TopicMeta {
19
19
  depends: string[];
20
20
  generatedAt: string;
21
21
  }
22
- export type ObservationKind = 'decision' | 'finding' | 'constraint' | 'question' | 'turn';
22
+ /** Bundle entry kinds (B2, spec §6.5 落点表: store kind 枚举). Canonical
23
+ * model lives in okf.ts next to TopicStatus; re-exported here as the store
24
+ * surface the spec anchors on. */
25
+ export type TopicKind = okf.TopicKind;
26
+ export declare const topicKindOf: typeof okf.topicKindOf;
27
+ export type ObservationKind = 'decision' | 'finding' | 'constraint' | 'question' | 'turn' | 'signal';
28
+ /**
29
+ * Where an observation came from. `fed` marks entries that arrived through
30
+ * the feedObservations write seam (B-line dual-write, spec 卷二 §6.2) — the
31
+ * provenance marker the distill lane and audits key on.
32
+ */
33
+ export type ObservationSource = 'model' | 'auto' | 'fed';
23
34
  export interface Observation {
24
35
  id: string;
25
36
  at: string;
26
37
  kind: ObservationKind;
27
- source: 'model' | 'auto';
38
+ source: ObservationSource;
28
39
  text: string;
29
40
  sessionId?: string;
30
41
  distilled: boolean;
31
42
  distilledInto?: string[];
32
43
  /** Failed distill attempts: fed to the model but consumed by no op (GC counter). */
33
44
  attempts?: number;
45
+ /** Write-seam audit: the feedObservations caller identity (opts.actor). */
46
+ by?: string;
34
47
  }
35
48
  export interface SaveResult {
36
49
  slug: string;
@@ -110,10 +123,19 @@ export declare class BundleStore {
110
123
  * Write a topic (atomic), regenerate the index, and commit both — one
111
124
  * commit per conclusion change (ADR 0003). `slug` in the doc's depends is
112
125
  * NOT normalized here; callers pass bundle-relative paths.
126
+ *
127
+ * `links` (A2, optional): whitelist-surviving semantic targets from the
128
+ * distill lane. Applied INSIDE this one write transaction — the topic and
129
+ * its edges land together (先落 topic 再建边); the Links section lands as
130
+ * body wikilinks and the index/backlinks regeneration below (same
131
+ * transaction) recomputes the edge index over them. Normalized and
132
+ * self/dedup-guarded here too, so any caller benefits from the same guard
133
+ * the distill lane already ran.
113
134
  */
114
135
  saveTopic(input: {
115
136
  slug: string;
116
137
  doc: okf.TopicDoc;
138
+ links?: readonly string[];
117
139
  }, opts: {
118
140
  message: string;
119
141
  actor?: string;
@@ -126,6 +148,19 @@ export declare class BundleStore {
126
148
  * safe write path. Returns false when the slug doesn't exist (idempotent).
127
149
  */
128
150
  deleteTopic(slug: string, message: string): Promise<boolean>;
151
+ /**
152
+ * Lesson removal seam (B2/卷二 §6.2 MJ-B, spec 红线④): archive BEFORE the
153
+ * roster removal — the file is copied byte-exact to `archive/lessons/`
154
+ * inside the bundle and only then deleted from `topics/`, both landing in
155
+ * ONE git commit so `git revert` restores the lesson exactly (自动写资产
156
+ * 必须可回滚). Returns undefined when the slug doesn't exist (idempotent).
157
+ */
158
+ archiveLesson(slug: string, opts: {
159
+ message: string;
160
+ }): Promise<{
161
+ archivedTo: string;
162
+ committed: boolean;
163
+ } | undefined>;
129
164
  private regenerateIndex;
130
165
  /**
131
166
  * Reverse-reference index: slug → who references it and how (`depends`
@@ -140,9 +175,10 @@ export declare class BundleStore {
140
175
  }[]>>;
141
176
  appendObservation(input: {
142
177
  kind: ObservationKind;
143
- source: 'model' | 'auto';
178
+ source: ObservationSource;
144
179
  text: string;
145
180
  sessionId?: string;
181
+ by?: string;
146
182
  }): Promise<Observation>;
147
183
  allObservations(limit?: number): Promise<Observation[]>;
148
184
  undistilledObservations(limit?: number): Promise<Observation[]>;
package/lib/store.js CHANGED
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * @module store
10
10
  */
11
- import { readdir, readFile, writeFile, rename, mkdir, appendFile, access, rm, } from 'node:fs/promises';
11
+ import { readdir, readFile, writeFile, rename, mkdir, appendFile, access, rm, copyFile, } from 'node:fs/promises';
12
12
  import { readFileSync } from 'node:fs';
13
13
  import { constants as FsConstants } from 'node:fs';
14
14
  import { dirname, join } from 'node:path';
@@ -16,6 +16,7 @@ import { randomUUID } from 'node:crypto';
16
16
  import * as okf from "./okf.js";
17
17
  import * as gitmod from "./git.js";
18
18
  import { actorFor } from "./paths.js";
19
+ export const topicKindOf = okf.topicKindOf;
19
20
  export class StoreError extends Error {
20
21
  name = 'StoreError';
21
22
  }
@@ -42,6 +43,14 @@ function dedupePaths(paths) {
42
43
  function sameStrings(a, b) {
43
44
  return a.length === b.length && a.every((v, i) => v === b[i]);
44
45
  }
46
+ /**
47
+ * Body heading for caller-supplied semantic links (A2). Links are rendered as
48
+ * `[[wikilinks]]` under this section so the EXISTING link-edge machinery
49
+ * (okf.bodyLinkSlugs → computeBacklinks → retrieval graph walk) indexes them
50
+ * as `via: 'link'` edges — the edge representation gains no new type; the
51
+ * body landing spot is what makes them first-class links for free.
52
+ */
53
+ const LINKS_HEADING = 'Links';
45
54
  export class BundleStore {
46
55
  root;
47
56
  actor;
@@ -285,6 +294,14 @@ export class BundleStore {
285
294
  * Write a topic (atomic), regenerate the index, and commit both — one
286
295
  * commit per conclusion change (ADR 0003). `slug` in the doc's depends is
287
296
  * NOT normalized here; callers pass bundle-relative paths.
297
+ *
298
+ * `links` (A2, optional): whitelist-surviving semantic targets from the
299
+ * distill lane. Applied INSIDE this one write transaction — the topic and
300
+ * its edges land together (先落 topic 再建边); the Links section lands as
301
+ * body wikilinks and the index/backlinks regeneration below (same
302
+ * transaction) recomputes the edge index over them. Normalized and
303
+ * self/dedup-guarded here too, so any caller benefits from the same guard
304
+ * the distill lane already ran.
288
305
  */
289
306
  async saveTopic(input, opts) {
290
307
  return this.enqueue(async () => {
@@ -292,9 +309,24 @@ export class BundleStore {
292
309
  const file = this.topicPath(slug);
293
310
  const created = opts.created ?? !(await existsSync(file));
294
311
  const now = new Date().toISOString();
312
+ let body = input.doc.body;
313
+ if (input.links !== undefined && input.links.length > 0) {
314
+ const targets = [];
315
+ for (const raw of input.links) {
316
+ if (typeof raw !== 'string')
317
+ continue;
318
+ const target = okf.normalizeLinkTarget(raw);
319
+ if (target === undefined || target === slug || targets.includes(target))
320
+ continue;
321
+ targets.push(target);
322
+ }
323
+ if (targets.length > 0) {
324
+ body = okf.setSection(body, LINKS_HEADING, targets.map((t) => `- [[${t}]]`).join('\n'));
325
+ }
326
+ }
295
327
  const doc = {
296
328
  fm: { ...input.doc.fm, generated: { by: opts.actor ?? this.actor, at: now } },
297
- body: input.doc.body,
329
+ body,
298
330
  };
299
331
  const raw = okf.serializeTopicDoc(doc);
300
332
  await atomicWrite(file, raw);
@@ -321,6 +353,30 @@ export class BundleStore {
321
353
  return true;
322
354
  });
323
355
  }
356
+ /**
357
+ * Lesson removal seam (B2/卷二 §6.2 MJ-B, spec 红线④): archive BEFORE the
358
+ * roster removal — the file is copied byte-exact to `archive/lessons/`
359
+ * inside the bundle and only then deleted from `topics/`, both landing in
360
+ * ONE git commit so `git revert` restores the lesson exactly (自动写资产
361
+ * 必须可回滚). Returns undefined when the slug doesn't exist (idempotent).
362
+ */
363
+ async archiveLesson(slug, opts) {
364
+ return this.enqueue(async () => {
365
+ const clean = okf.slugify(slug);
366
+ const file = this.topicPath(clean);
367
+ if (!(await existsSync(file)))
368
+ return undefined;
369
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
370
+ const archivedTo = `archive/lessons/${clean}-${stamp}.md`;
371
+ const absTo = join(this.root, archivedTo);
372
+ await mkdir(dirname(absTo), { recursive: true });
373
+ await copyFile(file, absTo);
374
+ await rm(file);
375
+ await this.regenerateIndex();
376
+ const committed = await this.commit([archivedTo, `topics/${clean}.md`, 'index.md'], opts.message);
377
+ return { archivedTo, committed };
378
+ });
379
+ }
324
380
  async regenerateIndex() {
325
381
  const metas = await this.listTopics();
326
382
  const entries = metas.map((m) => ({
@@ -400,6 +456,8 @@ export class BundleStore {
400
456
  };
401
457
  if (input.sessionId !== undefined)
402
458
  obs.sessionId = input.sessionId;
459
+ if (input.by !== undefined)
460
+ obs.by = input.by;
403
461
  // Behind the store-wide queue: markDistilled / recordUnconsumed rewrite
404
462
  // the whole JSONL file — an unsynchronized append here could interleave
405
463
  // with a rewrite and lose either the new observation or the rewrite.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-topics-memory",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "dsh-plugin: quality-first topic memory for dsh — a topic keeps the starting question, conclusion, impact and dependencies, not the process; local-first git-traced OKF bundle, budgeted LLM-free injection",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -42,7 +42,7 @@
42
42
  "build": "tsc -p tsconfig.json",
43
43
  "check": "tsc --noEmit -p tsconfig.json",
44
44
  "pretest": "tsc -p tsconfig.json",
45
- "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs test/legacy-import.test.mjs test/jev-config.test.mjs test/jev-gate.test.mjs test/jev-client.test.mjs test/jev-log.test.mjs test/jev-dual.test.mjs test/jev-fastgate.test.mjs test/zero-open-decay.test.mjs",
45
+ "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/digest-package.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs test/legacy-import.test.mjs test/jev-config.test.mjs test/jev-gate.test.mjs test/jev-client.test.mjs test/jev-log.test.mjs test/jev-dual.test.mjs test/jev-fastgate.test.mjs test/zero-open-decay.test.mjs test/embedding.test.mjs test/embedding-index.test.mjs test/query-sanitize.test.mjs test/semantic-rerank.test.mjs test/inject-config.test.mjs test/distill-links.test.mjs test/store-links.test.mjs test/lesson-seams.test.mjs",
46
46
  "smoke": "node scripts/smoke-boot.mjs"
47
47
  },
48
48
  "dependencies": {},
package/src/config.ts CHANGED
@@ -105,6 +105,28 @@ export const TopicsConfig = z.object({
105
105
  /** laya-serve systemone endpoint for the pace-maker (default: the local
106
106
  * laya-serve bind from the laya repo's serve mode). */
107
107
  jevLayaUrl: z.string().default('http://127.0.0.1:8000/v1/systemone').volatile(),
108
+ /** Local embedding provider (A4 semantic reranker, spec §3.1): ''
109
+ * sentinel = the whole semantic layer is OFF (pure lexical); 'ollama' =
110
+ * the local ollama embed API (localhost is the ADR 0017 local exception).
111
+ * v1 ships no remote provider — a remote embedding may only ever enter a
112
+ * slow-lane/offline path, never the fast lane (red line ①). */
113
+ embeddingProvider: z.string().default('').volatile(),
114
+ /** Embedding model; '' = provider default (ollama → nomic-embed-text),
115
+ * same sentinel pattern as distillModel. */
116
+ embeddingModel: z.string().default('').volatile(),
117
+ /** Injection-package master gate (A-line, spec §2/§3): off | shadow | on.
118
+ * Carries the WHOLE new-behavior surface — query sanitization, the
119
+ * three-layer package, nearFloor semantic rerank, degradation. off = zero
120
+ * behavior change anywhere (byte-for-byte legacy); shadow = compute +
121
+ * bypass-ledger only, nothing lands in context; on = engage. */
122
+ packageMode: z.string().default('off').volatile(),
123
+ /** Lesson injection gate (B2, spec §7 C1 MJ-4): off | shadow | on, default
124
+ * off. off = established lessons neither inject nor shadow (zero injection-
125
+ * side behavior); shadow = the lesson priority seat double-computes into
126
+ * the bypass ledger (meta/lesson-shadow.jsonl), never lands in context;
127
+ * on = established lessons inject. Only the injection side lives here —
128
+ * the lesson loop (distill/approval) is the new repo's improveLoop gate. */
129
+ lessonInject: z.string().default('off').volatile(),
108
130
  })
109
131
 
110
132
  export type TopicsConfigValue = {
@@ -149,6 +171,16 @@ export type TopicsConfigValue = {
149
171
  jevSecretFile?: string
150
172
  jevLayaFallback?: boolean
151
173
  jevLayaUrl?: string
174
+ /** Injection-quality line keys (spec §7). Optional in this TS view only
175
+ * (same sentinel pattern as the jev keys): index.ts's bare-harness
176
+ * DEFAULTS literal predates them — consumers must treat undefined as the
177
+ * documented default ('' / '' / 'off' = every new behavior zeroed). */
178
+ embeddingProvider?: string
179
+ embeddingModel?: string
180
+ packageMode?: 'off' | 'shadow' | 'on'
181
+ /** Lesson injection gate (B2). Optional in this TS view only (sentinel
182
+ * pattern as above) — consumers treat undefined as 'off'. */
183
+ lessonInject?: 'off' | 'shadow' | 'on'
152
184
  }
153
185
 
154
186
  export const CONFIG_KEYS = [
@@ -186,6 +218,10 @@ export const CONFIG_KEYS = [
186
218
  'jevSecretFile',
187
219
  'jevLayaFallback',
188
220
  'jevLayaUrl',
221
+ 'embeddingProvider',
222
+ 'embeddingModel',
223
+ 'packageMode',
224
+ 'lessonInject',
189
225
  ] as const
190
226
 
191
227
  export type ConfigKey = (typeof CONFIG_KEYS)[number]
@@ -257,10 +293,23 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
257
293
  }
258
294
  case 'distillProvider':
259
295
  case 'distillModel':
296
+ case 'embeddingModel':
260
297
  case 'jevModel':
261
298
  case 'jevSecretFile':
262
299
  case 'jevLayaUrl':
263
300
  return raw
301
+ case 'embeddingProvider': {
302
+ if (raw === '' || raw === 'ollama') return raw
303
+ return { error: 'embedding-provider 仅支持 ollama(留空=语义层关;远程 provider 不进快道)' }
304
+ }
305
+ case 'packageMode': {
306
+ if (raw === 'off' || raw === 'shadow' || raw === 'on') return raw
307
+ return { error: 'package-mode 取值 off|shadow|on' }
308
+ }
309
+ case 'lessonInject': {
310
+ if (raw === 'off' || raw === 'shadow' || raw === 'on') return raw
311
+ return { error: 'lesson-inject 取值 off|shadow|on' }
312
+ }
264
313
  case 'jevLayaFallback': {
265
314
  if (raw === 'on' || raw === 'true') return true
266
315
  if (raw === 'off' || raw === 'false') return false