@aiwayds/dsh-topics-memory 0.18.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/lib/index.d.ts +1 -1
- package/lib/index.js +23 -1
- package/lib/legacy-import.d.ts +94 -0
- package/lib/legacy-import.js +224 -0
- package/package.json +2 -2
- package/src/index.ts +23 -1
- package/src/legacy-import.ts +267 -0
package/README.md
CHANGED
|
@@ -97,7 +97,7 @@ Purge everything: back up `~/.dsh/topics` first, then `rm -rf ~/.dsh/topics`.
|
|
|
97
97
|
|
|
98
98
|
## Configuration
|
|
99
99
|
|
|
100
|
-
First-time setup belongs to `/topics onboard`; day-to-day tuning is `/topics set <key> <value>` — on dsh 0.1.7+ it writes the settings-page `dsh-topics-memory` entry (the profile patch) and every key is volatile: edits take effect immediately, no restart. All keys and defaults:
|
|
100
|
+
First-time setup belongs to `/topics onboard`; day-to-day tuning is `/topics set <key> <value>` — on dsh 0.1.7+ it writes the settings-page `dsh-topics-memory` entry (the profile patch) and every key is volatile: edits take effect immediately, no restart. Upgrading from a pre-0.1.7 install: a legacy top-level `topics:` section in the old `settings.yaml` is imported once into the new entry automatically at the next plugin boot (the audit record lands in `~/.dsh/storages/dsh-topics-memory/legacy-import.json`). All keys and defaults:
|
|
101
101
|
|
|
102
102
|
| Key | Default | Meaning |
|
|
103
103
|
|---|---|---|
|
|
@@ -142,7 +142,7 @@ This project's shape is directly inspired and supported by:
|
|
|
142
142
|
- **Subagents are out of memory by default — one switch to opt in**: by default (`include-subagents` off since 0.7.0) delegated sessions are skipped entirely — no injection, no observation, no distill triggers; `/topics set include-subagents on` applies injection and observation to them too. 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.
|
|
143
143
|
- **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`.
|
|
144
144
|
- **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.
|
|
145
|
-
- **Config read timing**: `/topics set` and
|
|
145
|
+
- **Config read timing**: `/topics set` and profile-patch edits take effect most reliably from the next session start (dsh 0.1.7: the settings document is the profile patch; the old `settings.yaml` is imported once and renamed).
|
|
146
146
|
- **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.
|
|
147
147
|
|
|
148
148
|
## Design docs
|
package/README.zh.md
CHANGED
|
@@ -97,7 +97,7 @@ dsh plugin --profile <name> remove @aiwayds/dsh-topics-memory
|
|
|
97
97
|
|
|
98
98
|
## 配置
|
|
99
99
|
|
|
100
|
-
首次配置交给 `/topics onboard`;日常微调用 `/topics set <key> <value>`——0.1.7+ 宿主上写设置页 `dsh-topics-memory` 条目(profile patch),全部键 volatile
|
|
100
|
+
首次配置交给 `/topics onboard`;日常微调用 `/topics set <key> <value>`——0.1.7+ 宿主上写设置页 `dsh-topics-memory` 条目(profile patch),全部键 volatile 免重启即时生效。从 0.1.7 之前升级:旧 `settings.yaml` 里的 `topics:` 段会在下次插件 boot 时一次性自动迁入新条目(审计档在 `~/.dsh/storages/dsh-topics-memory/legacy-import.json`)。全部键与默认值:
|
|
101
101
|
|
|
102
102
|
| 键 | 默认 | 说明 |
|
|
103
103
|
|---|---|---|
|
|
@@ -142,7 +142,7 @@ dsh plugin --profile <name> remove @aiwayds/dsh-topics-memory
|
|
|
142
142
|
- **子代理默认不参与记忆,可整体打开**:默认(`include-subagents` off,0.7.0 起)delegation depth > 0 的子代理会话被整体跳过——不注入、不观察、不触发蒸馏;`/topics set include-subagents on` 后注入与观察同样作用于子代理会话。topic 工具始终在全局层(子代理显式 `topic_save` 不受开关影响)。跨进程子代理(claude-code/codex 等 provider)本就不加载本插件。
|
|
143
143
|
- **退出路径本地化(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` 可查。
|
|
144
144
|
- **观察 GC(三振删除)**:被模型实际评估(返回了可解析应答,无论内容有无价值)却未被任何 op 消费的观察记一次 failed attempt,连续 3 次即物理删除(用户已明确授权删除 lane 确实无法处理的原始观察)。模型从未评估过的批次永不计数:基础设施失败(网络错误、蒸馏路由未配置 → 可读 `no-model` 短路)与输出不可解析(`invalid-output`)豁免;输出上限减半重试中的批次只有到达裁决(成功 / 触底 / stalled / 明确跳过)才计一次。删除立即 commit(数据销毁 git 可追溯),纯计数沿用 flush 节奏。
|
|
145
|
-
- **配置读取时机**:`/topics set` 与 settings.yaml
|
|
145
|
+
- **配置读取时机**:`/topics set` 与 profile patch 修改在下次会话启动后生效最稳(dsh 0.1.7 起 settings 文档即 profile patch;旧 settings.yaml 导入一次后改名)。
|
|
146
146
|
- **蒸馏选模型**:`/topics onboard` 的蒸馏一步拆成两问(先 provider 后 model,模型列表取自该 provider 的目录),选完经 `resolveModelInfo` 预校验——provider 无活路由会阻断重选,模型目录校验非 NO_ADAPTER 失败(目录外,可能仍可用)则警告但放行;无 ask UI 或无可用模型路由的环境自动退回文本输入。`/topics set` 的选择面板走同一套校验。
|
|
147
147
|
|
|
148
148
|
## 设计文档
|
package/lib/index.d.ts
CHANGED
|
@@ -163,5 +163,5 @@ export type TopicsRuntimeConfig = Partial<{
|
|
|
163
163
|
* delimiter semantics of the upstream skill-filesystem provider.
|
|
164
164
|
*/
|
|
165
165
|
export declare function stripFrontmatter(raw: string): string;
|
|
166
|
-
export declare function apply(ctx: Context, config?: TopicsRuntimeConfig): void
|
|
166
|
+
export declare function apply(ctx: Context, config?: TopicsRuntimeConfig): Promise<void>;
|
|
167
167
|
export {};
|
package/lib/index.js
CHANGED
|
@@ -35,6 +35,7 @@ import { Consolidator, dropExpiredDeprecated } from "./consolidate.js";
|
|
|
35
35
|
import { SlowLane } from "./quality.js";
|
|
36
36
|
import { CONFIG_KEYS, TopicsConfig } from "./config.js";
|
|
37
37
|
import { isDelegated } from "./delegation.js";
|
|
38
|
+
import { runLegacySettingsImport } from "./legacy-import.js";
|
|
38
39
|
export const name = 'dsh-topics-memory';
|
|
39
40
|
/**
|
|
40
41
|
* Bounded wait for the process-exit meta commit. The exit path is
|
|
@@ -235,7 +236,7 @@ export function stripFrontmatter(raw) {
|
|
|
235
236
|
}
|
|
236
237
|
return raw;
|
|
237
238
|
}
|
|
238
|
-
export function apply(ctx, config = {}) {
|
|
239
|
+
export async function apply(ctx, config = {}) {
|
|
239
240
|
// `inject = ['skills']` guarantees the service exists on every real host;
|
|
240
241
|
// register unconditionally so a missing service fails loud instead of
|
|
241
242
|
// silently dropping the bundled skill.
|
|
@@ -893,6 +894,27 @@ export function apply(ctx, config = {}) {
|
|
|
893
894
|
};
|
|
894
895
|
cmdCtx.effect(() => commands.register(buildTopicsCommand(service, mutate, resolveAsk, resolveLlm, manualDistill, manualConsolidate)), 'topics: /topics');
|
|
895
896
|
});
|
|
897
|
+
// ---- One-time legacy settings import (0.1.5 → 0.1.7 upgrade path) ----
|
|
898
|
+
// The 0.1.7 host imports the old settings.yaml ONCE by "section name =
|
|
899
|
+
// entry id" and renames it settings.yaml.imported — this plugin's legacy
|
|
900
|
+
// section was named `topics`, so the host's import silently dropped it.
|
|
901
|
+
// Recover it here (see legacy-import.ts). Awaited as apply()'s LAST step:
|
|
902
|
+
// every registration above stays synchronous (fake-ctx tests dispatch
|
|
903
|
+
// immediately), while a real boot — whose loader awaits the fiber setup —
|
|
904
|
+
// also settles the import before activation completes. Fully contained:
|
|
905
|
+
// any failure only warns and leaves the audit marker unwritten, so the
|
|
906
|
+
// next boot retries; activation never depends on this.
|
|
907
|
+
try {
|
|
908
|
+
await runLegacySettingsImport({
|
|
909
|
+
home: paths.resolveDshHome(),
|
|
910
|
+
settings: ctx.settings,
|
|
911
|
+
logger: ctx.logger,
|
|
912
|
+
getCurrent: (key) => cfgNow()[key],
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
catch (error) {
|
|
916
|
+
warn(`dsh-topics-memory 旧 settings 迁移失败(不影响启动,下次启动重试):${error instanceof Error ? error.message : String(error)}`);
|
|
917
|
+
}
|
|
896
918
|
}
|
|
897
919
|
/** Config defaults for harnesses that skip schemastery's default application. */
|
|
898
920
|
const DEFAULTS = {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time legacy settings import for the 0.1.5 → 0.1.7 upgrade: the dsh
|
|
3
|
+
* 0.1.7 host renames the old `~/.dsh/settings.yaml` to
|
|
4
|
+
* `settings.yaml.imported` and imports its sections as profile patches keyed
|
|
5
|
+
* by "section name = entry id". This plugin's old section was named `topics`,
|
|
6
|
+
* NOT `dsh-topics-memory` — the host's one-shot import silently drops it.
|
|
7
|
+
* This module recovers it once:
|
|
8
|
+
*
|
|
9
|
+
* 1. read `<home>/settings.yaml.imported` (preferred) →
|
|
10
|
+
* `<home>/settings.yaml` (fallback);
|
|
11
|
+
* 2. flat-parse the `topics:` section (scalars only, conservative);
|
|
12
|
+
* 3. keep keys whose legacy value differs from the current effective value;
|
|
13
|
+
* 4. `settings.update('dsh-topics-memory', patch)` → on success write the
|
|
14
|
+
* audit marker `<home>/storages/dsh-topics-memory/legacy-import.json`;
|
|
15
|
+
* on failure warn and write nothing (the next boot retries);
|
|
16
|
+
* 5. marker present → the whole migration is skipped (idempotent — a
|
|
17
|
+
* later manual edit of the imported file must not resurrect old values).
|
|
18
|
+
*
|
|
19
|
+
* A marker is written for EVERY settled outcome (imported / no-op /
|
|
20
|
+
* no-legacy / no-section) so this runs at most once per home. Never throws
|
|
21
|
+
* into the caller beyond what the apply()-side catch contains: plugin
|
|
22
|
+
* activation must not depend on this.
|
|
23
|
+
*
|
|
24
|
+
* @module legacy-import
|
|
25
|
+
*/
|
|
26
|
+
/** Profile entry id the recovered values are written to. */
|
|
27
|
+
export declare const ENTRY_ID = "dsh-topics-memory";
|
|
28
|
+
/** Legacy top-level section name in the old settings.yaml. */
|
|
29
|
+
export declare const LEGACY_SECTION = "topics";
|
|
30
|
+
/** Storage dir name for the audit marker (repo dir name, host convention). */
|
|
31
|
+
export declare const STORAGE_DIR_NAME = "dsh-topics-memory";
|
|
32
|
+
/** Legacy keys worth recovering, mapped 1:1 onto Config keys of the same name. */
|
|
33
|
+
export declare const LEGACY_KEYS: readonly ["repo", "distillProvider", "distillModel", "topK", "totalBudget", "autoObserve"];
|
|
34
|
+
export type LegacyKey = (typeof LEGACY_KEYS)[number];
|
|
35
|
+
/** The settings seam the import writes through (`ctx.settings.update`). */
|
|
36
|
+
export interface SettingsUpdateSeam {
|
|
37
|
+
update(ns: string, patch: object): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/** Minimal logger surface (both sinks optional — bare harnesses have none). */
|
|
40
|
+
export interface LegacyImportLogger {
|
|
41
|
+
info?(message: string): void;
|
|
42
|
+
warn?(message: string): void;
|
|
43
|
+
}
|
|
44
|
+
export interface LegacyImportInput {
|
|
45
|
+
/** Resolved dsh home (`paths.resolveDshHome()` at the call site). */
|
|
46
|
+
home: string;
|
|
47
|
+
/** `ctx.settings` — absent/unusable seams skip the import without a marker. */
|
|
48
|
+
settings?: SettingsUpdateSeam | undefined;
|
|
49
|
+
/** Host logger (optional). */
|
|
50
|
+
logger?: LegacyImportLogger | undefined;
|
|
51
|
+
/** Current effective value for one legacy key (Volatile ref `.get()` at the call site). */
|
|
52
|
+
getCurrent(key: LegacyKey): unknown;
|
|
53
|
+
}
|
|
54
|
+
export type LegacyImportOutcome = 'imported' | 'no-op' | 'no-legacy' | 'no-section' | 'marker-exists' | 'no-settings' | 'update-failed';
|
|
55
|
+
export interface LegacyImportResult {
|
|
56
|
+
outcome: LegacyImportOutcome;
|
|
57
|
+
/** Keys written through settings.update (also in the marker). */
|
|
58
|
+
imported: Record<string, unknown>;
|
|
59
|
+
/** Keys not imported, with the reason (audit trail). */
|
|
60
|
+
skipped: Record<string, string>;
|
|
61
|
+
}
|
|
62
|
+
/** Candidates in priority order: the host's renamed document first. */
|
|
63
|
+
export declare function legacySettingsCandidates(home: string): string[];
|
|
64
|
+
/** Audit marker path: `<home>/storages/<dir>/legacy-import.json`. */
|
|
65
|
+
export declare function legacyMarkerPath(home: string): string;
|
|
66
|
+
/**
|
|
67
|
+
* Scalar inference for one flat `key: value` value. Quoted values are
|
|
68
|
+
* unquoted (mirroring src/yaml.ts); a single-line quoted flow-JSON value
|
|
69
|
+
* (`'["a","b"]'`) is JSON-parsed after unquoting — a malformed one stays a
|
|
70
|
+
* string. Unquoted flow collections are kept as strings (conservative:
|
|
71
|
+
* this is not a full YAML parser). Empty never reaches here (the line
|
|
72
|
+
* parser skips value-less keys — they open a nested block).
|
|
73
|
+
*/
|
|
74
|
+
export declare function inferScalar(raw: string): unknown;
|
|
75
|
+
/**
|
|
76
|
+
* Flat section parser: locate the top-level `section:` line and collect its
|
|
77
|
+
* DIRECT children (`key: value` at one common indent) until the next
|
|
78
|
+
* top-level key. Deliberately conservative — NOT a YAML parser:
|
|
79
|
+
* - `key:` with an empty value (opens a nested map/sequence) is skipped, and
|
|
80
|
+
* the deeper lines under it never match the child indent, so folded values
|
|
81
|
+
* are skipped whole;
|
|
82
|
+
* - lines at any other indent are ignored;
|
|
83
|
+
* - an inline value on the header line itself (`section: x`) is not a flat
|
|
84
|
+
* map → `{}`.
|
|
85
|
+
* Pure function — tests run it against literal strings.
|
|
86
|
+
*/
|
|
87
|
+
export declare function parseFlatSection(raw: string, section: string): Record<string, unknown>;
|
|
88
|
+
/**
|
|
89
|
+
* Run the one-time import. Resolves with the outcome; only throws on truly
|
|
90
|
+
* unexpected I/O errors (the apply()-side catch contains those). The marker
|
|
91
|
+
* is written for every settled outcome EXCEPT the retry-worthy failures
|
|
92
|
+
* (`no-settings`, `update-failed`) — those must re-run at the next boot.
|
|
93
|
+
*/
|
|
94
|
+
export declare function runLegacySettingsImport(input: LegacyImportInput): Promise<LegacyImportResult>;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time legacy settings import for the 0.1.5 → 0.1.7 upgrade: the dsh
|
|
3
|
+
* 0.1.7 host renames the old `~/.dsh/settings.yaml` to
|
|
4
|
+
* `settings.yaml.imported` and imports its sections as profile patches keyed
|
|
5
|
+
* by "section name = entry id". This plugin's old section was named `topics`,
|
|
6
|
+
* NOT `dsh-topics-memory` — the host's one-shot import silently drops it.
|
|
7
|
+
* This module recovers it once:
|
|
8
|
+
*
|
|
9
|
+
* 1. read `<home>/settings.yaml.imported` (preferred) →
|
|
10
|
+
* `<home>/settings.yaml` (fallback);
|
|
11
|
+
* 2. flat-parse the `topics:` section (scalars only, conservative);
|
|
12
|
+
* 3. keep keys whose legacy value differs from the current effective value;
|
|
13
|
+
* 4. `settings.update('dsh-topics-memory', patch)` → on success write the
|
|
14
|
+
* audit marker `<home>/storages/dsh-topics-memory/legacy-import.json`;
|
|
15
|
+
* on failure warn and write nothing (the next boot retries);
|
|
16
|
+
* 5. marker present → the whole migration is skipped (idempotent — a
|
|
17
|
+
* later manual edit of the imported file must not resurrect old values).
|
|
18
|
+
*
|
|
19
|
+
* A marker is written for EVERY settled outcome (imported / no-op /
|
|
20
|
+
* no-legacy / no-section) so this runs at most once per home. Never throws
|
|
21
|
+
* into the caller beyond what the apply()-side catch contains: plugin
|
|
22
|
+
* activation must not depend on this.
|
|
23
|
+
*
|
|
24
|
+
* @module legacy-import
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
/** Profile entry id the recovered values are written to. */
|
|
29
|
+
export const ENTRY_ID = 'dsh-topics-memory';
|
|
30
|
+
/** Legacy top-level section name in the old settings.yaml. */
|
|
31
|
+
export const LEGACY_SECTION = 'topics';
|
|
32
|
+
/** Storage dir name for the audit marker (repo dir name, host convention). */
|
|
33
|
+
export const STORAGE_DIR_NAME = 'dsh-topics-memory';
|
|
34
|
+
/** Legacy keys worth recovering, mapped 1:1 onto Config keys of the same name. */
|
|
35
|
+
export const LEGACY_KEYS = ['repo', 'distillProvider', 'distillModel', 'topK', 'totalBudget', 'autoObserve'];
|
|
36
|
+
/** Candidates in priority order: the host's renamed document first. */
|
|
37
|
+
export function legacySettingsCandidates(home) {
|
|
38
|
+
return [join(home, 'settings.yaml.imported'), join(home, 'settings.yaml')];
|
|
39
|
+
}
|
|
40
|
+
/** Audit marker path: `<home>/storages/<dir>/legacy-import.json`. */
|
|
41
|
+
export function legacyMarkerPath(home) {
|
|
42
|
+
return join(home, 'storages', STORAGE_DIR_NAME, 'legacy-import.json');
|
|
43
|
+
}
|
|
44
|
+
function escapeRegExp(s) {
|
|
45
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
46
|
+
}
|
|
47
|
+
/** A fully quoted scalar: balanced matching quote pair. */
|
|
48
|
+
function isQuoted(s) {
|
|
49
|
+
return s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")));
|
|
50
|
+
}
|
|
51
|
+
function unquote(s) {
|
|
52
|
+
const inner = s.slice(1, -1);
|
|
53
|
+
// Double quotes: unescape \" and \\; single quotes: '' is a literal '.
|
|
54
|
+
if (s.startsWith('"'))
|
|
55
|
+
return inner.replaceAll('\\"', '"').replaceAll('\\\\', '\\');
|
|
56
|
+
return inner.replaceAll("''", "'");
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Scalar inference for one flat `key: value` value. Quoted values are
|
|
60
|
+
* unquoted (mirroring src/yaml.ts); a single-line quoted flow-JSON value
|
|
61
|
+
* (`'["a","b"]'`) is JSON-parsed after unquoting — a malformed one stays a
|
|
62
|
+
* string. Unquoted flow collections are kept as strings (conservative:
|
|
63
|
+
* this is not a full YAML parser). Empty never reaches here (the line
|
|
64
|
+
* parser skips value-less keys — they open a nested block).
|
|
65
|
+
*/
|
|
66
|
+
export function inferScalar(raw) {
|
|
67
|
+
const s = raw.trim();
|
|
68
|
+
if (isQuoted(s)) {
|
|
69
|
+
const inner = unquote(s);
|
|
70
|
+
const t = inner.trim();
|
|
71
|
+
if (t.startsWith('[') || t.startsWith('{')) {
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(t);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// not JSON after all — keep the unquoted string
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return inner;
|
|
80
|
+
}
|
|
81
|
+
if (s === 'true' || s === 'True' || s === 'TRUE')
|
|
82
|
+
return true;
|
|
83
|
+
if (s === 'false' || s === 'False' || s === 'FALSE')
|
|
84
|
+
return false;
|
|
85
|
+
if (/^-?\d+$/.test(s) || /^-?\d+\.\d+$/.test(s))
|
|
86
|
+
return Number(s);
|
|
87
|
+
return s;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Flat section parser: locate the top-level `section:` line and collect its
|
|
91
|
+
* DIRECT children (`key: value` at one common indent) until the next
|
|
92
|
+
* top-level key. Deliberately conservative — NOT a YAML parser:
|
|
93
|
+
* - `key:` with an empty value (opens a nested map/sequence) is skipped, and
|
|
94
|
+
* the deeper lines under it never match the child indent, so folded values
|
|
95
|
+
* are skipped whole;
|
|
96
|
+
* - lines at any other indent are ignored;
|
|
97
|
+
* - an inline value on the header line itself (`section: x`) is not a flat
|
|
98
|
+
* map → `{}`.
|
|
99
|
+
* Pure function — tests run it against literal strings.
|
|
100
|
+
*/
|
|
101
|
+
export function parseFlatSection(raw, section) {
|
|
102
|
+
const lines = raw.split('\n').map((l) => l.replace(/\r$/, ''));
|
|
103
|
+
const header = new RegExp(`^${escapeRegExp(section)}:(.*)$`);
|
|
104
|
+
const start = lines.findIndex((l) => header.test(l));
|
|
105
|
+
if (start < 0)
|
|
106
|
+
return {};
|
|
107
|
+
// An inline header value means the section is not a block map of children.
|
|
108
|
+
if (lines[start].replace(header, '$1').trim() !== '')
|
|
109
|
+
return {};
|
|
110
|
+
const out = {};
|
|
111
|
+
let childIndent;
|
|
112
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
113
|
+
const line = lines[i];
|
|
114
|
+
if (line.trim() === '' || line.trimStart().startsWith('#'))
|
|
115
|
+
continue;
|
|
116
|
+
const indent = line.length - line.trimStart().length;
|
|
117
|
+
if (indent === 0)
|
|
118
|
+
break; // next top-level key — section over
|
|
119
|
+
if (childIndent === undefined)
|
|
120
|
+
childIndent = indent;
|
|
121
|
+
if (indent !== childIndent)
|
|
122
|
+
continue; // nested block content — skipped
|
|
123
|
+
const match = /^([^\s:][^:]*):(?:[ \t]+(.*))?$/.exec(line.trim());
|
|
124
|
+
if (match === null)
|
|
125
|
+
continue; // not a `key: value` line — skipped
|
|
126
|
+
if (match[2] === undefined || match[2].trim() === '')
|
|
127
|
+
continue; // opens a nested block
|
|
128
|
+
out[match[1].trim()] = inferScalar(match[2]);
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Run the one-time import. Resolves with the outcome; only throws on truly
|
|
134
|
+
* unexpected I/O errors (the apply()-side catch contains those). The marker
|
|
135
|
+
* is written for every settled outcome EXCEPT the retry-worthy failures
|
|
136
|
+
* (`no-settings`, `update-failed`) — those must re-run at the next boot.
|
|
137
|
+
*/
|
|
138
|
+
export async function runLegacySettingsImport(input) {
|
|
139
|
+
const skipped = {};
|
|
140
|
+
const imported = {};
|
|
141
|
+
const warn = (message) => {
|
|
142
|
+
try {
|
|
143
|
+
input.logger?.warn?.(message);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// contained — a broken logger must not break the import
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const info = (message) => {
|
|
150
|
+
try {
|
|
151
|
+
input.logger?.info?.(message);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// contained
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const markerPath = legacyMarkerPath(input.home);
|
|
158
|
+
if (existsSync(markerPath))
|
|
159
|
+
return { outcome: 'marker-exists', imported, skipped };
|
|
160
|
+
// A settings seam that cannot accept the patch is a retry-worthy state:
|
|
161
|
+
// return WITHOUT reading/marking, so a host that mounts the service later
|
|
162
|
+
// (and a test harness pointing at a real home) neither imports nor writes.
|
|
163
|
+
const settings = input.settings;
|
|
164
|
+
if (settings === undefined || typeof settings.update !== 'function') {
|
|
165
|
+
return { outcome: 'no-settings', imported, skipped };
|
|
166
|
+
}
|
|
167
|
+
// Locate the legacy document.
|
|
168
|
+
const source = legacySettingsCandidates(input.home).find((p) => existsSync(p));
|
|
169
|
+
if (source === undefined) {
|
|
170
|
+
writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'no-legacy', source: undefined, imported, skipped });
|
|
171
|
+
return { outcome: 'no-legacy', imported, skipped };
|
|
172
|
+
}
|
|
173
|
+
const section = parseFlatSection(readFileSync(source, 'utf8'), LEGACY_SECTION);
|
|
174
|
+
// The marker records the source BASENAME: the marker lives inside the same
|
|
175
|
+
// home, so the directory part is implied.
|
|
176
|
+
const sourceName = source.slice(source.lastIndexOf('/') + 1);
|
|
177
|
+
const present = Object.keys(section);
|
|
178
|
+
if (present.length === 0) {
|
|
179
|
+
writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'no-section', source: sourceName, imported, skipped });
|
|
180
|
+
return { outcome: 'no-section', imported, skipped };
|
|
181
|
+
}
|
|
182
|
+
// Key-level rule: import only when the legacy value differs from the
|
|
183
|
+
// current effective value; keys this plugin does not know are recorded and
|
|
184
|
+
// left alone (an unknown key would be rejected by the host's schema).
|
|
185
|
+
for (const key of present) {
|
|
186
|
+
if (!LEGACY_KEYS.includes(key)) {
|
|
187
|
+
skipped[key] = 'unknown-key';
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const legacyValue = section[key];
|
|
191
|
+
if (legacyValue === input.getCurrent(key))
|
|
192
|
+
skipped[key] = 'equal';
|
|
193
|
+
else
|
|
194
|
+
imported[key] = legacyValue;
|
|
195
|
+
}
|
|
196
|
+
if (Object.keys(imported).length === 0) {
|
|
197
|
+
writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'no-op', source: sourceName, imported, skipped });
|
|
198
|
+
info(`${ENTRY_ID}: legacy settings import (${LEGACY_SECTION} → ${ENTRY_ID}): nothing to import (all values equal or unknown)`);
|
|
199
|
+
return { outcome: 'no-op', imported, skipped };
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
await settings.update(ENTRY_ID, imported);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
// No marker: the next boot retries the import.
|
|
206
|
+
warn(`${ENTRY_ID}: legacy settings import failed (will retry next boot): ${error instanceof Error ? error.message : String(error)}`);
|
|
207
|
+
return { outcome: 'update-failed', imported, skipped };
|
|
208
|
+
}
|
|
209
|
+
writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'imported', source: sourceName, imported, skipped });
|
|
210
|
+
info(`${ENTRY_ID}: legacy settings import (${LEGACY_SECTION} → ${ENTRY_ID}): ${Object.keys(imported).join(', ')}`);
|
|
211
|
+
return { outcome: 'imported', imported, skipped };
|
|
212
|
+
}
|
|
213
|
+
/** Persist the audit marker; a failed write leaves no marker (harmless — the
|
|
214
|
+
* next boot re-runs and converges, every step after the marker check is
|
|
215
|
+
* idempotent). */
|
|
216
|
+
function writeMarker(path, marker) {
|
|
217
|
+
try {
|
|
218
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
219
|
+
writeFileSync(path, `${JSON.stringify(marker, null, 2)}\n`);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// contained — see above
|
|
223
|
+
}
|
|
224
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwayds/dsh-topics-memory",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "dsh-plugin: quality-first topic memory for dsh — a topic keeps the starting question, conclusion, impact and dependencies, not the process; local-first git-traced OKF bundle, budgeted LLM-free injection",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"build": "tsc -p tsconfig.json",
|
|
43
43
|
"check": "tsc --noEmit -p tsconfig.json",
|
|
44
44
|
"pretest": "tsc -p tsconfig.json",
|
|
45
|
-
"test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs",
|
|
45
|
+
"test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs test/legacy-import.test.mjs",
|
|
46
46
|
"smoke": "node scripts/smoke-boot.mjs"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {},
|
package/src/index.ts
CHANGED
|
@@ -52,6 +52,7 @@ import type { SlowDelivery } from './service.ts'
|
|
|
52
52
|
import { CONFIG_KEYS, TopicsConfig, type TopicsConfigValue } from './config.ts'
|
|
53
53
|
import type { AskServiceResolver, AskServiceShape, LlmDirectoryResolver, LlmDirectoryShape } from './onboard.ts'
|
|
54
54
|
import { isDelegated } from './delegation.ts'
|
|
55
|
+
import { runLegacySettingsImport, type LegacyImportLogger, type SettingsUpdateSeam } from './legacy-import.ts'
|
|
55
56
|
|
|
56
57
|
export const name = 'dsh-topics-memory'
|
|
57
58
|
|
|
@@ -354,7 +355,7 @@ export function stripFrontmatter(raw: string): string {
|
|
|
354
355
|
return raw
|
|
355
356
|
}
|
|
356
357
|
|
|
357
|
-
export function apply(ctx: Context, config: TopicsRuntimeConfig = {}): void {
|
|
358
|
+
export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Promise<void> {
|
|
358
359
|
// `inject = ['skills']` guarantees the service exists on every real host;
|
|
359
360
|
// register unconditionally so a missing service fails loud instead of
|
|
360
361
|
// silently dropping the bundled skill.
|
|
@@ -1010,6 +1011,27 @@ export function apply(ctx: Context, config: TopicsRuntimeConfig = {}): void {
|
|
|
1010
1011
|
}
|
|
1011
1012
|
cmdCtx.effect(() => commands.register(buildTopicsCommand(service, mutate as never, resolveAsk, resolveLlm, manualDistill, manualConsolidate)), 'topics: /topics')
|
|
1012
1013
|
})
|
|
1014
|
+
|
|
1015
|
+
// ---- One-time legacy settings import (0.1.5 → 0.1.7 upgrade path) ----
|
|
1016
|
+
// The 0.1.7 host imports the old settings.yaml ONCE by "section name =
|
|
1017
|
+
// entry id" and renames it settings.yaml.imported — this plugin's legacy
|
|
1018
|
+
// section was named `topics`, so the host's import silently dropped it.
|
|
1019
|
+
// Recover it here (see legacy-import.ts). Awaited as apply()'s LAST step:
|
|
1020
|
+
// every registration above stays synchronous (fake-ctx tests dispatch
|
|
1021
|
+
// immediately), while a real boot — whose loader awaits the fiber setup —
|
|
1022
|
+
// also settles the import before activation completes. Fully contained:
|
|
1023
|
+
// any failure only warns and leaves the audit marker unwritten, so the
|
|
1024
|
+
// next boot retries; activation never depends on this.
|
|
1025
|
+
try {
|
|
1026
|
+
await runLegacySettingsImport({
|
|
1027
|
+
home: paths.resolveDshHome(),
|
|
1028
|
+
settings: ctx.settings as SettingsUpdateSeam,
|
|
1029
|
+
logger: (ctx as unknown as { logger?: LegacyImportLogger }).logger,
|
|
1030
|
+
getCurrent: (key) => cfgNow()[key],
|
|
1031
|
+
})
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
warn(`dsh-topics-memory 旧 settings 迁移失败(不影响启动,下次启动重试):${error instanceof Error ? error.message : String(error)}`)
|
|
1034
|
+
}
|
|
1013
1035
|
}
|
|
1014
1036
|
|
|
1015
1037
|
/** Config defaults for harnesses that skip schemastery's default application. */
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time legacy settings import for the 0.1.5 → 0.1.7 upgrade: the dsh
|
|
3
|
+
* 0.1.7 host renames the old `~/.dsh/settings.yaml` to
|
|
4
|
+
* `settings.yaml.imported` and imports its sections as profile patches keyed
|
|
5
|
+
* by "section name = entry id". This plugin's old section was named `topics`,
|
|
6
|
+
* NOT `dsh-topics-memory` — the host's one-shot import silently drops it.
|
|
7
|
+
* This module recovers it once:
|
|
8
|
+
*
|
|
9
|
+
* 1. read `<home>/settings.yaml.imported` (preferred) →
|
|
10
|
+
* `<home>/settings.yaml` (fallback);
|
|
11
|
+
* 2. flat-parse the `topics:` section (scalars only, conservative);
|
|
12
|
+
* 3. keep keys whose legacy value differs from the current effective value;
|
|
13
|
+
* 4. `settings.update('dsh-topics-memory', patch)` → on success write the
|
|
14
|
+
* audit marker `<home>/storages/dsh-topics-memory/legacy-import.json`;
|
|
15
|
+
* on failure warn and write nothing (the next boot retries);
|
|
16
|
+
* 5. marker present → the whole migration is skipped (idempotent — a
|
|
17
|
+
* later manual edit of the imported file must not resurrect old values).
|
|
18
|
+
*
|
|
19
|
+
* A marker is written for EVERY settled outcome (imported / no-op /
|
|
20
|
+
* no-legacy / no-section) so this runs at most once per home. Never throws
|
|
21
|
+
* into the caller beyond what the apply()-side catch contains: plugin
|
|
22
|
+
* activation must not depend on this.
|
|
23
|
+
*
|
|
24
|
+
* @module legacy-import
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
28
|
+
import { join } from 'node:path'
|
|
29
|
+
|
|
30
|
+
/** Profile entry id the recovered values are written to. */
|
|
31
|
+
export const ENTRY_ID = 'dsh-topics-memory'
|
|
32
|
+
|
|
33
|
+
/** Legacy top-level section name in the old settings.yaml. */
|
|
34
|
+
export const LEGACY_SECTION = 'topics'
|
|
35
|
+
|
|
36
|
+
/** Storage dir name for the audit marker (repo dir name, host convention). */
|
|
37
|
+
export const STORAGE_DIR_NAME = 'dsh-topics-memory'
|
|
38
|
+
|
|
39
|
+
/** Legacy keys worth recovering, mapped 1:1 onto Config keys of the same name. */
|
|
40
|
+
export const LEGACY_KEYS = ['repo', 'distillProvider', 'distillModel', 'topK', 'totalBudget', 'autoObserve'] as const
|
|
41
|
+
|
|
42
|
+
export type LegacyKey = (typeof LEGACY_KEYS)[number]
|
|
43
|
+
|
|
44
|
+
/** The settings seam the import writes through (`ctx.settings.update`). */
|
|
45
|
+
export interface SettingsUpdateSeam {
|
|
46
|
+
update(ns: string, patch: object): Promise<void>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Minimal logger surface (both sinks optional — bare harnesses have none). */
|
|
50
|
+
export interface LegacyImportLogger {
|
|
51
|
+
info?(message: string): void
|
|
52
|
+
warn?(message: string): void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LegacyImportInput {
|
|
56
|
+
/** Resolved dsh home (`paths.resolveDshHome()` at the call site). */
|
|
57
|
+
home: string
|
|
58
|
+
/** `ctx.settings` — absent/unusable seams skip the import without a marker. */
|
|
59
|
+
settings?: SettingsUpdateSeam | undefined
|
|
60
|
+
/** Host logger (optional). */
|
|
61
|
+
logger?: LegacyImportLogger | undefined
|
|
62
|
+
/** Current effective value for one legacy key (Volatile ref `.get()` at the call site). */
|
|
63
|
+
getCurrent(key: LegacyKey): unknown
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type LegacyImportOutcome =
|
|
67
|
+
| 'imported' // ≥1 key written through settings.update, marker written
|
|
68
|
+
| 'no-op' // section found but every key equal/unknown, marker written
|
|
69
|
+
| 'no-legacy' // neither legacy file exists, marker written
|
|
70
|
+
| 'no-section' // legacy file exists without a `topics:` section, marker written
|
|
71
|
+
| 'marker-exists' // already migrated before — nothing touched
|
|
72
|
+
| 'no-settings' // settings seam unusable — no marker, retried next boot
|
|
73
|
+
| 'update-failed' // settings.update rejected — no marker, retried next boot
|
|
74
|
+
|
|
75
|
+
export interface LegacyImportResult {
|
|
76
|
+
outcome: LegacyImportOutcome
|
|
77
|
+
/** Keys written through settings.update (also in the marker). */
|
|
78
|
+
imported: Record<string, unknown>
|
|
79
|
+
/** Keys not imported, with the reason (audit trail). */
|
|
80
|
+
skipped: Record<string, string>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Candidates in priority order: the host's renamed document first. */
|
|
84
|
+
export function legacySettingsCandidates(home: string): string[] {
|
|
85
|
+
return [join(home, 'settings.yaml.imported'), join(home, 'settings.yaml')]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Audit marker path: `<home>/storages/<dir>/legacy-import.json`. */
|
|
89
|
+
export function legacyMarkerPath(home: string): string {
|
|
90
|
+
return join(home, 'storages', STORAGE_DIR_NAME, 'legacy-import.json')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function escapeRegExp(s: string): string {
|
|
94
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A fully quoted scalar: balanced matching quote pair. */
|
|
98
|
+
function isQuoted(s: string): boolean {
|
|
99
|
+
return s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function unquote(s: string): string {
|
|
103
|
+
const inner = s.slice(1, -1)
|
|
104
|
+
// Double quotes: unescape \" and \\; single quotes: '' is a literal '.
|
|
105
|
+
if (s.startsWith('"')) return inner.replaceAll('\\"', '"').replaceAll('\\\\', '\\')
|
|
106
|
+
return inner.replaceAll("''", "'")
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Scalar inference for one flat `key: value` value. Quoted values are
|
|
111
|
+
* unquoted (mirroring src/yaml.ts); a single-line quoted flow-JSON value
|
|
112
|
+
* (`'["a","b"]'`) is JSON-parsed after unquoting — a malformed one stays a
|
|
113
|
+
* string. Unquoted flow collections are kept as strings (conservative:
|
|
114
|
+
* this is not a full YAML parser). Empty never reaches here (the line
|
|
115
|
+
* parser skips value-less keys — they open a nested block).
|
|
116
|
+
*/
|
|
117
|
+
export function inferScalar(raw: string): unknown {
|
|
118
|
+
const s = raw.trim()
|
|
119
|
+
if (isQuoted(s)) {
|
|
120
|
+
const inner = unquote(s)
|
|
121
|
+
const t = inner.trim()
|
|
122
|
+
if (t.startsWith('[') || t.startsWith('{')) {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(t)
|
|
125
|
+
} catch {
|
|
126
|
+
// not JSON after all — keep the unquoted string
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return inner
|
|
130
|
+
}
|
|
131
|
+
if (s === 'true' || s === 'True' || s === 'TRUE') return true
|
|
132
|
+
if (s === 'false' || s === 'False' || s === 'FALSE') return false
|
|
133
|
+
if (/^-?\d+$/.test(s) || /^-?\d+\.\d+$/.test(s)) return Number(s)
|
|
134
|
+
return s
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Flat section parser: locate the top-level `section:` line and collect its
|
|
139
|
+
* DIRECT children (`key: value` at one common indent) until the next
|
|
140
|
+
* top-level key. Deliberately conservative — NOT a YAML parser:
|
|
141
|
+
* - `key:` with an empty value (opens a nested map/sequence) is skipped, and
|
|
142
|
+
* the deeper lines under it never match the child indent, so folded values
|
|
143
|
+
* are skipped whole;
|
|
144
|
+
* - lines at any other indent are ignored;
|
|
145
|
+
* - an inline value on the header line itself (`section: x`) is not a flat
|
|
146
|
+
* map → `{}`.
|
|
147
|
+
* Pure function — tests run it against literal strings.
|
|
148
|
+
*/
|
|
149
|
+
export function parseFlatSection(raw: string, section: string): Record<string, unknown> {
|
|
150
|
+
const lines = raw.split('\n').map((l) => l.replace(/\r$/, ''))
|
|
151
|
+
const header = new RegExp(`^${escapeRegExp(section)}:(.*)$`)
|
|
152
|
+
const start = lines.findIndex((l) => header.test(l))
|
|
153
|
+
if (start < 0) return {}
|
|
154
|
+
// An inline header value means the section is not a block map of children.
|
|
155
|
+
if (lines[start].replace(header, '$1').trim() !== '') return {}
|
|
156
|
+
const out: Record<string, unknown> = {}
|
|
157
|
+
let childIndent: number | undefined
|
|
158
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
159
|
+
const line = lines[i]
|
|
160
|
+
if (line.trim() === '' || line.trimStart().startsWith('#')) continue
|
|
161
|
+
const indent = line.length - line.trimStart().length
|
|
162
|
+
if (indent === 0) break // next top-level key — section over
|
|
163
|
+
if (childIndent === undefined) childIndent = indent
|
|
164
|
+
if (indent !== childIndent) continue // nested block content — skipped
|
|
165
|
+
const match = /^([^\s:][^:]*):(?:[ \t]+(.*))?$/.exec(line.trim())
|
|
166
|
+
if (match === null) continue // not a `key: value` line — skipped
|
|
167
|
+
if (match[2] === undefined || match[2].trim() === '') continue // opens a nested block
|
|
168
|
+
out[match[1].trim()] = inferScalar(match[2])
|
|
169
|
+
}
|
|
170
|
+
return out
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run the one-time import. Resolves with the outcome; only throws on truly
|
|
175
|
+
* unexpected I/O errors (the apply()-side catch contains those). The marker
|
|
176
|
+
* is written for every settled outcome EXCEPT the retry-worthy failures
|
|
177
|
+
* (`no-settings`, `update-failed`) — those must re-run at the next boot.
|
|
178
|
+
*/
|
|
179
|
+
export async function runLegacySettingsImport(input: LegacyImportInput): Promise<LegacyImportResult> {
|
|
180
|
+
const skipped: Record<string, string> = {}
|
|
181
|
+
const imported: Record<string, unknown> = {}
|
|
182
|
+
const warn = (message: string): void => {
|
|
183
|
+
try {
|
|
184
|
+
input.logger?.warn?.(message)
|
|
185
|
+
} catch {
|
|
186
|
+
// contained — a broken logger must not break the import
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const info = (message: string): void => {
|
|
190
|
+
try {
|
|
191
|
+
input.logger?.info?.(message)
|
|
192
|
+
} catch {
|
|
193
|
+
// contained
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const markerPath = legacyMarkerPath(input.home)
|
|
198
|
+
if (existsSync(markerPath)) return { outcome: 'marker-exists', imported, skipped }
|
|
199
|
+
|
|
200
|
+
// A settings seam that cannot accept the patch is a retry-worthy state:
|
|
201
|
+
// return WITHOUT reading/marking, so a host that mounts the service later
|
|
202
|
+
// (and a test harness pointing at a real home) neither imports nor writes.
|
|
203
|
+
const settings = input.settings
|
|
204
|
+
if (settings === undefined || typeof settings.update !== 'function') {
|
|
205
|
+
return { outcome: 'no-settings', imported, skipped }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Locate the legacy document.
|
|
209
|
+
const source = legacySettingsCandidates(input.home).find((p) => existsSync(p))
|
|
210
|
+
if (source === undefined) {
|
|
211
|
+
writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'no-legacy', source: undefined, imported, skipped })
|
|
212
|
+
return { outcome: 'no-legacy', imported, skipped }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const section = parseFlatSection(readFileSync(source, 'utf8'), LEGACY_SECTION)
|
|
216
|
+
// The marker records the source BASENAME: the marker lives inside the same
|
|
217
|
+
// home, so the directory part is implied.
|
|
218
|
+
const sourceName = source.slice(source.lastIndexOf('/') + 1)
|
|
219
|
+
const present = Object.keys(section)
|
|
220
|
+
if (present.length === 0) {
|
|
221
|
+
writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'no-section', source: sourceName, imported, skipped })
|
|
222
|
+
return { outcome: 'no-section', imported, skipped }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Key-level rule: import only when the legacy value differs from the
|
|
226
|
+
// current effective value; keys this plugin does not know are recorded and
|
|
227
|
+
// left alone (an unknown key would be rejected by the host's schema).
|
|
228
|
+
for (const key of present) {
|
|
229
|
+
if (!(LEGACY_KEYS as readonly string[]).includes(key)) {
|
|
230
|
+
skipped[key] = 'unknown-key'
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
const legacyValue = section[key]
|
|
234
|
+
if (legacyValue === input.getCurrent(key as LegacyKey)) skipped[key] = 'equal'
|
|
235
|
+
else imported[key] = legacyValue
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (Object.keys(imported).length === 0) {
|
|
239
|
+
writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'no-op', source: sourceName, imported, skipped })
|
|
240
|
+
info(`${ENTRY_ID}: legacy settings import (${LEGACY_SECTION} → ${ENTRY_ID}): nothing to import (all values equal or unknown)`)
|
|
241
|
+
return { outcome: 'no-op', imported, skipped }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
await settings.update(ENTRY_ID, imported)
|
|
246
|
+
} catch (error) {
|
|
247
|
+
// No marker: the next boot retries the import.
|
|
248
|
+
warn(`${ENTRY_ID}: legacy settings import failed (will retry next boot): ${error instanceof Error ? error.message : String(error)}`)
|
|
249
|
+
return { outcome: 'update-failed', imported, skipped }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'imported', source: sourceName, imported, skipped })
|
|
253
|
+
info(`${ENTRY_ID}: legacy settings import (${LEGACY_SECTION} → ${ENTRY_ID}): ${Object.keys(imported).join(', ')}`)
|
|
254
|
+
return { outcome: 'imported', imported, skipped }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Persist the audit marker; a failed write leaves no marker (harmless — the
|
|
258
|
+
* next boot re-runs and converges, every step after the marker check is
|
|
259
|
+
* idempotent). */
|
|
260
|
+
function writeMarker(path: string, marker: Record<string, unknown>): void {
|
|
261
|
+
try {
|
|
262
|
+
mkdirSync(join(path, '..'), { recursive: true })
|
|
263
|
+
writeFileSync(path, `${JSON.stringify(marker, null, 2)}\n`)
|
|
264
|
+
} catch {
|
|
265
|
+
// contained — see above
|
|
266
|
+
}
|
|
267
|
+
}
|