@aiwayds/dsh-topics-memory 0.17.1 → 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.
@@ -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
+ }