@aiwayds/dsh-topics-memory 0.8.0 → 0.9.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/README.md +18 -0
- package/README.zh.md +18 -0
- package/lib/index.js +5 -2
- package/lib/okf.d.ts +22 -2
- package/lib/okf.js +48 -5
- package/lib/service.js +16 -1
- package/lib/store.d.ts +12 -1
- package/lib/store.js +69 -4
- package/lib/viz.js +4 -1
- package/package.json +3 -2
- package/src/index.ts +8 -2
- package/src/okf.ts +44 -5
- package/src/service.ts +16 -1
- package/src/store.ts +66 -4
- package/src/viz.ts +4 -1
package/README.md
CHANGED
|
@@ -58,6 +58,24 @@ First thing after installing: run `/topics onboard`. The bundle lives at `~/.dsh
|
|
|
58
58
|
|
|
59
59
|
0.6.0 renames the plugin: `@aiwayds/dsh-llmwiki-memory` → `@aiwayds/dsh-topics-memory`, the `/wiki` command family → `/topics`, and the settings namespace `llmwiki` → `topics`. Install the new package (and remove the old one from your profile) — on first start the plugin migrates everything automatically: the data directory `~/.dsh/llmwiki` is renamed to `~/.dsh/topics`, and user-tuned values in the old `llmwiki` settings namespace are copied into `topics`. No manual steps; if a migration step fails the plugin falls back to the old locations and keeps working.
|
|
60
60
|
|
|
61
|
+
## Uninstall
|
|
62
|
+
|
|
63
|
+
Remove the plugin from a profile:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
dsh plugin --profile <name> remove @aiwayds/dsh-topics-memory
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The host reconciles the profile automatically: the `dsh.profile.bundles` entry is spliced and the patch layer is dropped.
|
|
70
|
+
|
|
71
|
+
What stays on disk (kept on purpose — this is your memory):
|
|
72
|
+
|
|
73
|
+
- `~/.dsh/topics/` — the whole topic bundle: topic markdown, `meta/`, and the embedded `.git` repo (the full history; it may carry an `origin` remote — GitHub sync stops with the plugin). To archive the bundle elsewhere, copy or clone this directory as-is.
|
|
74
|
+
- `~/.dsh/settings.yaml` `topics:` section — user overrides. Reinstalling silently reactivates sync including any configured `repo`; delete the section first if you want a clean start.
|
|
75
|
+
- The legacy `llmwiki:` settings section left by the 0.5.x → 0.6.x migration is never auto-deleted; remove it by hand once nothing needs it.
|
|
76
|
+
|
|
77
|
+
Purge everything: back up `~/.dsh/topics` first, then `rm -rf ~/.dsh/topics`.
|
|
78
|
+
|
|
61
79
|
## Configuration
|
|
62
80
|
|
|
63
81
|
First-time setup belongs to `/topics onboard`; day-to-day tuning is `/topics set <key> <value>` (writes the `topics` namespace in `settings.yaml`, effective from the next session). All keys and defaults:
|
package/README.zh.md
CHANGED
|
@@ -58,6 +58,24 @@ Bundle 默认在 `~/.dsh/topics/`(`$DSH_TOPICS_HOME` 可覆盖)。装好后
|
|
|
58
58
|
2. 跑 `/topics onboard`,在 ask-user 面板里走完 模式 / 仓库 / 蒸馏模型 / 注入档位 / 自动观察 五个决定——末步确认才写入;
|
|
59
59
|
3. 正常干活:相关结论每轮自动注入;说「记住…」让模型 `topic_save`;`/topics status` 看健康,`/topics stats` 看注入命中。
|
|
60
60
|
|
|
61
|
+
## 卸载
|
|
62
|
+
|
|
63
|
+
从 profile 移除插件:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
dsh plugin --profile <name> remove @aiwayds/dsh-topics-memory
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
宿主会自动收敛:`dsh.profile.bundles` 条目被移除,patch 层随包消失。
|
|
70
|
+
|
|
71
|
+
以下内容有意保留在磁盘上(这是你的记忆本体):
|
|
72
|
+
|
|
73
|
+
- `~/.dsh/topics/` —— 整个话题包:话题 markdown、`meta/`、以及内嵌的 `.git` 仓库(完整历史;可能带有 `origin` 远端 —— GitHub 同步随插件停止)。要异地归档就原样拷贝/克隆这个目录。
|
|
74
|
+
- `~/.dsh/settings.yaml` 的 `topics:` 段 —— 用户覆盖项。注意:重装会静默恢复同步(包括已配置的 `repo`);想干净重来就先删这一段。
|
|
75
|
+
- 0.5.x → 0.6.x 迁移留下的 `llmwiki:` 旧设置段不会被自动删除;确认无用后手动移除。
|
|
76
|
+
|
|
77
|
+
彻底清除:先备份 `~/.dsh/topics`,再 `rm -rf ~/.dsh/topics`。
|
|
78
|
+
|
|
61
79
|
## 配置
|
|
62
80
|
|
|
63
81
|
首次配置交给 `/topics onboard`;日常微调用 `/topics set <key> <value>`(写 `settings.yaml` 的 `topics` namespace,下次会话启动生效)。全部键与默认值:
|
package/lib/index.js
CHANGED
|
@@ -470,8 +470,11 @@ export function apply(ctx) {
|
|
|
470
470
|
observer.onSessionEvent('dispose', 'agent/disposed', undefined);
|
|
471
471
|
}
|
|
472
472
|
sync.dispose();
|
|
473
|
-
|
|
474
|
-
|
|
473
|
+
// The exit flush (final meta commit + ledger flush) rides the SAME
|
|
474
|
+
// bounded window as the exit distill — fire-and-forget here loses the
|
|
475
|
+
// exit race that window was built to win.
|
|
476
|
+
const exitFlush = sync.commitMeta().then(() => sync.flush()).catch(() => undefined);
|
|
477
|
+
return settleBounded(exitDistill === undefined ? exitFlush : Promise.allSettled([exitDistill, exitFlush]), EXIT_DISTILL_TIMEOUT_MS);
|
|
475
478
|
};
|
|
476
479
|
}, 'topics: lifecycle');
|
|
477
480
|
// ---- /topics command (optional peer) ----
|
package/lib/okf.d.ts
CHANGED
|
@@ -86,7 +86,25 @@ export declare function slugify(title: string): string;
|
|
|
86
86
|
export declare function pathToSlug(path: string): string;
|
|
87
87
|
/** `foo` → `topics/foo.md`. */
|
|
88
88
|
export declare function slugToPath(slug: string): string;
|
|
89
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Strip every `topics/` (or `topics:` — a colon form the wild has produced)
|
|
91
|
+
* prefix and `.md` suffix. The canonical depends entry carries exactly one
|
|
92
|
+
* of each, but bundles written by the save path that re-wrapped preserved
|
|
93
|
+
* values (fixed in the same release) can carry any number — idempotent by
|
|
94
|
+
* construction.
|
|
95
|
+
*/
|
|
96
|
+
export declare function unwrapTopicRef(raw: string): string;
|
|
97
|
+
/**
|
|
98
|
+
* Canonical bundle-relative path for a `depends` entry, accepting every form
|
|
99
|
+
* the wild has produced: bare slug (`foo`), canonical path (`topics/foo.md`),
|
|
100
|
+
* multi-wrapped (`topics/topics/foo.md.md`). Idempotent. A value that still
|
|
101
|
+
* carries a path separator or scheme after unwrapping is not a bundle-internal
|
|
102
|
+
* topic reference and comes back as its unwrapped self (a dead edge for the
|
|
103
|
+
* graph, but never re-wrapped into a fake one); the empty string means the
|
|
104
|
+
* entry unwrapped to nothing and should be dropped by the caller.
|
|
105
|
+
*/
|
|
106
|
+
export declare function normalizeDependsEntry(raw: string): string;
|
|
107
|
+
/** Topic slugs referenced by `depends`, tolerant of wrapped entries. */
|
|
90
108
|
export declare function dependsSlugs(fm: {
|
|
91
109
|
depends: readonly string[];
|
|
92
110
|
}): string[];
|
|
@@ -111,7 +129,9 @@ export declare function normalizeLinkTarget(target: string): string | undefined;
|
|
|
111
129
|
* Topic slugs referenced from a body: `[[wikilinks]]` (with optional
|
|
112
130
|
* `#heading` / `|alias`) plus markdown links pointing at bundle paths.
|
|
113
131
|
* These are the human-authored edges alongside `depends` — both feed the
|
|
114
|
-
* retrieval graph walk and the backlinks index.
|
|
132
|
+
* retrieval graph walk and the backlinks index. Inline code and fenced
|
|
133
|
+
* code blocks are stripped first: topics that document link syntax would
|
|
134
|
+
* otherwise mint permanent phantom edges out of their own examples.
|
|
115
135
|
*/
|
|
116
136
|
export declare function bodyLinkSlugs(body: string): string[];
|
|
117
137
|
/** The conventional profile headings, for validators and tooling. */
|
package/lib/okf.js
CHANGED
|
@@ -216,9 +216,47 @@ export function pathToSlug(path) {
|
|
|
216
216
|
export function slugToPath(slug) {
|
|
217
217
|
return `topics/${slug}.md`;
|
|
218
218
|
}
|
|
219
|
-
/**
|
|
219
|
+
/**
|
|
220
|
+
* Strip every `topics/` (or `topics:` — a colon form the wild has produced)
|
|
221
|
+
* prefix and `.md` suffix. The canonical depends entry carries exactly one
|
|
222
|
+
* of each, but bundles written by the save path that re-wrapped preserved
|
|
223
|
+
* values (fixed in the same release) can carry any number — idempotent by
|
|
224
|
+
* construction.
|
|
225
|
+
*/
|
|
226
|
+
export function unwrapTopicRef(raw) {
|
|
227
|
+
let t = raw.trim();
|
|
228
|
+
for (;;) {
|
|
229
|
+
if (t.startsWith('topics/'))
|
|
230
|
+
t = t.slice('topics/'.length);
|
|
231
|
+
else if (t.startsWith('topics:'))
|
|
232
|
+
t = t.slice('topics:'.length);
|
|
233
|
+
else
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
while (t.endsWith('.md'))
|
|
237
|
+
t = t.slice(0, -3);
|
|
238
|
+
return t;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Canonical bundle-relative path for a `depends` entry, accepting every form
|
|
242
|
+
* the wild has produced: bare slug (`foo`), canonical path (`topics/foo.md`),
|
|
243
|
+
* multi-wrapped (`topics/topics/foo.md.md`). Idempotent. A value that still
|
|
244
|
+
* carries a path separator or scheme after unwrapping is not a bundle-internal
|
|
245
|
+
* topic reference and comes back as its unwrapped self (a dead edge for the
|
|
246
|
+
* graph, but never re-wrapped into a fake one); the empty string means the
|
|
247
|
+
* entry unwrapped to nothing and should be dropped by the caller.
|
|
248
|
+
*/
|
|
249
|
+
export function normalizeDependsEntry(raw) {
|
|
250
|
+
const t = unwrapTopicRef(raw);
|
|
251
|
+
if (t === '')
|
|
252
|
+
return '';
|
|
253
|
+
if (t.includes('/') || t.includes(':'))
|
|
254
|
+
return t;
|
|
255
|
+
return slugToPath(t);
|
|
256
|
+
}
|
|
257
|
+
/** Topic slugs referenced by `depends`, tolerant of wrapped entries. */
|
|
220
258
|
export function dependsSlugs(fm) {
|
|
221
|
-
return fm.depends.map(
|
|
259
|
+
return fm.depends.map(unwrapTopicRef);
|
|
222
260
|
}
|
|
223
261
|
/** Render the auto-generated OKF `index.md` (progressive disclosure, §Bundle). */
|
|
224
262
|
export function renderIndex(entries) {
|
|
@@ -268,16 +306,21 @@ export function normalizeLinkTarget(target) {
|
|
|
268
306
|
* Topic slugs referenced from a body: `[[wikilinks]]` (with optional
|
|
269
307
|
* `#heading` / `|alias`) plus markdown links pointing at bundle paths.
|
|
270
308
|
* These are the human-authored edges alongside `depends` — both feed the
|
|
271
|
-
* retrieval graph walk and the backlinks index.
|
|
309
|
+
* retrieval graph walk and the backlinks index. Inline code and fenced
|
|
310
|
+
* code blocks are stripped first: topics that document link syntax would
|
|
311
|
+
* otherwise mint permanent phantom edges out of their own examples.
|
|
272
312
|
*/
|
|
273
313
|
export function bodyLinkSlugs(body) {
|
|
274
314
|
const out = new Set();
|
|
275
|
-
|
|
315
|
+
const prose = body
|
|
316
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
317
|
+
.replace(/`[^`\n]*`/g, '');
|
|
318
|
+
for (const m of prose.matchAll(WIKILINK)) {
|
|
276
319
|
const slug = normalizeLinkTarget(m[1]);
|
|
277
320
|
if (slug !== undefined)
|
|
278
321
|
out.add(slug);
|
|
279
322
|
}
|
|
280
|
-
for (const m of
|
|
323
|
+
for (const m of prose.matchAll(MD_LINK)) {
|
|
281
324
|
const slug = normalizeLinkTarget(m[1]);
|
|
282
325
|
if (slug !== undefined)
|
|
283
326
|
out.add(slug);
|
package/lib/service.js
CHANGED
|
@@ -446,7 +446,11 @@ export class TopicsService {
|
|
|
446
446
|
type: 'Topic',
|
|
447
447
|
title: input.title,
|
|
448
448
|
tags: dedupeLower(input.tags ?? existing?.fm.tags ?? []),
|
|
449
|
-
|
|
449
|
+
// normalizeDependsEntry, never bare slugToPath: the preserved-on-update
|
|
450
|
+
// fallback feeds already-canonical `topics/<slug>.md` entries back in,
|
|
451
|
+
// and wrapping those again stacked one layer per save (the double-wrap
|
|
452
|
+
// bug repaired on startup by BundleStore.repairDepends).
|
|
453
|
+
depends: dedupePaths((input.depends ?? existing?.fm.depends ?? []).map(okf.normalizeDependsEntry)),
|
|
450
454
|
open_questions: input.openQuestions ?? existing?.fm.open_questions ?? [],
|
|
451
455
|
impact: input.impact ?? existing?.fm.impact ?? [],
|
|
452
456
|
status: input.status ?? existing?.fm.status ?? 'draft',
|
|
@@ -534,6 +538,17 @@ function dedupeLower(tags) {
|
|
|
534
538
|
}
|
|
535
539
|
return out;
|
|
536
540
|
}
|
|
541
|
+
function dedupePaths(paths) {
|
|
542
|
+
const seen = new Set();
|
|
543
|
+
const out = [];
|
|
544
|
+
for (const p of paths) {
|
|
545
|
+
if (p === '' || seen.has(p))
|
|
546
|
+
continue;
|
|
547
|
+
seen.add(p);
|
|
548
|
+
out.push(p);
|
|
549
|
+
}
|
|
550
|
+
return out;
|
|
551
|
+
}
|
|
537
552
|
function sanitizeTriggers(triggers) {
|
|
538
553
|
const out = [];
|
|
539
554
|
for (const t of triggers) {
|
package/lib/store.d.ts
CHANGED
|
@@ -66,7 +66,8 @@ export declare class BundleStore {
|
|
|
66
66
|
/** Last distill-lane run outcome (diagnostics for /topics status and e2e). */
|
|
67
67
|
writeDistillState(state: unknown): Promise<void>;
|
|
68
68
|
readDistillState(): Promise<Record<string, unknown> | undefined>;
|
|
69
|
-
/** Create the directory skeleton, git repo,
|
|
69
|
+
/** Create the directory skeleton, git repo, initial index, and repair
|
|
70
|
+
* wrapped depends entries left by older save paths (idempotent). */
|
|
70
71
|
ensure(): Promise<void>;
|
|
71
72
|
/** Repo initialized and not gitDisabled. */
|
|
72
73
|
hasGit(): Promise<boolean>;
|
|
@@ -76,6 +77,16 @@ export declare class BundleStore {
|
|
|
76
77
|
brokenTopics(): Promise<string[]>;
|
|
77
78
|
readTopic(slug: string): Promise<okf.TopicDoc | undefined>;
|
|
78
79
|
exists(slug: string): Promise<boolean>;
|
|
80
|
+
/**
|
|
81
|
+
* Upgrade repair for bundles written by the save path that stacked another
|
|
82
|
+
* `topics/….md` wrap onto preserved-on-update depends entries, leaving
|
|
83
|
+
* multi-wrapped references (`topics/topics/foo.md.md`) whose edges resolve
|
|
84
|
+
* to nothing. Unwraps in place behind the write queue, regenerates the
|
|
85
|
+
* derived files, and lands the result as one traceable commit; file
|
|
86
|
+
* rewrites happen even without git, the commit just doesn't. Idempotent —
|
|
87
|
+
* every clean startup re-scans and finds nothing to change.
|
|
88
|
+
*/
|
|
89
|
+
private repairDepends;
|
|
79
90
|
/** First free slug: `foo`, then `foo-2`, `foo-3`, … */
|
|
80
91
|
uniqueSlug(base: string): Promise<string>;
|
|
81
92
|
/**
|
package/lib/store.js
CHANGED
|
@@ -28,6 +28,20 @@ async function atomicWrite(file, content) {
|
|
|
28
28
|
await writeFile(tmp, content, 'utf8');
|
|
29
29
|
await rename(tmp, file);
|
|
30
30
|
}
|
|
31
|
+
function dedupePaths(paths) {
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const p of paths) {
|
|
35
|
+
if (p === '' || seen.has(p))
|
|
36
|
+
continue;
|
|
37
|
+
seen.add(p);
|
|
38
|
+
out.push(p);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
function sameStrings(a, b) {
|
|
43
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
44
|
+
}
|
|
31
45
|
export class BundleStore {
|
|
32
46
|
root;
|
|
33
47
|
actor;
|
|
@@ -79,7 +93,8 @@ export class BundleStore {
|
|
|
79
93
|
return undefined;
|
|
80
94
|
}
|
|
81
95
|
}
|
|
82
|
-
/** Create the directory skeleton, git repo,
|
|
96
|
+
/** Create the directory skeleton, git repo, initial index, and repair
|
|
97
|
+
* wrapped depends entries left by older save paths (idempotent). */
|
|
83
98
|
async ensure() {
|
|
84
99
|
await mkdir(this.topicsDir(), { recursive: true });
|
|
85
100
|
await mkdir(this.metaDir(), { recursive: true });
|
|
@@ -89,6 +104,7 @@ export class BundleStore {
|
|
|
89
104
|
if (!(await existsSync(join(this.root, 'index.md')))) {
|
|
90
105
|
await this.enqueue(() => this.regenerateIndex());
|
|
91
106
|
}
|
|
107
|
+
await this.repairDepends();
|
|
92
108
|
}
|
|
93
109
|
/** Repo initialized and not gitDisabled. */
|
|
94
110
|
async hasGit() {
|
|
@@ -170,6 +186,49 @@ export class BundleStore {
|
|
|
170
186
|
async exists(slug) {
|
|
171
187
|
return existsSync(this.topicPath(slug));
|
|
172
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Upgrade repair for bundles written by the save path that stacked another
|
|
191
|
+
* `topics/….md` wrap onto preserved-on-update depends entries, leaving
|
|
192
|
+
* multi-wrapped references (`topics/topics/foo.md.md`) whose edges resolve
|
|
193
|
+
* to nothing. Unwraps in place behind the write queue, regenerates the
|
|
194
|
+
* derived files, and lands the result as one traceable commit; file
|
|
195
|
+
* rewrites happen even without git, the commit just doesn't. Idempotent —
|
|
196
|
+
* every clean startup re-scans and finds nothing to change.
|
|
197
|
+
*/
|
|
198
|
+
async repairDepends() {
|
|
199
|
+
await this.enqueue(async () => {
|
|
200
|
+
let files;
|
|
201
|
+
try {
|
|
202
|
+
files = await readdir(this.topicsDir());
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const changed = [];
|
|
208
|
+
for (const f of files) {
|
|
209
|
+
if (!f.endsWith('.md') || f === 'index.md')
|
|
210
|
+
continue;
|
|
211
|
+
const file = join(this.topicsDir(), f);
|
|
212
|
+
let doc;
|
|
213
|
+
try {
|
|
214
|
+
doc = okf.parseTopicDoc(await readFile(file, 'utf8'));
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
continue; // broken files are status()'s business, not the repair's
|
|
218
|
+
}
|
|
219
|
+
const normalized = dedupePaths(doc.fm.depends.map(okf.normalizeDependsEntry));
|
|
220
|
+
if (sameStrings(normalized, doc.fm.depends))
|
|
221
|
+
continue;
|
|
222
|
+
doc.fm.depends = normalized;
|
|
223
|
+
await atomicWrite(file, okf.serializeTopicDoc(doc));
|
|
224
|
+
changed.push(`topics/${f}`);
|
|
225
|
+
}
|
|
226
|
+
if (changed.length === 0)
|
|
227
|
+
return;
|
|
228
|
+
await this.regenerateIndex();
|
|
229
|
+
await this.commit([...changed, 'index.md'], `topics(migrate): repair wrapped depends entries in ${changed.length} topic file(s)`);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
173
232
|
/** First free slug: `foo`, then `foo-2`, `foo-3`, … */
|
|
174
233
|
async uniqueSlug(base) {
|
|
175
234
|
const clean = okf.slugify(base);
|
|
@@ -508,13 +567,19 @@ export class BundleStore {
|
|
|
508
567
|
}
|
|
509
568
|
async setConflicts(paths) {
|
|
510
569
|
await this.enqueue(async () => {
|
|
511
|
-
|
|
570
|
+
// Only topic files belong here: a rebase can also conflict on meta
|
|
571
|
+
// sidecars (observations.jsonl is appended on both machines), and
|
|
572
|
+
// storing those leaked non-topic paths into the conflicts mark, where
|
|
573
|
+
// /topics status displayed them as if they were topics and the demote
|
|
574
|
+
// gate keyed on slugs that can never exist.
|
|
575
|
+
const topics = [...new Set(paths.filter((p) => /^topics\/.+\.md$/.test(p)))];
|
|
576
|
+
if (topics.length === 0) {
|
|
512
577
|
await atomicWrite(this.conflictsPath(), '[]\n');
|
|
513
578
|
}
|
|
514
579
|
else {
|
|
515
|
-
await atomicWrite(this.conflictsPath(), `${JSON.stringify(
|
|
580
|
+
await atomicWrite(this.conflictsPath(), `${JSON.stringify(topics, null, 2)}\n`);
|
|
516
581
|
}
|
|
517
|
-
await this.commit(['meta/conflicts.json'], `topics(meta): mark ${
|
|
582
|
+
await this.commit(['meta/conflicts.json'], `topics(meta): mark ${topics.length} conflicted topic(s)`);
|
|
518
583
|
});
|
|
519
584
|
}
|
|
520
585
|
// ------------------------------------------------------------------
|
package/lib/viz.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @module viz
|
|
11
11
|
*/
|
|
12
|
+
import { unwrapTopicRef } from "./okf.js";
|
|
12
13
|
/** Build the relationship graph: depends edges + body-link edges, no self-loops. */
|
|
13
14
|
export function buildGraph(roster) {
|
|
14
15
|
const bySlug = new Set(roster.map((r) => r.slug));
|
|
@@ -31,7 +32,9 @@ export function buildGraph(roster) {
|
|
|
31
32
|
};
|
|
32
33
|
for (const topic of roster) {
|
|
33
34
|
for (const dep of topic.depends) {
|
|
34
|
-
|
|
35
|
+
// unwrapTopicRef, not a single strip: pre-repair bundles can carry
|
|
36
|
+
// multi-wrapped depends entries, which would render a dead node here.
|
|
37
|
+
const target = unwrapTopicRef(dep);
|
|
35
38
|
addEdge(topic.slug, target, 'depends');
|
|
36
39
|
}
|
|
37
40
|
for (const link of topic.links ?? [])
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwayds/dsh-topics-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "dsh-plugin: OKF v0.2 topic memory for dsh - local-first git-tracked bundle, hot-path LLM-free injection, two-stage observer with background distill",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"build": "tsc -p tsconfig.json",
|
|
39
39
|
"check": "tsc --noEmit -p tsconfig.json",
|
|
40
40
|
"pretest": "tsc -p tsconfig.json",
|
|
41
|
-
"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/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/settings-migration.test.mjs test/quality.test.mjs test/replay-gate.test.mjs"
|
|
41
|
+
"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/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/settings-migration.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs",
|
|
42
|
+
"smoke": "node scripts/smoke-boot.mjs"
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {},
|
|
44
45
|
"peerDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -535,8 +535,14 @@ export function apply(ctx: Context): void {
|
|
|
535
535
|
observer.onSessionEvent('dispose', 'agent/disposed', undefined)
|
|
536
536
|
}
|
|
537
537
|
sync.dispose()
|
|
538
|
-
|
|
539
|
-
|
|
538
|
+
// The exit flush (final meta commit + ledger flush) rides the SAME
|
|
539
|
+
// bounded window as the exit distill — fire-and-forget here loses the
|
|
540
|
+
// exit race that window was built to win.
|
|
541
|
+
const exitFlush = sync.commitMeta().then(() => sync.flush()).catch(() => undefined)
|
|
542
|
+
return settleBounded(
|
|
543
|
+
exitDistill === undefined ? exitFlush : Promise.allSettled([exitDistill, exitFlush]),
|
|
544
|
+
EXIT_DISTILL_TIMEOUT_MS,
|
|
545
|
+
)
|
|
540
546
|
}
|
|
541
547
|
},
|
|
542
548
|
'topics: lifecycle',
|
package/src/okf.ts
CHANGED
|
@@ -254,9 +254,43 @@ export function slugToPath(slug: string): string {
|
|
|
254
254
|
return `topics/${slug}.md`
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
/**
|
|
257
|
+
/**
|
|
258
|
+
* Strip every `topics/` (or `topics:` — a colon form the wild has produced)
|
|
259
|
+
* prefix and `.md` suffix. The canonical depends entry carries exactly one
|
|
260
|
+
* of each, but bundles written by the save path that re-wrapped preserved
|
|
261
|
+
* values (fixed in the same release) can carry any number — idempotent by
|
|
262
|
+
* construction.
|
|
263
|
+
*/
|
|
264
|
+
export function unwrapTopicRef(raw: string): string {
|
|
265
|
+
let t = raw.trim()
|
|
266
|
+
for (;;) {
|
|
267
|
+
if (t.startsWith('topics/')) t = t.slice('topics/'.length)
|
|
268
|
+
else if (t.startsWith('topics:')) t = t.slice('topics:'.length)
|
|
269
|
+
else break
|
|
270
|
+
}
|
|
271
|
+
while (t.endsWith('.md')) t = t.slice(0, -3)
|
|
272
|
+
return t
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Canonical bundle-relative path for a `depends` entry, accepting every form
|
|
277
|
+
* the wild has produced: bare slug (`foo`), canonical path (`topics/foo.md`),
|
|
278
|
+
* multi-wrapped (`topics/topics/foo.md.md`). Idempotent. A value that still
|
|
279
|
+
* carries a path separator or scheme after unwrapping is not a bundle-internal
|
|
280
|
+
* topic reference and comes back as its unwrapped self (a dead edge for the
|
|
281
|
+
* graph, but never re-wrapped into a fake one); the empty string means the
|
|
282
|
+
* entry unwrapped to nothing and should be dropped by the caller.
|
|
283
|
+
*/
|
|
284
|
+
export function normalizeDependsEntry(raw: string): string {
|
|
285
|
+
const t = unwrapTopicRef(raw)
|
|
286
|
+
if (t === '') return ''
|
|
287
|
+
if (t.includes('/') || t.includes(':')) return t
|
|
288
|
+
return slugToPath(t)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Topic slugs referenced by `depends`, tolerant of wrapped entries. */
|
|
258
292
|
export function dependsSlugs(fm: { depends: readonly string[] }): string[] {
|
|
259
|
-
return fm.depends.map(
|
|
293
|
+
return fm.depends.map(unwrapTopicRef)
|
|
260
294
|
}
|
|
261
295
|
|
|
262
296
|
export interface IndexEntry {
|
|
@@ -312,15 +346,20 @@ export function normalizeLinkTarget(target: string): string | undefined {
|
|
|
312
346
|
* Topic slugs referenced from a body: `[[wikilinks]]` (with optional
|
|
313
347
|
* `#heading` / `|alias`) plus markdown links pointing at bundle paths.
|
|
314
348
|
* These are the human-authored edges alongside `depends` — both feed the
|
|
315
|
-
* retrieval graph walk and the backlinks index.
|
|
349
|
+
* retrieval graph walk and the backlinks index. Inline code and fenced
|
|
350
|
+
* code blocks are stripped first: topics that document link syntax would
|
|
351
|
+
* otherwise mint permanent phantom edges out of their own examples.
|
|
316
352
|
*/
|
|
317
353
|
export function bodyLinkSlugs(body: string): string[] {
|
|
318
354
|
const out = new Set<string>()
|
|
319
|
-
|
|
355
|
+
const prose = body
|
|
356
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
357
|
+
.replace(/`[^`\n]*`/g, '')
|
|
358
|
+
for (const m of prose.matchAll(WIKILINK)) {
|
|
320
359
|
const slug = normalizeLinkTarget(m[1])
|
|
321
360
|
if (slug !== undefined) out.add(slug)
|
|
322
361
|
}
|
|
323
|
-
for (const m of
|
|
362
|
+
for (const m of prose.matchAll(MD_LINK)) {
|
|
324
363
|
const slug = normalizeLinkTarget(m[1])
|
|
325
364
|
if (slug !== undefined) out.add(slug)
|
|
326
365
|
}
|
package/src/service.ts
CHANGED
|
@@ -484,7 +484,11 @@ export class TopicsService {
|
|
|
484
484
|
type: 'Topic',
|
|
485
485
|
title: input.title,
|
|
486
486
|
tags: dedupeLower(input.tags ?? existing?.fm.tags ?? []),
|
|
487
|
-
|
|
487
|
+
// normalizeDependsEntry, never bare slugToPath: the preserved-on-update
|
|
488
|
+
// fallback feeds already-canonical `topics/<slug>.md` entries back in,
|
|
489
|
+
// and wrapping those again stacked one layer per save (the double-wrap
|
|
490
|
+
// bug repaired on startup by BundleStore.repairDepends).
|
|
491
|
+
depends: dedupePaths((input.depends ?? existing?.fm.depends ?? []).map(okf.normalizeDependsEntry)),
|
|
488
492
|
open_questions: input.openQuestions ?? existing?.fm.open_questions ?? [],
|
|
489
493
|
impact: input.impact ?? existing?.fm.impact ?? [],
|
|
490
494
|
status: input.status ?? existing?.fm.status ?? 'draft',
|
|
@@ -577,6 +581,17 @@ function dedupeLower(tags: string[]): string[] {
|
|
|
577
581
|
return out
|
|
578
582
|
}
|
|
579
583
|
|
|
584
|
+
function dedupePaths(paths: string[]): string[] {
|
|
585
|
+
const seen = new Set<string>()
|
|
586
|
+
const out: string[] = []
|
|
587
|
+
for (const p of paths) {
|
|
588
|
+
if (p === '' || seen.has(p)) continue
|
|
589
|
+
seen.add(p)
|
|
590
|
+
out.push(p)
|
|
591
|
+
}
|
|
592
|
+
return out
|
|
593
|
+
}
|
|
594
|
+
|
|
580
595
|
function sanitizeTriggers(triggers: readonly string[]): string[] {
|
|
581
596
|
const out: string[] = []
|
|
582
597
|
for (const t of triggers) {
|
package/src/store.ts
CHANGED
|
@@ -77,6 +77,21 @@ async function atomicWrite(file: string, content: string): Promise<void> {
|
|
|
77
77
|
await rename(tmp, file)
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function dedupePaths(paths: readonly string[]): string[] {
|
|
81
|
+
const seen = new Set<string>()
|
|
82
|
+
const out: string[] = []
|
|
83
|
+
for (const p of paths) {
|
|
84
|
+
if (p === '' || seen.has(p)) continue
|
|
85
|
+
seen.add(p)
|
|
86
|
+
out.push(p)
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function sameStrings(a: readonly string[], b: readonly string[]): boolean {
|
|
92
|
+
return a.length === b.length && a.every((v, i) => v === b[i])
|
|
93
|
+
}
|
|
94
|
+
|
|
80
95
|
export interface BundleStoreOptions {
|
|
81
96
|
/** Override the stamped actor (tests). Defaults to `agent:dsh-topics-memory@<host>`. */
|
|
82
97
|
actor?: string
|
|
@@ -146,7 +161,8 @@ export class BundleStore {
|
|
|
146
161
|
}
|
|
147
162
|
}
|
|
148
163
|
|
|
149
|
-
/** Create the directory skeleton, git repo,
|
|
164
|
+
/** Create the directory skeleton, git repo, initial index, and repair
|
|
165
|
+
* wrapped depends entries left by older save paths (idempotent). */
|
|
150
166
|
async ensure(): Promise<void> {
|
|
151
167
|
await mkdir(this.topicsDir(), { recursive: true })
|
|
152
168
|
await mkdir(this.metaDir(), { recursive: true })
|
|
@@ -156,6 +172,7 @@ export class BundleStore {
|
|
|
156
172
|
if (!(await existsSync(join(this.root, 'index.md')))) {
|
|
157
173
|
await this.enqueue(() => this.regenerateIndex())
|
|
158
174
|
}
|
|
175
|
+
await this.repairDepends()
|
|
159
176
|
}
|
|
160
177
|
|
|
161
178
|
/** Repo initialized and not gitDisabled. */
|
|
@@ -234,6 +251,45 @@ export class BundleStore {
|
|
|
234
251
|
return existsSync(this.topicPath(slug))
|
|
235
252
|
}
|
|
236
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Upgrade repair for bundles written by the save path that stacked another
|
|
256
|
+
* `topics/….md` wrap onto preserved-on-update depends entries, leaving
|
|
257
|
+
* multi-wrapped references (`topics/topics/foo.md.md`) whose edges resolve
|
|
258
|
+
* to nothing. Unwraps in place behind the write queue, regenerates the
|
|
259
|
+
* derived files, and lands the result as one traceable commit; file
|
|
260
|
+
* rewrites happen even without git, the commit just doesn't. Idempotent —
|
|
261
|
+
* every clean startup re-scans and finds nothing to change.
|
|
262
|
+
*/
|
|
263
|
+
private async repairDepends(): Promise<void> {
|
|
264
|
+
await this.enqueue(async () => {
|
|
265
|
+
let files: string[]
|
|
266
|
+
try {
|
|
267
|
+
files = await readdir(this.topicsDir())
|
|
268
|
+
} catch {
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
const changed: string[] = []
|
|
272
|
+
for (const f of files) {
|
|
273
|
+
if (!f.endsWith('.md') || f === 'index.md') continue
|
|
274
|
+
const file = join(this.topicsDir(), f)
|
|
275
|
+
let doc: okf.TopicDoc
|
|
276
|
+
try {
|
|
277
|
+
doc = okf.parseTopicDoc(await readFile(file, 'utf8'))
|
|
278
|
+
} catch {
|
|
279
|
+
continue // broken files are status()'s business, not the repair's
|
|
280
|
+
}
|
|
281
|
+
const normalized = dedupePaths(doc.fm.depends.map(okf.normalizeDependsEntry))
|
|
282
|
+
if (sameStrings(normalized, doc.fm.depends)) continue
|
|
283
|
+
doc.fm.depends = normalized
|
|
284
|
+
await atomicWrite(file, okf.serializeTopicDoc(doc))
|
|
285
|
+
changed.push(`topics/${f}`)
|
|
286
|
+
}
|
|
287
|
+
if (changed.length === 0) return
|
|
288
|
+
await this.regenerateIndex()
|
|
289
|
+
await this.commit([...changed, 'index.md'], `topics(migrate): repair wrapped depends entries in ${changed.length} topic file(s)`)
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
237
293
|
/** First free slug: `foo`, then `foo-2`, `foo-3`, … */
|
|
238
294
|
async uniqueSlug(base: string): Promise<string> {
|
|
239
295
|
const clean = okf.slugify(base)
|
|
@@ -575,12 +631,18 @@ export class BundleStore {
|
|
|
575
631
|
|
|
576
632
|
async setConflicts(paths: readonly string[]): Promise<void> {
|
|
577
633
|
await this.enqueue(async () => {
|
|
578
|
-
|
|
634
|
+
// Only topic files belong here: a rebase can also conflict on meta
|
|
635
|
+
// sidecars (observations.jsonl is appended on both machines), and
|
|
636
|
+
// storing those leaked non-topic paths into the conflicts mark, where
|
|
637
|
+
// /topics status displayed them as if they were topics and the demote
|
|
638
|
+
// gate keyed on slugs that can never exist.
|
|
639
|
+
const topics = [...new Set(paths.filter((p) => /^topics\/.+\.md$/.test(p)))]
|
|
640
|
+
if (topics.length === 0) {
|
|
579
641
|
await atomicWrite(this.conflictsPath(), '[]\n')
|
|
580
642
|
} else {
|
|
581
|
-
await atomicWrite(this.conflictsPath(), `${JSON.stringify(
|
|
643
|
+
await atomicWrite(this.conflictsPath(), `${JSON.stringify(topics, null, 2)}\n`)
|
|
582
644
|
}
|
|
583
|
-
await this.commit(['meta/conflicts.json'], `topics(meta): mark ${
|
|
645
|
+
await this.commit(['meta/conflicts.json'], `topics(meta): mark ${topics.length} conflicted topic(s)`)
|
|
584
646
|
})
|
|
585
647
|
}
|
|
586
648
|
|
package/src/viz.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { RetrievableTopic } from './retrieval.ts'
|
|
14
|
+
import { unwrapTopicRef } from './okf.ts'
|
|
14
15
|
|
|
15
16
|
export interface VizNode {
|
|
16
17
|
id: string
|
|
@@ -51,7 +52,9 @@ export function buildGraph(roster: readonly RetrievableTopic[]): VizGraph {
|
|
|
51
52
|
}
|
|
52
53
|
for (const topic of roster) {
|
|
53
54
|
for (const dep of topic.depends) {
|
|
54
|
-
|
|
55
|
+
// unwrapTopicRef, not a single strip: pre-repair bundles can carry
|
|
56
|
+
// multi-wrapped depends entries, which would render a dead node here.
|
|
57
|
+
const target = unwrapTopicRef(dep)
|
|
55
58
|
addEdge(topic.slug, target, 'depends')
|
|
56
59
|
}
|
|
57
60
|
for (const link of topic.links ?? []) addEdge(topic.slug, link, 'link')
|