@aiwayds/dsh-topics-memory 0.8.1 → 0.10.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 +20 -2
- package/README.zh.md +19 -1
- package/lib/distill.d.ts +3 -1
- package/lib/distill.js +2 -0
- package/lib/git.d.ts +4 -1
- package/lib/git.js +5 -2
- package/lib/index.d.ts +6 -6
- package/lib/index.js +48 -23
- 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/sync.d.ts +6 -1
- package/lib/sync.js +31 -2
- package/lib/viz.js +4 -1
- package/package.json +3 -3
- package/src/distill.ts +3 -1
- package/src/git.ts +5 -2
- package/src/index.ts +44 -24
- package/src/okf.ts +44 -5
- package/src/service.ts +16 -1
- package/src/store.ts +66 -4
- package/src/sync.ts +29 -2
- package/src/viz.ts +4 -1
package/README.md
CHANGED
|
@@ -6,10 +6,28 @@ A dsh plugin: maintains "working topic memory" as an [OKF (Open Knowledge Format
|
|
|
6
6
|
|
|
7
7
|
> **Requires dsh >= 0.1.2-rc.1** — this plugin targets the dsh RC/stable line only (CI and releases resolve the newest of the `latest`/`next` dist-tags at runtime). **The alpha line is no longer supported.**
|
|
8
8
|
|
|
9
|
+
## Demo
|
|
10
|
+
|
|
11
|
+
The full flow in about four minutes: topics captured, distilled, and injected in a real session.
|
|
12
|
+
|
|
13
|
+
https://github.com/user-attachments/assets/8c06cc98-b1ed-402b-9110-4f9a93eb15bc
|
|
14
|
+
|
|
9
15
|
## The problem it solves
|
|
10
16
|
|
|
11
17
|
Long sessions forget. Cross-session, even more so. This plugin maintains **structured topic memory**: each Topic records a matter's **name, dependencies, open questions, current conclusion, impact, and recommendations**. When a conclusion changes, edit the file and commit — `git log` directly answers "when, by whom, and why did this conclusion change".
|
|
12
18
|
|
|
19
|
+
## Why this plugin exists: memory is edited, not accumulated
|
|
20
|
+
|
|
21
|
+
The short version: **more memory is not better memory.**
|
|
22
|
+
|
|
23
|
+
Most memory tools assume accumulation — record everything, retrieve broadly. That may work for humans; for LLMs it backfires twice over. Model attention is a finite resource, so a giant memory bank means every turn is spent digging for signal in noise. Worse, process memories hoard intermediate judgments that were right once and wrong later — and they will confidently steer the model into bad decisions.
|
|
24
|
+
|
|
25
|
+
So this plugin takes a hard editorial line on what deserves to be remembered: **a topic records exactly four things — the question that started it, the conclusion it reached, what it impacts, and what it depends on. Everything in between — the discussion, the dead ends, the wrong turns — is deliberately not memory.** Process belongs to the session; when the session ends, it goes. Only conclusions that survive distillation make it into the bundle.
|
|
26
|
+
|
|
27
|
+
Short-term memory is the session's own job — the conversation context already is one, and a plugin that feeds it back is noise. Long-term memory belongs to topics: small, structured, git-traceable, injected in budgeted slices with zero hits meaning zero injection. Every turn hands the model the **minimum high-value context**, not the biggest warehouse.
|
|
28
|
+
|
|
29
|
+
This plugin is not trying to be the model's notebook. It is trying to be the model's editor: deciding what is worth keeping — and, more importantly, what should be forgotten.
|
|
30
|
+
|
|
13
31
|
## Core features
|
|
14
32
|
|
|
15
33
|
- **Strict OKF v0.2 compliance**: each Topic is a `markdown + YAML frontmatter` concept document (`type: Topic`) that the whole OKF ecosystem (Obsidian, OKF validators) can consume directly; ships with the provenance (`sources`), trust (`generated`/`verified`), and lifecycle (`status`/`stale_after`) field families.
|
|
@@ -30,7 +48,7 @@ Long sessions forget. Cross-session, even more so. This plugin maintains **struc
|
|
|
30
48
|
|
|
31
49
|
| Model tools | Purpose |
|
|
32
50
|
|---|---|
|
|
33
|
-
| `topic_save` |
|
|
51
|
+
| `topic_save` | Save/revise a Topic (name / dependencies / open questions / conclusion / impact / recommendations) |
|
|
34
52
|
| `topic_observe` | Jot an atomic observation (decision/finding/constraint/question), pending distill |
|
|
35
53
|
| `topic_search` | LLM-free keyword search over memory |
|
|
36
54
|
| `topic_history` | A topic's conclusion change history (git log as a tool) |
|
|
@@ -115,7 +133,7 @@ This project's shape is directly inspired and supported by:
|
|
|
115
133
|
## Known boundaries
|
|
116
134
|
|
|
117
135
|
- **Subagents engage memory by default — one switch to opt out**: by default (`include-subagents` on), injection and observation apply to subagent sessions too. `/topics set include-subagents off` skips delegated sessions entirely — no injection, no observation, no distill triggers; the topic tools stay on the global layer, so an explicit `topic_save` from a child still lands. Out-of-process subagents (claude-code/codex providers) never load this plugin anyway.
|
|
118
|
-
- **
|
|
136
|
+
- **Exit is local-only (0.10.0)**: the plugin's disposer makes one local git commit of the meta sidecars (observations / injections / distill state) and never waits on the network — no pull, no push, no model call, so host exit no longer pays a git round-trip or a bounded distill wait (the old 90s cap is gone; only a 10s guard against a pathological git stall remains). The exit distill trigger is fired fire-and-forget and is skipped entirely while a session-end run is still in flight (the same pool head would otherwise be fed to the model twice). Nothing is lost by skipping: observations are write-through on disk, the deferred push is replayed by the next boot's pull, and the skipped distill is replayed there too (boot-replay). `meta/distill-state.json` records each lane's outcome, checkable via `/topics status`.
|
|
119
137
|
- **Observation GC (three strikes)**: an observation the model actually evaluated (parseable answer, however useless) but no op consumed accrues one failed attempt; the third failed attempt physically deletes it — explicitly authorized cleanup of raw data the lane demonstrably cannot process. Runs that never evaluated the batch never count: infrastructure failures (network errors, unconfigured distill route → readable `no-model` short-circuit) and unparseable output (`invalid-output`) are exempt, and a batch still mid-shrink on output-limit retries is only counted once a verdict is reached (success, floor stop, stall, or an explicit skip). Deletions are committed immediately (data destruction stays git-traceable); pure attempt counters follow the usual flush cadence.
|
|
120
138
|
- **Config read timing**: `/topics set` and `settings.yaml` edits take effect most reliably from the next session start.
|
|
121
139
|
- **Picking a distill model**: `/topics onboard` splits the distill decision into two dependent questions (provider first, then that provider's model catalog), pre-validated with `resolveModelInfo` — a provider with no live route blocks and re-asks, an off-catalog model (a non-NO_ADAPTER failure: outside the advisory catalog, possibly still usable) warns but is allowed; hosts without an ask UI or a usable model route fall back to typed input. The same validation backs the `/topics set` picker panels.
|
package/README.zh.md
CHANGED
|
@@ -6,10 +6,28 @@
|
|
|
6
6
|
|
|
7
7
|
> **要求 dsh >= 0.1.2-rc.1** — 本插件只跟随 dsh RC/stable 线(CI 与发版在运行时解析 latest/next 中更新的 dist-tag)。**不再支持 alpha 线。**
|
|
8
8
|
|
|
9
|
+
## 演示
|
|
10
|
+
|
|
11
|
+
约 4 分钟的完整流程:topic 沉淀、蒸馏、注入在真实会话里跑起来。
|
|
12
|
+
|
|
13
|
+
https://github.com/user-attachments/assets/6e9b346d-2ec9-4148-ae62-7087797d3188
|
|
14
|
+
|
|
9
15
|
## 它解决什么问题
|
|
10
16
|
|
|
11
17
|
长会话会失忆,跨会话更会。本插件维护一份**结构化的 topic 记忆**:每个 Topic 记录一件事的**名字、依赖、未决问题、目前结论、影响、建议**。结论变了就改文件、打 commit——`git log` 直接回答「这个结论什么时候、被谁、为什么改的」。
|
|
12
18
|
|
|
19
|
+
## 初衷:记忆是编辑出来的,不是攒出来的
|
|
20
|
+
|
|
21
|
+
先说结论:**记忆不是越多越好。**
|
|
22
|
+
|
|
23
|
+
「记忆」类产品的常见思路是多多益善——能记的都记,用时全量检索。这对人也许成立,对 LLM 恰恰相反,而且反在两处:模型的注意力是有限资源,巨型记忆库意味着每一轮对话都在噪音里翻找信号;更危险的是,过程记忆里囤着大量「当时正确、事后失效」的中间判断,它们会理直气壮地把模型引向错误的决定。
|
|
24
|
+
|
|
25
|
+
所以本插件对「记什么」做了一次苛刻的取舍:**一个 topic 只记四件事——它从什么问题开始、得出了什么结论、影响什么、依赖什么。至于得出结论的过程:讨论、试错、绕过的弯路,一概不记。**过程属于 session,session 结束就该随它去;能留下的,只有经得起蒸馏的结论。
|
|
26
|
+
|
|
27
|
+
短时记忆交给 session,对话上下文本就是它,不需要插件再回喂一遍;长时记忆交给 topics——小、结构化、git 可追溯,注入按预算切片、零命中零注入,每轮给模型的都是**最小的高价值上下文**,而不是越大越好的仓库。
|
|
28
|
+
|
|
29
|
+
所以这个插件想做的不是模型的记事本,而是模型的编辑:替它决定什么值得记住,以及——更要紧的——什么应该被忘掉。
|
|
30
|
+
|
|
13
31
|
## 核心特性
|
|
14
32
|
|
|
15
33
|
- **OKF v0.2 严格合规**:每个 Topic 是 `markdown + YAML frontmatter` 的 concept 文档(`type: Topic`),可被 Obsidian、OKF 校验器等整个生态直接消费;自带 provenance(`sources`)、trust(`generated`/`verified`)、lifecycle(`status`/`stale_after`)三族字段。
|
|
@@ -115,7 +133,7 @@ dsh plugin --profile <name> remove @aiwayds/dsh-topics-memory
|
|
|
115
133
|
## 已知边界
|
|
116
134
|
|
|
117
135
|
- **子代理默认参与记忆,可整体关掉**:默认(`include-subagents` 开)注入与观察同样作用于子代理会话。`/topics set include-subagents off` 后,delegation depth > 0 的子代理会话被整体跳过——不注入、不观察、不触发蒸馏;topic 工具始终在全局层(子代理显式 `topic_save` 不受开关影响)。跨进程子代理(claude-code/codex 等 provider)本就不加载本插件。
|
|
118
|
-
-
|
|
136
|
+
- **退出路径本地化(0.10.0)**:插件 disposer 只做一步本地 git commit(meta 侧车文件:observations / injections / distill state),不再等待任何网络——没有 pull、没有 push、没有模型调用,宿主退出不再支付 git 双程与有界蒸馏等待(旧的 90s 上限移除,仅保留 10s 兜底以防病理性 git 卡死)。退出蒸馏触发改为 fire-and-forget;已有 session-end run 在跑时整体跳过(否则同一全局队头批次会被双份喂给模型)。跳过不丢活:observations 本就 write-through 落盘,推迟的 push 由下次启动的 pull 补推,被跳过的蒸馏同样由下次启动回放(boot-replay)。`meta/distill-state.json` 记录每次 lane 的结局,`/topics status` 可查。
|
|
119
137
|
- **观察 GC(三振删除)**:被模型实际评估(返回了可解析应答,无论内容有无价值)却未被任何 op 消费的观察记一次 failed attempt,连续 3 次即物理删除(用户已明确授权删除 lane 确实无法处理的原始观察)。模型从未评估过的批次永不计数:基础设施失败(网络错误、蒸馏路由未配置 → 可读 `no-model` 短路)与输出不可解析(`invalid-output`)豁免;输出上限减半重试中的批次只有到达裁决(成功 / 触底 / stalled / 明确跳过)才计一次。删除立即 commit(数据销毁 git 可追溯),纯计数沿用 flush 节奏。
|
|
120
138
|
- **配置读取时机**:`/topics set` 与 settings.yaml 修改在下次会话启动后生效最稳。
|
|
121
139
|
- **蒸馏选模型**:`/topics onboard` 的蒸馏一步拆成两问(先 provider 后 model,模型列表取自该 provider 的目录),选完经 `resolveModelInfo` 预校验——provider 无活路由会阻断重选,模型目录校验非 NO_ADAPTER 失败(目录外,可能仍可用)则警告但放行;无 ask UI 或无可用模型路由的环境自动退回文本输入。`/topics set` 的选择面板走同一套校验。
|
package/lib/distill.d.ts
CHANGED
|
@@ -69,8 +69,10 @@ export declare class Distiller {
|
|
|
69
69
|
* caller can hook post-run cleanup — the per-session llm capture in
|
|
70
70
|
* index.ts must live exactly as long as a run that may read it. `manual`
|
|
71
71
|
* is the /topics distill trigger: same lane, same in-flight guard.
|
|
72
|
+
* `boot-replay` is the session-start backlog drain: a previous exit's
|
|
73
|
+
* skipped (or killed-mid-run) distill replays here.
|
|
72
74
|
*/
|
|
73
|
-
request(sessionId: string, reason: 'every-n' | 'session-end' | 'manual'): Promise<DistillResult> | undefined;
|
|
75
|
+
request(sessionId: string, reason: 'every-n' | 'session-end' | 'manual' | 'boot-replay'): Promise<DistillResult> | undefined;
|
|
74
76
|
/** True while a run for the session is still in flight (teardown guard). */
|
|
75
77
|
hasPending(sessionId: string): boolean;
|
|
76
78
|
/** True while ANY session's run is still in flight (exit-dispose guard). */
|
package/lib/distill.js
CHANGED
|
@@ -65,6 +65,8 @@ export class Distiller {
|
|
|
65
65
|
* caller can hook post-run cleanup — the per-session llm capture in
|
|
66
66
|
* index.ts must live exactly as long as a run that may read it. `manual`
|
|
67
67
|
* is the /topics distill trigger: same lane, same in-flight guard.
|
|
68
|
+
* `boot-replay` is the session-start backlog drain: a previous exit's
|
|
69
|
+
* skipped (or killed-mid-run) distill replays here.
|
|
68
70
|
*/
|
|
69
71
|
request(sessionId, reason) {
|
|
70
72
|
if (!this.configured)
|
package/lib/git.d.ts
CHANGED
|
@@ -51,7 +51,10 @@ export interface PullOutcome {
|
|
|
51
51
|
* `pull --rebase` with conflict containment: on conflict we abort the rebase
|
|
52
52
|
* (ADR 0003 — no automatic smart merging) and report the conflicted paths so
|
|
53
53
|
* the caller can mark those topics. Remote and branch are explicit — the
|
|
54
|
-
* bundle never depends on upstream tracking config.
|
|
54
|
+
* bundle never depends on upstream tracking config. IDENTITY rides along:
|
|
55
|
+
* a rebase REPLAYS local commits, which requires a committer identity, and
|
|
56
|
+
* a fresh machine (CI, new host) may have none — without this the pull
|
|
57
|
+
* fails with an empty conflicted list on every session start.
|
|
55
58
|
*/
|
|
56
59
|
export declare function pullRebase(cwd: string, token?: string, remote?: string, branch?: string): Promise<PullOutcome>;
|
|
57
60
|
export declare function push(cwd: string, token?: string, remote?: string, branch?: string): Promise<{
|
package/lib/git.js
CHANGED
|
@@ -117,10 +117,13 @@ export async function fileAtRev(cwd, path, rev) {
|
|
|
117
117
|
* `pull --rebase` with conflict containment: on conflict we abort the rebase
|
|
118
118
|
* (ADR 0003 — no automatic smart merging) and report the conflicted paths so
|
|
119
119
|
* the caller can mark those topics. Remote and branch are explicit — the
|
|
120
|
-
* bundle never depends on upstream tracking config.
|
|
120
|
+
* bundle never depends on upstream tracking config. IDENTITY rides along:
|
|
121
|
+
* a rebase REPLAYS local commits, which requires a committer identity, and
|
|
122
|
+
* a fresh machine (CI, new host) may have none — without this the pull
|
|
123
|
+
* fails with an empty conflicted list on every session start.
|
|
121
124
|
*/
|
|
122
125
|
export async function pullRebase(cwd, token, remote = 'origin', branch = 'main') {
|
|
123
|
-
const r = await runGit(['pull', '--rebase', '--autostash', remote, branch], { cwd, token, mayFail: true, timeoutMs: 60_000 }).catch((e) => {
|
|
126
|
+
const r = await runGit([...IDENTITY, 'pull', '--rebase', '--autostash', remote, branch], { cwd, token, mayFail: true, timeoutMs: 60_000 }).catch((e) => {
|
|
124
127
|
return { code: -1, stdout: '', stderr: String(e instanceof Error ? e.message : e) };
|
|
125
128
|
});
|
|
126
129
|
if (r.code === 0)
|
package/lib/index.d.ts
CHANGED
|
@@ -18,13 +18,13 @@
|
|
|
18
18
|
import type { Context } from '@deepseek-ai/cordis';
|
|
19
19
|
export declare const name = "dsh-topics-memory";
|
|
20
20
|
/**
|
|
21
|
-
* Bounded wait for the process-exit
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* Bounded wait for the process-exit meta commit. The exit path is
|
|
22
|
+
* local-only: no pull/push, no model call — the deferred push rides the
|
|
23
|
+
* next boot's pull and the skipped exit distill is replayed there. The
|
|
24
|
+
* window only bounds the local `git commit` against a pathological stall
|
|
25
|
+
* (a wedged index.lock, a hung filesystem), which should never fire.
|
|
26
26
|
*/
|
|
27
|
-
export declare const
|
|
27
|
+
export declare const EXIT_COMMIT_TIMEOUT_MS = 10000;
|
|
28
28
|
/**
|
|
29
29
|
* Resolve when `p` settles (rejection swallowed) or after `ms`, whichever
|
|
30
30
|
* comes first. The cap timer is unref'd: an otherwise-idle event loop exits
|
package/lib/index.js
CHANGED
|
@@ -11,13 +11,13 @@ import { CONFIG_KEYS, TopicsConfig } from "./config.js";
|
|
|
11
11
|
import { isDelegated } from "./delegation.js";
|
|
12
12
|
export const name = 'dsh-topics-memory';
|
|
13
13
|
/**
|
|
14
|
-
* Bounded wait for the process-exit
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* Bounded wait for the process-exit meta commit. The exit path is
|
|
15
|
+
* local-only: no pull/push, no model call — the deferred push rides the
|
|
16
|
+
* next boot's pull and the skipped exit distill is replayed there. The
|
|
17
|
+
* window only bounds the local `git commit` against a pathological stall
|
|
18
|
+
* (a wedged index.lock, a hung filesystem), which should never fire.
|
|
19
19
|
*/
|
|
20
|
-
export const
|
|
20
|
+
export const EXIT_COMMIT_TIMEOUT_MS = 10_000;
|
|
21
21
|
/**
|
|
22
22
|
* Resolve when `p` settles (rejection swallowed) or after `ms`, whichever
|
|
23
23
|
* comes first. The cap timer is unref'd: an otherwise-idle event loop exits
|
|
@@ -301,10 +301,10 @@ export function apply(ctx) {
|
|
|
301
301
|
// mid-pipeline deferred to THIS pipeline and never fires again.
|
|
302
302
|
const slowLane = new SlowLane(service, caller, releaseSessionLlm);
|
|
303
303
|
// ---- Observer (M2) ----
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
304
|
+
// Trigger runs are fire-and-forget everywhere now — including the exit
|
|
305
|
+
// one. Observations are write-through on disk, so a run the process exits
|
|
306
|
+
// under is never lost work: its input is still undistilled and the next
|
|
307
|
+
// boot's replay distills it.
|
|
308
308
|
const observer = new Observer(service, (sessionId, reason) => {
|
|
309
309
|
// Trigger-time capture: the agent is still registered here, so its scoped
|
|
310
310
|
// ctx can hand over the llm instance whose adapters are live.
|
|
@@ -321,8 +321,6 @@ export function apply(ctx) {
|
|
|
321
321
|
// and its own settle hook does the release.
|
|
322
322
|
const run = distiller.request(sessionId, reason);
|
|
323
323
|
if (run !== undefined) {
|
|
324
|
-
if (sessionId === 'dispose')
|
|
325
|
-
exitDistill = run;
|
|
326
324
|
void run.finally(() => releaseSessionLlm(sessionId)).catch(() => undefined);
|
|
327
325
|
}
|
|
328
326
|
});
|
|
@@ -445,36 +443,63 @@ export function apply(ctx) {
|
|
|
445
443
|
releaseSessionLlm(sessionId);
|
|
446
444
|
}));
|
|
447
445
|
}
|
|
448
|
-
// ---- Sync lifecycle: pull on session start,
|
|
446
|
+
// ---- Sync lifecycle: pull on session start, local commit on dispose ----
|
|
449
447
|
ctx.on('session/event', ((session, event) => {
|
|
450
448
|
if (event.type === 'agent/session-start') {
|
|
451
449
|
void sync
|
|
452
450
|
.pull()
|
|
453
451
|
.catch(() => undefined)
|
|
454
|
-
.finally(() => store.ensure().catch(() => undefined))
|
|
452
|
+
.finally(() => store.ensure().catch(() => undefined))
|
|
453
|
+
.then(() => {
|
|
454
|
+
// Boot replay: the observations JSONL is the durable distill queue.
|
|
455
|
+
// Whatever the previous exit skipped (local-only exit since the
|
|
456
|
+
// no-network exit commit) or a killed run left unmarked is still
|
|
457
|
+
// undistilled here. Empty pool and no-model are cheap no-ops inside
|
|
458
|
+
// the distiller; the per-session dedup guards double runs.
|
|
459
|
+
void store
|
|
460
|
+
.undistilledObservations(1)
|
|
461
|
+
.then((pending) => {
|
|
462
|
+
if (pending.length === 0)
|
|
463
|
+
return;
|
|
464
|
+
// Same trigger-time capture the observer callback does: the
|
|
465
|
+
// replay run's caller resolves candidates lazily and a fresh
|
|
466
|
+
// session has no prior capture to lean on.
|
|
467
|
+
try {
|
|
468
|
+
captureFromAgent(agents()?.get(session.id));
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
// contained — the run below fails with the readable
|
|
472
|
+
// no-adapter detail instead
|
|
473
|
+
}
|
|
474
|
+
const run = distiller.request(String(session.id), 'boot-replay');
|
|
475
|
+
if (run !== undefined)
|
|
476
|
+
void run.catch(() => undefined);
|
|
477
|
+
})
|
|
478
|
+
.catch(() => undefined);
|
|
479
|
+
});
|
|
455
480
|
}
|
|
456
481
|
}));
|
|
457
482
|
ctx.effect(() => {
|
|
458
483
|
void store.ensure().catch(() => undefined);
|
|
459
484
|
// Async disposer: cordis awaits it during unload (Disposable may be
|
|
460
|
-
// async)
|
|
461
|
-
//
|
|
485
|
+
// async). The exit path is local-only — one git commit of the meta
|
|
486
|
+
// sidecars (observations / injection log / distill state), no pull, no
|
|
487
|
+
// push, no model call. The deferred push rides the next boot's pull
|
|
488
|
+
// (sync.pull replays it) and the skipped exit distill is replayed
|
|
489
|
+
// there too; both only ever needed the on-disk state, which the commit
|
|
490
|
+
// below makes durable before the process goes away.
|
|
462
491
|
return () => {
|
|
463
492
|
// A real session's session-end run (agent/disposed trigger, moments
|
|
464
493
|
// earlier) may still be in flight — and the fake-'dispose' run would
|
|
465
494
|
// feed the SAME global pool head to the model a second time (double
|
|
466
495
|
// evaluation, double GC attempts). Skip the exit trigger while any
|
|
467
|
-
// run is pending;
|
|
468
|
-
// extra to await.
|
|
496
|
+
// run is pending; its capture is already write-through on disk.
|
|
469
497
|
if (!distiller.hasAnyPending()) {
|
|
470
498
|
observer.onSessionEvent('dispose', 'agent/disposed', undefined);
|
|
471
499
|
}
|
|
472
500
|
sync.dispose();
|
|
473
|
-
|
|
474
|
-
|
|
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);
|
|
501
|
+
const exitFlush = sync.commitMeta().catch(() => undefined);
|
|
502
|
+
return settleBounded(exitFlush, EXIT_COMMIT_TIMEOUT_MS);
|
|
478
503
|
};
|
|
479
504
|
}, 'topics: lifecycle');
|
|
480
505
|
// ---- /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/sync.d.ts
CHANGED
|
@@ -32,7 +32,12 @@ export declare class Sync {
|
|
|
32
32
|
private readonly urlFor;
|
|
33
33
|
constructor(store: BundleStore, cfg: () => SyncConfig, token?: TokenResolver, urlFor?: UrlResolver);
|
|
34
34
|
get active(): boolean;
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Session start (or `/topics sync pull`): rebase onto remote, mark
|
|
37
|
+
* conflicts, then replay any push the previous session's local-only exit
|
|
38
|
+
* deferred. The replay is best-effort — a failure just marks lastError and
|
|
39
|
+
* the debounced flush (or the next boot's pull) retries.
|
|
40
|
+
*/
|
|
36
41
|
pull(): Promise<{
|
|
37
42
|
ok: boolean;
|
|
38
43
|
conflicted: string[];
|
package/lib/sync.js
CHANGED
|
@@ -35,7 +35,12 @@ export class Sync {
|
|
|
35
35
|
get active() {
|
|
36
36
|
return this.cfg().repo !== '';
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Session start (or `/topics sync pull`): rebase onto remote, mark
|
|
40
|
+
* conflicts, then replay any push the previous session's local-only exit
|
|
41
|
+
* deferred. The replay is best-effort — a failure just marks lastError and
|
|
42
|
+
* the debounced flush (or the next boot's pull) retries.
|
|
43
|
+
*/
|
|
39
44
|
async pull() {
|
|
40
45
|
if (!this.active)
|
|
41
46
|
return { ok: true, conflicted: [], message: 'local-only 模式,无同步' };
|
|
@@ -52,6 +57,23 @@ export class Sync {
|
|
|
52
57
|
const prior = await this.store.getConflicts();
|
|
53
58
|
if (prior.size > 0)
|
|
54
59
|
await this.store.setConflicts([]);
|
|
60
|
+
// Deferred-push replay: the previous exit committed locally only, so a
|
|
61
|
+
// successful rebase here is the moment the backlog lands on the remote.
|
|
62
|
+
try {
|
|
63
|
+
if ((await gitmod.unpushedCount(this.store.root)) > 0) {
|
|
64
|
+
const pushed = await gitmod.push(this.store.root, token);
|
|
65
|
+
if (pushed.ok) {
|
|
66
|
+
this.lastPushAt = new Date().toISOString();
|
|
67
|
+
this.lastError = '';
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
this.lastError = pushed.output.slice(-500);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// pull must stay best-effort for the session-start caller
|
|
76
|
+
}
|
|
55
77
|
return { ok: true, conflicted: [], message: '已与远端同步' };
|
|
56
78
|
}
|
|
57
79
|
const conflictedSlugs = outcome.conflicted.map((p) => p.replace(/^topics\//, '').replace(/\.md$/, ''));
|
|
@@ -59,7 +81,14 @@ export class Sync {
|
|
|
59
81
|
await this.store.setConflicts(outcome.conflicted);
|
|
60
82
|
}
|
|
61
83
|
this.lastError = outcome.output.slice(-500);
|
|
62
|
-
|
|
84
|
+
// A rebase can fail without producing conflicted paths (identity missing,
|
|
85
|
+
// fetch failure, corrupt state) — surface the git output tail instead of
|
|
86
|
+
// the misleading 「未知路径」 so a headless failure is diagnosable.
|
|
87
|
+
if (conflictedSlugs.length === 0) {
|
|
88
|
+
const tail = outcome.output.trim().split('\n').slice(-3).join(';').slice(-200);
|
|
89
|
+
return { ok: false, conflicted: [], message: `rebase 失败(无冲突文件):${tail !== '' ? tail : 'git 无输出'}` };
|
|
90
|
+
}
|
|
91
|
+
return { ok: false, conflicted: conflictedSlugs, message: `rebase 冲突:${conflictedSlugs.join('、')}(已标记降权,待手工解决)` };
|
|
63
92
|
}
|
|
64
93
|
/** Called after every write-through commit — schedules the debounced push. */
|
|
65
94
|
schedulePush() {
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwayds/dsh-topics-memory",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "dsh-plugin:
|
|
3
|
+
"version": "0.10.0",
|
|
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",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -38,7 +38,7 @@
|
|
|
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
42
|
"smoke": "node scripts/smoke-boot.mjs"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {},
|
package/src/distill.ts
CHANGED
|
@@ -161,8 +161,10 @@ export class Distiller {
|
|
|
161
161
|
* caller can hook post-run cleanup — the per-session llm capture in
|
|
162
162
|
* index.ts must live exactly as long as a run that may read it. `manual`
|
|
163
163
|
* is the /topics distill trigger: same lane, same in-flight guard.
|
|
164
|
+
* `boot-replay` is the session-start backlog drain: a previous exit's
|
|
165
|
+
* skipped (or killed-mid-run) distill replays here.
|
|
164
166
|
*/
|
|
165
|
-
request(sessionId: string, reason: 'every-n' | 'session-end' | 'manual'): Promise<DistillResult> | undefined {
|
|
167
|
+
request(sessionId: string, reason: 'every-n' | 'session-end' | 'manual' | 'boot-replay'): Promise<DistillResult> | undefined {
|
|
166
168
|
if (!this.configured) return undefined
|
|
167
169
|
if (this.inFlight.has(sessionId)) return undefined
|
|
168
170
|
const run = this.run(sessionId).finally(() => this.inFlight.delete(sessionId))
|
package/src/git.ts
CHANGED
|
@@ -152,10 +152,13 @@ export interface PullOutcome {
|
|
|
152
152
|
* `pull --rebase` with conflict containment: on conflict we abort the rebase
|
|
153
153
|
* (ADR 0003 — no automatic smart merging) and report the conflicted paths so
|
|
154
154
|
* the caller can mark those topics. Remote and branch are explicit — the
|
|
155
|
-
* bundle never depends on upstream tracking config.
|
|
155
|
+
* bundle never depends on upstream tracking config. IDENTITY rides along:
|
|
156
|
+
* a rebase REPLAYS local commits, which requires a committer identity, and
|
|
157
|
+
* a fresh machine (CI, new host) may have none — without this the pull
|
|
158
|
+
* fails with an empty conflicted list on every session start.
|
|
156
159
|
*/
|
|
157
160
|
export async function pullRebase(cwd: string, token?: string, remote = 'origin', branch = 'main'): Promise<PullOutcome> {
|
|
158
|
-
const r = await runGit(['pull', '--rebase', '--autostash', remote, branch], { cwd, token, mayFail: true, timeoutMs: 60_000 }).catch((e) => {
|
|
161
|
+
const r = await runGit([...IDENTITY, 'pull', '--rebase', '--autostash', remote, branch], { cwd, token, mayFail: true, timeoutMs: 60_000 }).catch((e) => {
|
|
159
162
|
return { code: -1, stdout: '', stderr: String(e instanceof Error ? e.message : e) } satisfies GitResult
|
|
160
163
|
})
|
|
161
164
|
if (r.code === 0) return { ok: true, conflicted: [], output: r.stdout + r.stderr }
|
package/src/index.ts
CHANGED
|
@@ -39,13 +39,13 @@ import { isDelegated } from './delegation.ts'
|
|
|
39
39
|
export const name = 'dsh-topics-memory'
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* Bounded wait for the process-exit
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
42
|
+
* Bounded wait for the process-exit meta commit. The exit path is
|
|
43
|
+
* local-only: no pull/push, no model call — the deferred push rides the
|
|
44
|
+
* next boot's pull and the skipped exit distill is replayed there. The
|
|
45
|
+
* window only bounds the local `git commit` against a pathological stall
|
|
46
|
+
* (a wedged index.lock, a hung filesystem), which should never fire.
|
|
47
47
|
*/
|
|
48
|
-
export const
|
|
48
|
+
export const EXIT_COMMIT_TIMEOUT_MS = 10_000
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Resolve when `p` settles (rejection swallowed) or after `ms`, whichever
|
|
@@ -368,10 +368,10 @@ export function apply(ctx: Context): void {
|
|
|
368
368
|
const slowLane = new SlowLane(service, caller, releaseSessionLlm)
|
|
369
369
|
|
|
370
370
|
// ---- Observer (M2) ----
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
371
|
+
// Trigger runs are fire-and-forget everywhere now — including the exit
|
|
372
|
+
// one. Observations are write-through on disk, so a run the process exits
|
|
373
|
+
// under is never lost work: its input is still undistilled and the next
|
|
374
|
+
// boot's replay distills it.
|
|
375
375
|
const observer = new Observer(service, (sessionId, reason) => {
|
|
376
376
|
// Trigger-time capture: the agent is still registered here, so its scoped
|
|
377
377
|
// ctx can hand over the llm instance whose adapters are live.
|
|
@@ -387,7 +387,6 @@ export function apply(ctx: Context): void {
|
|
|
387
387
|
// and its own settle hook does the release.
|
|
388
388
|
const run = distiller.request(sessionId, reason)
|
|
389
389
|
if (run !== undefined) {
|
|
390
|
-
if (sessionId === 'dispose') exitDistill = run
|
|
391
390
|
void run.finally(() => releaseSessionLlm(sessionId)).catch(() => undefined)
|
|
392
391
|
}
|
|
393
392
|
})
|
|
@@ -508,13 +507,37 @@ export function apply(ctx: Context): void {
|
|
|
508
507
|
}) as never)
|
|
509
508
|
}
|
|
510
509
|
|
|
511
|
-
// ---- Sync lifecycle: pull on session start,
|
|
510
|
+
// ---- Sync lifecycle: pull on session start, local commit on dispose ----
|
|
512
511
|
ctx.on('session/event' as never, ((session: { id: unknown }, event: SessionEvent) => {
|
|
513
512
|
if (event.type === 'agent/session-start') {
|
|
514
513
|
void sync
|
|
515
514
|
.pull()
|
|
516
515
|
.catch(() => undefined)
|
|
517
516
|
.finally(() => store.ensure().catch(() => undefined))
|
|
517
|
+
.then(() => {
|
|
518
|
+
// Boot replay: the observations JSONL is the durable distill queue.
|
|
519
|
+
// Whatever the previous exit skipped (local-only exit since the
|
|
520
|
+
// no-network exit commit) or a killed run left unmarked is still
|
|
521
|
+
// undistilled here. Empty pool and no-model are cheap no-ops inside
|
|
522
|
+
// the distiller; the per-session dedup guards double runs.
|
|
523
|
+
void store
|
|
524
|
+
.undistilledObservations(1)
|
|
525
|
+
.then((pending) => {
|
|
526
|
+
if (pending.length === 0) return
|
|
527
|
+
// Same trigger-time capture the observer callback does: the
|
|
528
|
+
// replay run's caller resolves candidates lazily and a fresh
|
|
529
|
+
// session has no prior capture to lean on.
|
|
530
|
+
try {
|
|
531
|
+
captureFromAgent(agents()?.get(session.id) as unknown)
|
|
532
|
+
} catch {
|
|
533
|
+
// contained — the run below fails with the readable
|
|
534
|
+
// no-adapter detail instead
|
|
535
|
+
}
|
|
536
|
+
const run = distiller.request(String(session.id), 'boot-replay')
|
|
537
|
+
if (run !== undefined) void run.catch(() => undefined)
|
|
538
|
+
})
|
|
539
|
+
.catch(() => undefined)
|
|
540
|
+
})
|
|
518
541
|
}
|
|
519
542
|
}) as never)
|
|
520
543
|
|
|
@@ -522,27 +545,24 @@ export function apply(ctx: Context): void {
|
|
|
522
545
|
() => {
|
|
523
546
|
void store.ensure().catch(() => undefined)
|
|
524
547
|
// Async disposer: cordis awaits it during unload (Disposable may be
|
|
525
|
-
// async)
|
|
526
|
-
//
|
|
548
|
+
// async). The exit path is local-only — one git commit of the meta
|
|
549
|
+
// sidecars (observations / injection log / distill state), no pull, no
|
|
550
|
+
// push, no model call. The deferred push rides the next boot's pull
|
|
551
|
+
// (sync.pull replays it) and the skipped exit distill is replayed
|
|
552
|
+
// there too; both only ever needed the on-disk state, which the commit
|
|
553
|
+
// below makes durable before the process goes away.
|
|
527
554
|
return () => {
|
|
528
555
|
// A real session's session-end run (agent/disposed trigger, moments
|
|
529
556
|
// earlier) may still be in flight — and the fake-'dispose' run would
|
|
530
557
|
// feed the SAME global pool head to the model a second time (double
|
|
531
558
|
// evaluation, double GC attempts). Skip the exit trigger while any
|
|
532
|
-
// run is pending;
|
|
533
|
-
// extra to await.
|
|
559
|
+
// run is pending; its capture is already write-through on disk.
|
|
534
560
|
if (!distiller.hasAnyPending()) {
|
|
535
561
|
observer.onSessionEvent('dispose', 'agent/disposed', undefined)
|
|
536
562
|
}
|
|
537
563
|
sync.dispose()
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
)
|
|
564
|
+
const exitFlush = sync.commitMeta().catch(() => undefined)
|
|
565
|
+
return settleBounded(exitFlush, EXIT_COMMIT_TIMEOUT_MS)
|
|
546
566
|
}
|
|
547
567
|
},
|
|
548
568
|
'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/sync.ts
CHANGED
|
@@ -51,7 +51,12 @@ export class Sync {
|
|
|
51
51
|
return this.cfg().repo !== ''
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Session start (or `/topics sync pull`): rebase onto remote, mark
|
|
56
|
+
* conflicts, then replay any push the previous session's local-only exit
|
|
57
|
+
* deferred. The replay is best-effort — a failure just marks lastError and
|
|
58
|
+
* the debounced flush (or the next boot's pull) retries.
|
|
59
|
+
*/
|
|
55
60
|
async pull(): Promise<{ ok: boolean; conflicted: string[]; message: string }> {
|
|
56
61
|
if (!this.active) return { ok: true, conflicted: [], message: 'local-only 模式,无同步' }
|
|
57
62
|
const token = await this.safeToken()
|
|
@@ -66,6 +71,21 @@ export class Sync {
|
|
|
66
71
|
// A successful rebase clears previous conflict marks.
|
|
67
72
|
const prior = await this.store.getConflicts()
|
|
68
73
|
if (prior.size > 0) await this.store.setConflicts([])
|
|
74
|
+
// Deferred-push replay: the previous exit committed locally only, so a
|
|
75
|
+
// successful rebase here is the moment the backlog lands on the remote.
|
|
76
|
+
try {
|
|
77
|
+
if ((await gitmod.unpushedCount(this.store.root)) > 0) {
|
|
78
|
+
const pushed = await gitmod.push(this.store.root, token)
|
|
79
|
+
if (pushed.ok) {
|
|
80
|
+
this.lastPushAt = new Date().toISOString()
|
|
81
|
+
this.lastError = ''
|
|
82
|
+
} else {
|
|
83
|
+
this.lastError = pushed.output.slice(-500)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// pull must stay best-effort for the session-start caller
|
|
88
|
+
}
|
|
69
89
|
return { ok: true, conflicted: [], message: '已与远端同步' }
|
|
70
90
|
}
|
|
71
91
|
const conflictedSlugs = outcome.conflicted.map((p) => p.replace(/^topics\//, '').replace(/\.md$/, ''))
|
|
@@ -73,7 +93,14 @@ export class Sync {
|
|
|
73
93
|
await this.store.setConflicts(outcome.conflicted)
|
|
74
94
|
}
|
|
75
95
|
this.lastError = outcome.output.slice(-500)
|
|
76
|
-
|
|
96
|
+
// A rebase can fail without producing conflicted paths (identity missing,
|
|
97
|
+
// fetch failure, corrupt state) — surface the git output tail instead of
|
|
98
|
+
// the misleading 「未知路径」 so a headless failure is diagnosable.
|
|
99
|
+
if (conflictedSlugs.length === 0) {
|
|
100
|
+
const tail = outcome.output.trim().split('\n').slice(-3).join(';').slice(-200)
|
|
101
|
+
return { ok: false, conflicted: [], message: `rebase 失败(无冲突文件):${tail !== '' ? tail : 'git 无输出'}` }
|
|
102
|
+
}
|
|
103
|
+
return { ok: false, conflicted: conflictedSlugs, message: `rebase 冲突:${conflictedSlugs.join('、')}(已标记降权,待手工解决)` }
|
|
77
104
|
}
|
|
78
105
|
|
|
79
106
|
/** Called after every write-through commit — schedules the debounced push. */
|
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')
|