@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.
- package/README.md +5 -5
- package/README.zh.md +4 -4
- package/icon.svg +6 -0
- package/lib/config.d.ts +55 -55
- package/lib/config.js +26 -26
- package/lib/distill.d.ts +8 -0
- package/lib/distill.js +1 -1
- package/lib/index.d.ts +67 -36
- package/lib/index.js +169 -155
- package/lib/legacy-import.d.ts +94 -0
- package/lib/legacy-import.js +224 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +30 -27
- package/skills/dsh-topics-memory-config/SKILL.md +6 -6
- package/src/config.ts +26 -26
- package/src/distill.ts +15 -1
- package/src/index.ts +182 -171
- package/src/legacy-import.ts +267 -0
|
@@ -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
|
+
}
|