@aiwayds/dsh-topics-memory 0.23.0 → 0.24.1
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 +18 -1
- package/lib/config.js +13 -0
- package/lib/digest.d.ts +38 -5
- package/lib/digest.js +57 -15
- package/lib/ilog.d.ts +14 -0
- package/lib/index.d.ts +9 -1
- package/lib/index.js +1 -0
- 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 +4 -2
- package/lib/pipeline-events.js +2 -0
- package/lib/service.d.ts +88 -3
- package/lib/service.js +435 -19
- package/lib/store.d.ts +30 -3
- package/lib/store.js +28 -1
- package/package.json +2 -2
- package/src/config.ts +15 -0
- package/src/digest.ts +85 -12
- package/src/ilog.ts +9 -0
- package/src/index.ts +9 -0
- package/src/okf.ts +68 -0
- package/src/paths.ts +15 -0
- package/src/pipeline-events.ts +6 -2
- package/src/service.ts +476 -21
- package/src/store.ts +45 -3
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
|
}
|
|
@@ -352,6 +353,30 @@ export class BundleStore {
|
|
|
352
353
|
return true;
|
|
353
354
|
});
|
|
354
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
|
+
}
|
|
355
380
|
async regenerateIndex() {
|
|
356
381
|
const metas = await this.listTopics();
|
|
357
382
|
const entries = metas.map((m) => ({
|
|
@@ -431,6 +456,8 @@ export class BundleStore {
|
|
|
431
456
|
};
|
|
432
457
|
if (input.sessionId !== undefined)
|
|
433
458
|
obs.sessionId = input.sessionId;
|
|
459
|
+
if (input.by !== undefined)
|
|
460
|
+
obs.by = input.by;
|
|
434
461
|
// Behind the store-wide queue: markDistilled / recordUnconsumed rewrite
|
|
435
462
|
// the whole JSONL file — an unsynchronized append here could interleave
|
|
436
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.
|
|
3
|
+
"version": "0.24.1",
|
|
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/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",
|
|
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
|
@@ -120,6 +120,13 @@ export const TopicsConfig = z.object({
|
|
|
120
120
|
* behavior change anywhere (byte-for-byte legacy); shadow = compute +
|
|
121
121
|
* bypass-ledger only, nothing lands in context; on = engage. */
|
|
122
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(),
|
|
123
130
|
})
|
|
124
131
|
|
|
125
132
|
export type TopicsConfigValue = {
|
|
@@ -171,6 +178,9 @@ export type TopicsConfigValue = {
|
|
|
171
178
|
embeddingProvider?: string
|
|
172
179
|
embeddingModel?: string
|
|
173
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'
|
|
174
184
|
}
|
|
175
185
|
|
|
176
186
|
export const CONFIG_KEYS = [
|
|
@@ -211,6 +221,7 @@ export const CONFIG_KEYS = [
|
|
|
211
221
|
'embeddingProvider',
|
|
212
222
|
'embeddingModel',
|
|
213
223
|
'packageMode',
|
|
224
|
+
'lessonInject',
|
|
214
225
|
] as const
|
|
215
226
|
|
|
216
227
|
export type ConfigKey = (typeof CONFIG_KEYS)[number]
|
|
@@ -295,6 +306,10 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
|
|
|
295
306
|
if (raw === 'off' || raw === 'shadow' || raw === 'on') return raw
|
|
296
307
|
return { error: 'package-mode 取值 off|shadow|on' }
|
|
297
308
|
}
|
|
309
|
+
case 'lessonInject': {
|
|
310
|
+
if (raw === 'off' || raw === 'shadow' || raw === 'on') return raw
|
|
311
|
+
return { error: 'lesson-inject 取值 off|shadow|on' }
|
|
312
|
+
}
|
|
298
313
|
case 'jevLayaFallback': {
|
|
299
314
|
if (raw === 'on' || raw === 'true') return true
|
|
300
315
|
if (raw === 'off' || raw === 'false') return false
|
package/src/digest.ts
CHANGED
|
@@ -110,6 +110,8 @@ export interface AssembleResult {
|
|
|
110
110
|
dropped: { slug: string; reason: string }[]
|
|
111
111
|
/** Slow-lane pointers that entered the context (v4 §4.2). */
|
|
112
112
|
slowIncluded?: string[]
|
|
113
|
+
/** Established lessons that entered the context (B2, spec 卷一 §6.3). */
|
|
114
|
+
lessonIncluded?: string[]
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
export interface AssembleConfig {
|
|
@@ -126,6 +128,36 @@ export interface SlowPointerInput {
|
|
|
126
128
|
why: string
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
/**
|
|
132
|
+
* One established-lesson pointer (B2, spec 卷一 §6.3): flat-mode normal-entry
|
|
133
|
+
* rendering with the machine-recognizable source marker (MJ-C combination
|
|
134
|
+
* matrix — lessonInject=on without the A3 package injects lessons as plain
|
|
135
|
+
* entries, tagged `lesson source:<kind>`).
|
|
136
|
+
*/
|
|
137
|
+
export interface LessonPointerInput {
|
|
138
|
+
slug: string
|
|
139
|
+
title: string
|
|
140
|
+
status: string
|
|
141
|
+
description?: string
|
|
142
|
+
score: number
|
|
143
|
+
source: string
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The `(lesson source:x)` marker on lesson entries — flat AND package form. */
|
|
147
|
+
export function lessonSourceTag(source: string): string {
|
|
148
|
+
return `lesson source:${source}`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** One flat lesson entry: same two-line pointer shape + the source marker. */
|
|
152
|
+
export function lessonEntryText(input: LessonPointerInput, budgetTokens: number): { text: string; usedTokens: number } {
|
|
153
|
+
const lines: string[] = []
|
|
154
|
+
lines.push(`### ${input.title} [${input.status}] (topics:${input.slug} score:${input.score.toFixed(2)}) (${lessonSourceTag(input.source)})`)
|
|
155
|
+
const one = (input.description ?? '').replace(/\s+/g, ' ').trim()
|
|
156
|
+
if (one !== '') lines.push(truncateToTokens(one, Math.max(16, budgetTokens - 32)).text)
|
|
157
|
+
const text = lines.join('\n')
|
|
158
|
+
return { text, usedTokens: estimateTokens(text) }
|
|
159
|
+
}
|
|
160
|
+
|
|
129
161
|
/**
|
|
130
162
|
* One slow-lane pointer: same shape as a fast pointer plus the mandatory why
|
|
131
163
|
* line (v4 §4.2 — 每条带 why 行). No score: the gate here was the LLM rerank,
|
|
@@ -153,8 +185,9 @@ function wrapBlocks(blocks: readonly string[]): string {
|
|
|
153
185
|
|
|
154
186
|
/**
|
|
155
187
|
* Shared greedy packing: fast entries first (score-ordered), then optional
|
|
156
|
-
* slow-lane pointers,
|
|
157
|
-
*
|
|
188
|
+
* slow-lane pointers, then optional lesson entries (B2: flat-mode normal
|
|
189
|
+
* entry slots), all under one total budget (v4 B2: shared 600 — the slow lane
|
|
190
|
+
* spends whatever the fast lane leaves; 分账终值 is a P3 question).
|
|
158
191
|
* Zero blocks → empty text (ADR 0006: 零命中零注入).
|
|
159
192
|
*/
|
|
160
193
|
function packInjection(
|
|
@@ -162,12 +195,14 @@ function packInjection(
|
|
|
162
195
|
cfg: AssembleConfig,
|
|
163
196
|
render: (entry: DigestInput, budget: number) => { text: string; usedTokens: number },
|
|
164
197
|
slow?: readonly SlowPointerInput[],
|
|
198
|
+
lessons?: readonly LessonPointerInput[],
|
|
165
199
|
): AssembleResult {
|
|
166
|
-
if (entries.length === 0 && (slow === undefined || slow.length === 0)) {
|
|
200
|
+
if (entries.length === 0 && (slow === undefined || slow.length === 0) && (lessons === undefined || lessons.length === 0)) {
|
|
167
201
|
return { text: '', usedTokens: 0, included: [], dropped: [] }
|
|
168
202
|
}
|
|
169
203
|
const included: string[] = []
|
|
170
204
|
const slowIncluded: string[] = []
|
|
205
|
+
const lessonIncluded: string[] = []
|
|
171
206
|
const dropped: { slug: string; reason: string }[] = []
|
|
172
207
|
const blocks: string[] = []
|
|
173
208
|
let used = estimateTokens(`${WRAPPER_OPEN}\n${WRAPPER_INTRO}\n\n${WRAPPER_CLOSE}`)
|
|
@@ -201,35 +236,57 @@ function packInjection(
|
|
|
201
236
|
slowIncluded.push(item.slug)
|
|
202
237
|
used += block.usedTokens
|
|
203
238
|
}
|
|
239
|
+
for (const item of lessons ?? []) {
|
|
240
|
+
const budget = Math.min(cfg.perTopicBudget, cfg.totalBudget - used)
|
|
241
|
+
if (budget < 24) {
|
|
242
|
+
dropped.push({ slug: item.slug, reason: 'lesson-budget' })
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
const block = lessonEntryText(item, budget)
|
|
246
|
+
if (used + block.usedTokens > cfg.totalBudget) {
|
|
247
|
+
dropped.push({ slug: item.slug, reason: 'lesson-budget' })
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
blocks.push(block.text)
|
|
251
|
+
lessonIncluded.push(item.slug)
|
|
252
|
+
used += block.usedTokens
|
|
253
|
+
}
|
|
204
254
|
if (blocks.length === 0) return { text: '', usedTokens: 0, included: [], dropped }
|
|
205
255
|
const text = wrapBlocks(blocks)
|
|
206
256
|
const result: AssembleResult = { text, usedTokens: estimateTokens(text), included, dropped }
|
|
207
257
|
if (slowIncluded.length > 0) result.slowIncluded = slowIncluded
|
|
258
|
+
if (lessonIncluded.length > 0) {
|
|
259
|
+
result.lessonIncluded = lessonIncluded
|
|
260
|
+
result.included = [...included, ...lessonIncluded]
|
|
261
|
+
}
|
|
208
262
|
return result
|
|
209
263
|
}
|
|
210
264
|
|
|
211
265
|
/**
|
|
212
266
|
* Greedily pack pointer entries (hits already score-ordered) under the total
|
|
213
|
-
* budget. Zero hits → empty text (ADR 0006: 零命中零注入).
|
|
267
|
+
* budget. Zero hits → empty text (ADR 0006: 零命中零注入). `lessons` (B2)
|
|
268
|
+
* packs after the slow pointers as flat entries with the source marker.
|
|
214
269
|
*/
|
|
215
270
|
export function assemblePointer(
|
|
216
271
|
entries: readonly DigestInput[],
|
|
217
272
|
cfg: AssembleConfig,
|
|
218
273
|
slow?: readonly SlowPointerInput[],
|
|
274
|
+
lessons?: readonly LessonPointerInput[],
|
|
219
275
|
): AssembleResult {
|
|
220
|
-
return packInjection(entries, cfg, pointerEntry, slow)
|
|
276
|
+
return packInjection(entries, cfg, pointerEntry, slow, lessons)
|
|
221
277
|
}
|
|
222
278
|
|
|
223
279
|
/**
|
|
224
|
-
* Greedily pack per-topic digests (hits already score-ordered) under the
|
|
225
|
-
*
|
|
280
|
+
* Greedily pack per-topic digests (hits already score-ordered) under the total
|
|
281
|
+
* budget. Zero hits → empty text (ADR 0006: 零命中零注入). `lessons` (B2).
|
|
226
282
|
*/
|
|
227
283
|
export function assembleInjection(
|
|
228
284
|
entries: readonly DigestInput[],
|
|
229
285
|
cfg: AssembleConfig,
|
|
230
286
|
slow?: readonly SlowPointerInput[],
|
|
287
|
+
lessons?: readonly LessonPointerInput[],
|
|
231
288
|
): AssembleResult {
|
|
232
|
-
return packInjection(entries, cfg, topicDigest, slow)
|
|
289
|
+
return packInjection(entries, cfg, topicDigest, slow, lessons)
|
|
233
290
|
}
|
|
234
291
|
|
|
235
292
|
// ---------------------------------------------------------------------------
|
|
@@ -253,6 +310,15 @@ export interface PackageCandidate {
|
|
|
253
310
|
reasons: string[]
|
|
254
311
|
/** Edge/lane provenance: `depends:dN` | `slow-pick` | `semantic…` | raw. */
|
|
255
312
|
via?: string
|
|
313
|
+
/**
|
|
314
|
+
* Lesson priority seat (B2, spec 卷一 §6.3): sorts FIRST in the direct
|
|
315
|
+
* layer and is exempt from the ≤2 direct-seat cap (still budget-bound) —
|
|
316
|
+
* 「主层 ≤2 席中命中 lesson 至少 1 席」. Absent on every topic candidate.
|
|
317
|
+
*/
|
|
318
|
+
prioritySeat?: boolean
|
|
319
|
+
/** Rendered as a `(${sourceTag})` suffix on the entry head — lesson source
|
|
320
|
+
* marker (`lesson source:correction`), MJ-C. */
|
|
321
|
+
sourceTag?: string
|
|
256
322
|
}
|
|
257
323
|
|
|
258
324
|
export interface PackageInput {
|
|
@@ -401,11 +467,16 @@ function isTrivialQuery(query: string, queryTokens: readonly string[]): boolean
|
|
|
401
467
|
return stripped.length > 0 && stripped.length <= PACKAGE_TRIVIAL_QUERY_CHARS
|
|
402
468
|
}
|
|
403
469
|
|
|
404
|
-
/** Stable within-layer order:
|
|
470
|
+
/** Stable within-layer order: prioritySeat first (lesson 优先席), then score
|
|
471
|
+
* desc, then usage, then input order. */
|
|
405
472
|
function rankStable(list: readonly PackageCandidate[], usage: Map<string, number> | undefined): PackageCandidate[] {
|
|
406
473
|
return list
|
|
407
474
|
.map((c, i) => ({ c, i, u: usage?.get(c.slug) ?? 0 }))
|
|
408
|
-
.sort((a, b) =>
|
|
475
|
+
.sort((a, b) =>
|
|
476
|
+
(b.c.prioritySeat === true ? 1 : 0) - (a.c.prioritySeat === true ? 1 : 0) ||
|
|
477
|
+
b.c.score - a.c.score ||
|
|
478
|
+
b.u - a.u ||
|
|
479
|
+
a.i - b.i)
|
|
409
480
|
.map((x) => x.c)
|
|
410
481
|
}
|
|
411
482
|
|
|
@@ -452,7 +523,7 @@ function directEntryText(
|
|
|
452
523
|
queryTokens: readonly string[],
|
|
453
524
|
budget: number,
|
|
454
525
|
): { text: string; usedTokens: number } {
|
|
455
|
-
const head = `### ${c.title} [${c.status}]`
|
|
526
|
+
const head = `### ${c.title} [${c.status}]${c.sourceTag !== undefined && c.sourceTag !== '' ? ` (${c.sourceTag})` : ''}`
|
|
456
527
|
const why = packageWhyLine(c, queryTokens)
|
|
457
528
|
const warn = c.reasons.includes('conflicted-demoted') ? '⚠ 此条存在未合并冲突,结论可能过期。' : null
|
|
458
529
|
const openBase = `细节: topic_open "${c.slug}"`
|
|
@@ -568,7 +639,9 @@ export function assemblePackage(input: PackageInput, opts?: PackageOpts): Packag
|
|
|
568
639
|
const layers: PackageResult['layers'] = { direct: [], neighborhood: [], weak: [] }
|
|
569
640
|
const directBlocks: string[] = []
|
|
570
641
|
for (const c of direct) {
|
|
571
|
-
|
|
642
|
+
// prioritySeat (lesson 优先席) is exempt from the direct-seat cap — the
|
|
643
|
+
// budget is the only thing that can still cut it.
|
|
644
|
+
if (directBlocks.length >= PACKAGE_DIRECT_MAX && !isEntityAnchored(input.queryTokens, c) && c.prioritySeat !== true) {
|
|
572
645
|
dropped.push({ slug: c.slug, reason: 'direct-cap' })
|
|
573
646
|
continue
|
|
574
647
|
}
|
package/src/ilog.ts
CHANGED
|
@@ -77,6 +77,15 @@ export interface InjectionRecord {
|
|
|
77
77
|
* Written once with the record, never backfilled (ilog append-only).
|
|
78
78
|
*/
|
|
79
79
|
slowOutcome?: SlowOutcome
|
|
80
|
+
/**
|
|
81
|
+
* Lesson gate fields (B2, spec 卷一 §6.3/§7) — present only on rounds where
|
|
82
|
+
* lessonInject is shadow|on AND at least one established lesson matched the
|
|
83
|
+
* query. `lessons` is the trigger-channel match list with whether the entry
|
|
84
|
+
* entered the context; shadow rounds always carry included:false there (the
|
|
85
|
+
* double-compute lands in meta/lesson-shadow.jsonl instead).
|
|
86
|
+
*/
|
|
87
|
+
lessonInject?: 'shadow' | 'on'
|
|
88
|
+
lessons?: { slug: string; score: number; source: string; included: boolean }[]
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
/**
|
package/src/index.ts
CHANGED
|
@@ -259,6 +259,14 @@ interface VolatileRef<T> {
|
|
|
259
259
|
* on bare test harnesses — {@link readConfigValue} accepts both. */
|
|
260
260
|
export type TopicsRuntimeConfig = Partial<{ [K in keyof TopicsConfigValue]: VolatileRef<TopicsConfigValue[K]> | TopicsConfigValue[K] }>
|
|
261
261
|
|
|
262
|
+
/**
|
|
263
|
+
* B-line dual-write seam contract (卷二 §6.2) — shared types are exported by
|
|
264
|
+
* THIS repo (C1 决议 3: no shared lib until a third consumer appears). The
|
|
265
|
+
* new repo calls TopicsService.feedObservations / setLessonStatus through the
|
|
266
|
+
* host process; direct bundle writes are forbidden on its side.
|
|
267
|
+
*/
|
|
268
|
+
export type { StructuredObservation, FeedResult, StoreResult } from './service.ts'
|
|
269
|
+
|
|
262
270
|
/** Snapshot one config slot: live ref → current value, plain value → itself. */
|
|
263
271
|
function readConfigValue(slot: unknown): unknown {
|
|
264
272
|
const ref = slot as { get?: unknown } | undefined
|
|
@@ -1164,6 +1172,7 @@ const DEFAULTS: TopicsConfigValue = {
|
|
|
1164
1172
|
embeddingProvider: '',
|
|
1165
1173
|
embeddingModel: '',
|
|
1166
1174
|
packageMode: 'off',
|
|
1175
|
+
lessonInject: 'off',
|
|
1167
1176
|
usageBoost: 0.15,
|
|
1168
1177
|
pushDebounceSeconds: 45,
|
|
1169
1178
|
}
|
package/src/okf.ts
CHANGED
|
@@ -17,6 +17,43 @@ import { parseYaml, stringifyYaml, YamlError } from './yaml.ts'
|
|
|
17
17
|
|
|
18
18
|
export type TopicStatus = 'draft' | 'stable' | 'deprecated'
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Bundle entry kinds (B2, spec 2026-10-self-improve-topics §6.1): `topic` =
|
|
22
|
+
* factual knowledge (「是什么」), `lesson` = behavioral lesson (「怎么做事」).
|
|
23
|
+
* The kind rides the OKF `type` frontmatter field — the canonical serialized
|
|
24
|
+
* value for a lesson is `Lesson` (matching the `Topic` capitalization
|
|
25
|
+
* convention); recognition is case-insensitive so bundles written by the
|
|
26
|
+
* new repo's loop parse regardless of casing drift.
|
|
27
|
+
*/
|
|
28
|
+
export type TopicKind = 'topic' | 'lesson'
|
|
29
|
+
|
|
30
|
+
/** lesson scope (B2 MJ-2, spec §6.2): workspace lessons inject only in the
|
|
31
|
+
* workspace that produced them (cwd-hash match); global need no filter. */
|
|
32
|
+
export type LessonScope = 'workspace' | 'global'
|
|
33
|
+
/** lesson confidence (B2, spec §6.2): the FACT source — `status` derives from
|
|
34
|
+
* it (hint↔draft, established↔stable); hint never injects (MJ-3). */
|
|
35
|
+
export type LessonConfidence = 'hint' | 'established'
|
|
36
|
+
/** lesson provenance (B2 BL-2, spec §6.2): machine-recognizable source tag;
|
|
37
|
+
* uninstall reclamation keys on source≠manual (卷二 §9). */
|
|
38
|
+
export type LessonSource = 'distilled' | 'correction' | 'manual'
|
|
39
|
+
|
|
40
|
+
/** kind from the raw `type` frontmatter value (case-insensitive `lesson`). */
|
|
41
|
+
export function topicKindOf(type: string): TopicKind {
|
|
42
|
+
return typeof type === 'string' && type.trim().toLowerCase() === 'lesson' ? 'lesson' : 'topic'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The canonical serialized `type` value per kind. */
|
|
46
|
+
export function kindTypeValue(kind: TopicKind): string {
|
|
47
|
+
return kind === 'lesson' ? 'Lesson' : 'Topic'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** confidence 为事实源(B2 Minor):hint↔draft、established↔stable.
|
|
51
|
+
* `deprecated` is a lifecycle endpoint OUTSIDE the confidence axis — callers
|
|
52
|
+
* set it directly (setLessonStatus / consolidation / TTL sweep). */
|
|
53
|
+
export function deriveLessonStatus(confidence: LessonConfidence): TopicStatus {
|
|
54
|
+
return confidence === 'established' ? 'stable' : 'draft'
|
|
55
|
+
}
|
|
56
|
+
|
|
20
57
|
export interface SourceEntry {
|
|
21
58
|
id?: string
|
|
22
59
|
resource: string
|
|
@@ -52,6 +89,15 @@ export interface TopicFrontmatter {
|
|
|
52
89
|
verified?: TrustStamp
|
|
53
90
|
stale_after?: string
|
|
54
91
|
sources?: SourceEntry[]
|
|
92
|
+
// ---- lesson-kind fields (B2, spec §6.2) — present on kind=lesson docs ----
|
|
93
|
+
/** lesson 必带 scope(默认 workspace;global 需显式)。 */
|
|
94
|
+
scope?: LessonScope
|
|
95
|
+
/** confidence 事实源;status 派生(deriveLessonStatus)。默认 hint。 */
|
|
96
|
+
confidence?: LessonConfidence
|
|
97
|
+
/** provenance 标记(卸载回收按 source≠manual)。默认 manual。 */
|
|
98
|
+
source?: LessonSource
|
|
99
|
+
/** 写入时的 workspace 标识(cwd hash,MJ-2);scope=global 时可空。 */
|
|
100
|
+
workspaceId?: string
|
|
55
101
|
/** Producer-defined extras — preserved on round-trip per OKF §4.1. */
|
|
56
102
|
[extra: string]: unknown
|
|
57
103
|
}
|
|
@@ -148,8 +194,25 @@ export function parseTopicDoc(raw: string): TopicDoc {
|
|
|
148
194
|
return s as SourceEntry
|
|
149
195
|
})
|
|
150
196
|
}
|
|
197
|
+
// ---- lesson-kind fields (B2, spec §6.2) — parsed on lesson docs only ----
|
|
198
|
+
// Invalid enum values DEGRADE TO THE DEFAULT (triggers precedent: a producer
|
|
199
|
+
// slip must never invalidate an otherwise sound doc); the seam writers own
|
|
200
|
+
// the closed vocabulary. On lesson docs these four keys join the known set
|
|
201
|
+
// so the extras loop cannot double-store a raw unvalidated value.
|
|
202
|
+
if (topicKindOf(fmRaw.type) === 'lesson') {
|
|
203
|
+
fm.scope = fmRaw.scope === 'global' ? 'global' : 'workspace'
|
|
204
|
+
fm.confidence = fmRaw.confidence === 'established' ? 'established' : 'hint'
|
|
205
|
+
fm.source = fmRaw.source === 'distilled' || fmRaw.source === 'correction' ? fmRaw.source : 'manual'
|
|
206
|
+
if (typeof fmRaw.workspaceId === 'string' && fmRaw.workspaceId !== '') fm.workspaceId = fmRaw.workspaceId
|
|
207
|
+
}
|
|
151
208
|
// Preserve unknown producer-defined keys verbatim (OKF §4.1 extensions).
|
|
152
209
|
const known = new Set(['type', 'title', 'description', 'tags', 'triggers', 'depends', 'open_questions', 'impact', 'status', 'generated', 'verified', 'stale_after', 'sources'])
|
|
210
|
+
if (fm.scope !== undefined || fm.confidence !== undefined || fm.source !== undefined) {
|
|
211
|
+
known.add('scope')
|
|
212
|
+
known.add('confidence')
|
|
213
|
+
known.add('source')
|
|
214
|
+
known.add('workspaceId')
|
|
215
|
+
}
|
|
153
216
|
for (const [k, v] of Object.entries(fmRaw)) {
|
|
154
217
|
if (!known.has(k)) fm[k] = v
|
|
155
218
|
}
|
|
@@ -174,6 +237,11 @@ export function serializeTopicDoc(doc: TopicDoc): string {
|
|
|
174
237
|
if (fm.verified !== undefined) out.verified = { by: fm.verified.by, at: fm.verified.at }
|
|
175
238
|
if (fm.stale_after !== undefined) out.stale_after = fm.stale_after
|
|
176
239
|
if (fm.sources !== undefined && fm.sources.length > 0) out.sources = fm.sources
|
|
240
|
+
// lesson fields serialize in a fixed slot (deterministic order, spec §6.2).
|
|
241
|
+
if (fm.scope !== undefined) out.scope = fm.scope
|
|
242
|
+
if (fm.confidence !== undefined) out.confidence = fm.confidence
|
|
243
|
+
if (fm.source !== undefined) out.source = fm.source
|
|
244
|
+
if (fm.workspaceId !== undefined) out.workspaceId = fm.workspaceId
|
|
177
245
|
for (const [k, v] of Object.entries(fm)) {
|
|
178
246
|
if (!(k in out)) out[k] = v
|
|
179
247
|
}
|
package/src/paths.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { existsSync, renameSync } from 'node:fs'
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
16
17
|
import { homedir } from 'node:os'
|
|
17
18
|
import { hostname as osHostname } from 'node:os'
|
|
18
19
|
import { join } from 'node:path'
|
|
@@ -102,3 +103,17 @@ export function hostId(): string {
|
|
|
102
103
|
export function actorFor(): string {
|
|
103
104
|
return `agent:dsh-topics-memory@${hostId()}`
|
|
104
105
|
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Current workspace identifier for lesson scope filtering (B2 MJ-2, spec
|
|
109
|
+
* 卷一 §6.2): the sha256 prefix of the process cwd, overridable via
|
|
110
|
+
* $DSH_TOPICS_WORKSPACE_ID (tests / multi-root hosts; the override also lets
|
|
111
|
+
* a host pin one identity across chdirs). 拍板(spec 未钉死来源): cwd hash at
|
|
112
|
+
* read time is the minimal viable source — the same derivation the lesson
|
|
113
|
+
* write path stamps into frontmatter.workspaceId, so no config plumbing.
|
|
114
|
+
*/
|
|
115
|
+
export function currentWorkspaceId(): string {
|
|
116
|
+
const env = process.env.DSH_TOPICS_WORKSPACE_ID?.trim()
|
|
117
|
+
if (env !== undefined && env !== '') return env
|
|
118
|
+
return createHash('sha256').update(process.cwd()).digest('hex').slice(0, 16)
|
|
119
|
+
}
|
package/src/pipeline-events.ts
CHANGED
|
@@ -21,8 +21,10 @@ import { metaDir } from './paths.ts'
|
|
|
21
21
|
|
|
22
22
|
/** Closed vocabulary, spec §8.1 + W-C wave start set. `slow-lane-outcome` is
|
|
23
23
|
* the slow lane's per-run disposition row (quality.ts); the distill/link
|
|
24
|
-
* kinds are the A2/A6 producers' contract — they import this module.
|
|
25
|
-
|
|
24
|
+
* kinds are the A2/A6 producers' contract — they import this module. The
|
|
25
|
+
* two `seam-*` kinds are the B-line dual-write audit trail (卷二 §6.2:
|
|
26
|
+
* caller-identity audit — opts.actor — rides every seam call). */
|
|
27
|
+
export type PipelineEventKind = 'slow-lane-outcome' | 'distill-op-invalid' | 'distill-op-retry-exhausted' | 'link-ref-rejected' | 'seam-observations-fed' | 'seam-lesson-status'
|
|
26
28
|
|
|
27
29
|
/** File NAME inside the bundle's meta dir (path via pipelineEventsFile). */
|
|
28
30
|
export const PIPELINE_EVENTS_FILE = 'pipeline-events.jsonl'
|
|
@@ -47,6 +49,8 @@ const KNOWN_KINDS: ReadonlySet<string> = new Set<string>([
|
|
|
47
49
|
'distill-op-invalid',
|
|
48
50
|
'distill-op-retry-exhausted',
|
|
49
51
|
'link-ref-rejected',
|
|
52
|
+
'seam-observations-fed',
|
|
53
|
+
'seam-lesson-status',
|
|
50
54
|
])
|
|
51
55
|
|
|
52
56
|
export function pipelineEventsFile(bundleDir: string): string {
|