pi-hermes-memory 0.7.8 → 0.7.10

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/src/index.ts CHANGED
@@ -51,18 +51,20 @@ import { loadConfig } from "./config.js";
51
51
  import { detectProject, detectProjectSkills } from "./project.js";
52
52
  import { buildPromptContext } from "./prompt-context.js";
53
53
  import { migrateLegacyProjectMemoryDirs } from "./project-memory-migration.js";
54
+ import { migrateExtensionRoot } from "./extension-root-migration.js";
54
55
 
55
56
  export function resolveProjectSkillDiscovery(
56
57
  skillStore: SkillStore,
57
58
  projectsMemoryDir: string | undefined,
58
59
  cwd?: string,
59
- ): { skillPaths: string[] } | undefined {
60
+ ): { skillPaths: string[] } {
60
61
  const detected = detectProjectSkills(projectsMemoryDir, cwd);
61
62
  skillStore.setProjectContext(detected.name, detected.skillsDir);
62
- if (!detected.skillsDir) return undefined;
63
- return {
64
- skillPaths: [detected.skillsDir],
65
- };
63
+
64
+ const skillPaths = [skillStore.getGlobalSkillsDir()];
65
+ if (detected.skillsDir) skillPaths.push(detected.skillsDir);
66
+
67
+ return { skillPaths };
66
68
  }
67
69
 
68
70
  export function registerProjectSkillDiscoveryHandler(
@@ -78,17 +80,32 @@ export function registerProjectSkillDiscoveryHandler(
78
80
  export default function (pi: ExtensionAPI) {
79
81
  const config = loadConfig();
80
82
 
81
- const globalDir = config.memoryDir ?? path.join(os.homedir(), ".pi", "agent", "memory");
82
83
  const agentRoot = path.join(os.homedir(), ".pi", "agent");
83
- const store = new MemoryStore(config);
84
+ const legacyGlobalDir = path.join(agentRoot, "memory");
85
+ const defaultGlobalDir = path.join(agentRoot, "pi-hermes-memory");
86
+
87
+ const configuredMemoryDir = config.memoryDir?.trim();
88
+ const pointsToLegacyMemoryDir = configuredMemoryDir
89
+ ? path.resolve(configuredMemoryDir) === path.resolve(legacyGlobalDir)
90
+ : false;
91
+
92
+ const globalDir = !configuredMemoryDir || pointsToLegacyMemoryDir
93
+ ? defaultGlobalDir
94
+ : configuredMemoryDir;
95
+
96
+ const shouldMigrateExtensionRoot = !configuredMemoryDir || pointsToLegacyMemoryDir;
97
+ let extensionRootMigrated = false;
98
+
99
+ const store = new MemoryStore({ ...config, memoryDir: globalDir });
84
100
  const project = detectProject(config.projectsMemoryDir);
85
101
  const projectName = project.name ?? "";
86
102
  const skillStore = new SkillStore({
87
- globalSkillsDir: path.join(agentRoot, "skills"),
103
+ globalSkillsDir: path.join(globalDir, "skills"),
88
104
  projectSkillsDir: project.memoryDir ? path.join(project.memoryDir, "skills") : null,
89
105
  projectName: project.name,
90
- legacySkillsDir: path.join(globalDir, "skills"),
91
- migrationSentinelPath: path.join(globalDir, ".skills-migrated-to-pi-native"),
106
+ legacySkillsDir: path.join(legacyGlobalDir, "skills"),
107
+ legacyPiGlobalSkillsDir: path.join(agentRoot, "skills"),
108
+ migrationSentinelPath: path.join(globalDir, ".skills-migrated-to-extension-storage"),
92
109
  });
93
110
  const dbManager = new DatabaseManager(globalDir);
94
111
 
@@ -120,6 +137,15 @@ export default function (pi: ExtensionAPI) {
120
137
 
121
138
  // ── 1. Load memory from disk on session start ──
122
139
  pi.on("session_start", async (event, _ctx) => {
140
+ if (shouldMigrateExtensionRoot && !extensionRootMigrated) {
141
+ try {
142
+ await migrateExtensionRoot(legacyGlobalDir, globalDir);
143
+ } catch {
144
+ // best effort migration only
145
+ }
146
+ extensionRootMigrated = true;
147
+ }
148
+
123
149
  refreshSkillProjectContext((event as { cwd?: string }).cwd);
124
150
  await skillStore.migrateLegacySkills();
125
151
  await skillStore.ensureDiscoveredRoots();
@@ -174,7 +200,7 @@ export default function (pi: ExtensionAPI) {
174
200
  registerPreviewContextCommand(pi, store, projectStore, projectName, config);
175
201
 
176
202
  // ── 11. SQLite session search + extended memory ──
177
- registerSessionSearchTool(pi, dbManager);
203
+ registerSessionSearchTool(pi, dbManager, config.sessionSearch ?? { variant: "legacy" });
178
204
  registerMemorySearchTool(pi, dbManager);
179
205
  registerIndexSessionsCommand(pi);
180
206
 
@@ -23,7 +23,7 @@ function writeEntries(filePath: string, entries: string[]): void {
23
23
  }
24
24
 
25
25
  function isLegacyProjectDir(agentRoot: string, projectsMemoryDir: string, name: string): boolean {
26
- if (name === "memory" || name === "skills" || name === projectsMemoryDir) return false;
26
+ if (name === "memory" || name === "pi-hermes-memory" || name === "skills" || name === projectsMemoryDir) return false;
27
27
  if (name.startsWith(".")) return false;
28
28
 
29
29
  const dir = path.join(agentRoot, name);
@@ -46,7 +46,7 @@ export class MemoryStore {
46
46
  // ─── Path helpers ───
47
47
 
48
48
  private get memoryDir(): string {
49
- return this.config.memoryDir ?? path.join(os.homedir(), ".pi", "agent", "memory");
49
+ return this.config.memoryDir ?? path.join(os.homedir(), ".pi", "agent", "pi-hermes-memory");
50
50
  }
51
51
 
52
52
  private pathFor(target: "memory" | "user" | "failure"): string {
@@ -0,0 +1,472 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ const DEFAULT_LIMIT = 50;
5
+ const MAX_LIMIT = 100;
6
+ const DEFAULT_MAX_FILES = 5000;
7
+ const DEFAULT_MAX_LINES = 500000;
8
+ const LIST_FIELDS = new Set(["all", "any", "exclude"]);
9
+ const VALUE_FIELDS = new Set(["from", "to", "cwd", "limit"]);
10
+
11
+ export interface SessionAnchorRange {
12
+ path: string;
13
+ startLine: number;
14
+ endLine: number;
15
+ sessionId?: string;
16
+ cwd?: string;
17
+ startTime?: string;
18
+ endTime?: string;
19
+ score?: number;
20
+ reason: string;
21
+ }
22
+
23
+ export interface SessionAnchorSearchResult {
24
+ success: boolean;
25
+ ranges: SessionAnchorRange[];
26
+ message?: string;
27
+ }
28
+
29
+ export interface SessionAnchorSearchOptions {
30
+ sessionsDir?: string;
31
+ maxFiles?: number;
32
+ maxLines?: number;
33
+ }
34
+
35
+ interface ParsedAnchorRequest {
36
+ from?: Date;
37
+ to?: Date;
38
+ cwd?: string;
39
+ limit: number;
40
+ all: string[];
41
+ any: string[];
42
+ exclude: string[];
43
+ hasTimeConstraint: boolean;
44
+ hasTextConstraint: boolean;
45
+ }
46
+
47
+ interface LineHit {
48
+ path: string;
49
+ lineNumber: number;
50
+ sessionId?: string;
51
+ cwd?: string;
52
+ timestamp?: string;
53
+ timestampMs?: number;
54
+ text: string;
55
+ score: number;
56
+ reason: string;
57
+ }
58
+
59
+ interface PendingRange {
60
+ path: string;
61
+ startLine: number;
62
+ endLine: number;
63
+ sessionId?: string;
64
+ cwd?: string;
65
+ startTime?: string;
66
+ endTime?: string;
67
+ score: number;
68
+ reason: string;
69
+ text: string;
70
+ }
71
+
72
+ export function searchSessionAnchors(
73
+ markdown: string,
74
+ options: SessionAnchorSearchOptions = {},
75
+ ): SessionAnchorSearchResult {
76
+ const parsed = parseMarkdownRequest(markdown);
77
+ if (!parsed.success) {
78
+ return { success: false, ranges: [], message: parsed.message };
79
+ }
80
+
81
+ if (!options.sessionsDir) {
82
+ return { success: false, ranges: [], message: "sessionsDir is required" };
83
+ }
84
+
85
+ if (!fs.existsSync(options.sessionsDir)) {
86
+ return { success: false, ranges: [], message: `sessionsDir does not exist: ${options.sessionsDir}` };
87
+ }
88
+
89
+ const files = findJsonlFiles(options.sessionsDir).sort();
90
+ const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
91
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
92
+ if (files.length > maxFiles) {
93
+ return {
94
+ success: false,
95
+ ranges: [],
96
+ message: `Request too broad: ${files.length} session files exceed the configured scan cap of ${maxFiles}. Add from/to, cwd, all, or any constraints.`,
97
+ };
98
+ }
99
+
100
+ const ranges: PendingRange[] = [];
101
+ let scannedLines = 0;
102
+
103
+ for (const file of files) {
104
+ const remainingLines = maxLines - scannedLines;
105
+ const fileResult = searchJsonlFile(file, parsed.request, remainingLines, scannedLines, maxLines);
106
+ if (!fileResult.success) {
107
+ return { success: false, ranges: [], message: fileResult.message };
108
+ }
109
+ scannedLines += fileResult.scannedLines;
110
+ ranges.push(...fileResult.ranges);
111
+ }
112
+
113
+ const filtered = ranges.filter((range) => !containsAny(range.text, parsed.request.exclude));
114
+ const sorted = sortRanges(filtered, parsed.request.hasTextConstraint);
115
+ const limited = sorted.slice(0, parsed.request.limit).map(({ text: _text, ...range }) => range);
116
+
117
+ return {
118
+ success: true,
119
+ ranges: limited,
120
+ message: limited.length === 0 ? "No matching session anchors found." : undefined,
121
+ };
122
+ }
123
+
124
+ function parseMarkdownRequest(markdown: string): { success: true; request: ParsedAnchorRequest } | { success: false; message: string } {
125
+ if (!markdown || markdown.trim().length === 0) {
126
+ return { success: false, message: "markdown is required" };
127
+ }
128
+
129
+ const fields = new Map<string, string>();
130
+ const lists: Record<"all" | "any" | "exclude", string[]> = { all: [], any: [], exclude: [] };
131
+ const seen = new Set<string>();
132
+ let currentList: "all" | "any" | "exclude" | null = null;
133
+
134
+ const lines = markdown.split(/\r?\n/);
135
+ for (const line of lines) {
136
+ const trimmed = line.trim();
137
+ if (trimmed.length === 0) continue;
138
+
139
+ const fieldMatch = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(trimmed);
140
+ if (fieldMatch) {
141
+ const field = fieldMatch[1];
142
+ const value = fieldMatch[2];
143
+
144
+ if (!VALUE_FIELDS.has(field) && !LIST_FIELDS.has(field)) {
145
+ return {
146
+ success: false,
147
+ message: `Invalid field '${field}'. Supported fields: from, to, cwd, limit, all, any, exclude.`,
148
+ };
149
+ }
150
+ if (seen.has(field)) {
151
+ return { success: false, message: `Duplicate field '${field}'. Keep one value.` };
152
+ }
153
+ seen.add(field);
154
+
155
+ if (LIST_FIELDS.has(field)) {
156
+ if (value.trim().length > 0) {
157
+ return { success: false, message: `Invalid list section '${field}'. Use '${field}:' followed by '- item' lines.` };
158
+ }
159
+ currentList = field as "all" | "any" | "exclude";
160
+ } else {
161
+ fields.set(field, value.trim());
162
+ currentList = null;
163
+ }
164
+ continue;
165
+ }
166
+
167
+ const listMatch = /^-\s+(.*)$/.exec(trimmed);
168
+ if (listMatch && currentList) {
169
+ const term = listMatch[1].trim();
170
+ if (term.length === 0) {
171
+ return { success: false, message: `Empty term in '${currentList}'. Remove it or provide text.` };
172
+ }
173
+ lists[currentList].push(term);
174
+ continue;
175
+ }
176
+
177
+ if (listMatch && !currentList) {
178
+ return { success: false, message: "List item found outside all, any, or exclude section." };
179
+ }
180
+
181
+ return { success: false, message: `Invalid markdown line: ${trimmed}` };
182
+ }
183
+
184
+ const limitValue = fields.get("limit");
185
+ let limit = DEFAULT_LIMIT;
186
+ if (limitValue !== undefined) {
187
+ if (!/^\d+$/.test(limitValue)) {
188
+ return { success: false, message: "Invalid limit. Use a positive integer." };
189
+ }
190
+ const parsedLimit = Number(limitValue);
191
+ if (!Number.isSafeInteger(parsedLimit) || parsedLimit <= 0) {
192
+ return { success: false, message: "Invalid limit. Use a positive integer." };
193
+ }
194
+ limit = Math.min(parsedLimit, MAX_LIMIT);
195
+ }
196
+
197
+ const fromValue = fields.get("from");
198
+ const toValue = fields.get("to");
199
+ const from = fromValue === undefined ? undefined : parseDateTime(fromValue, "from");
200
+ if (from === null) return { success: false, message: "Invalid from. Use YYYY-MM-DD or an ISO timestamp." };
201
+ const to = toValue === undefined ? undefined : parseDateTime(toValue, "to");
202
+ if (to === null) return { success: false, message: "Invalid to. Use YYYY-MM-DD or an ISO timestamp." };
203
+ if (from && to && from.getTime() > to.getTime()) {
204
+ return { success: false, message: "Invalid time window. 'from' must be before or equal to 'to'." };
205
+ }
206
+
207
+ const cwd = fields.get("cwd");
208
+ if (fields.has("cwd") && (!cwd || cwd.trim().length === 0)) {
209
+ return { success: false, message: "Invalid cwd. Provide a non-empty path." };
210
+ }
211
+ const all = lists.all;
212
+ const any = lists.any;
213
+ const exclude = lists.exclude;
214
+ const hasTimeConstraint = Boolean(from || to);
215
+ const hasCwdConstraint = Boolean(cwd);
216
+ const hasTextConstraint = all.length > 0 || any.length > 0;
217
+
218
+ if (!hasTimeConstraint && !hasCwdConstraint && !hasTextConstraint) {
219
+ return {
220
+ success: false,
221
+ message: "Request needs at least one constraint: provide from/to, cwd, all, or any.",
222
+ };
223
+ }
224
+ return {
225
+ success: true,
226
+ request: { from, to, cwd, limit, all, any, exclude, hasTimeConstraint, hasTextConstraint },
227
+ };
228
+ }
229
+
230
+ function parseDateTime(value: string, boundary: "from" | "to"): Date | null {
231
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
232
+ if (dateOnly) {
233
+ const year = Number(dateOnly[1]);
234
+ const month = Number(dateOnly[2]);
235
+ const day = Number(dateOnly[3]);
236
+ const date = boundary === "from"
237
+ ? new Date(year, month - 1, day, 0, 0, 0, 0)
238
+ : new Date(year, month - 1, day, 23, 59, 59, 999);
239
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
240
+ return null;
241
+ }
242
+ return date;
243
+ }
244
+
245
+ const date = new Date(value);
246
+ return Number.isNaN(date.getTime()) ? null : date;
247
+ }
248
+
249
+ function findJsonlFiles(dir: string): string[] {
250
+ const files: string[] = [];
251
+
252
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
253
+ const fullPath = path.join(dir, entry.name);
254
+ if (entry.isDirectory()) {
255
+ files.push(...findJsonlFiles(fullPath));
256
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
257
+ files.push(fullPath);
258
+ }
259
+ }
260
+
261
+ return files;
262
+ }
263
+
264
+ function searchJsonlFile(
265
+ filePath: string,
266
+ request: ParsedAnchorRequest,
267
+ maxLines: number,
268
+ scannedBefore: number,
269
+ scanCap: number,
270
+ ): { success: true; ranges: PendingRange[]; scannedLines: number } | { success: false; message: string } {
271
+ const content = fs.readFileSync(filePath, "utf-8");
272
+ const lines = content.split(/\r?\n/);
273
+ const hits: LineHit[] = [];
274
+ let currentSessionId: string | undefined;
275
+ let currentCwd: string | undefined;
276
+
277
+ let scannedLines = 0;
278
+
279
+ for (let index = 0; index < lines.length; index += 1) {
280
+ const line = lines[index];
281
+ if (line.trim().length === 0) continue;
282
+
283
+ scannedLines += 1;
284
+ if (scannedLines > maxLines) {
285
+ return {
286
+ success: false,
287
+ message: `Request too broad: scanned ${scannedBefore + scannedLines} session lines, exceeding the configured scan cap of ${scanCap}. Add from/to, cwd, all, or any constraints.`,
288
+ };
289
+ }
290
+
291
+ let event: unknown;
292
+ try {
293
+ event = JSON.parse(line);
294
+ } catch {
295
+ return { success: false, message: `Invalid JSON in ${filePath}:${index + 1}` };
296
+ }
297
+
298
+ const sessionId = getSessionId(event) ?? currentSessionId;
299
+ if (sessionId) currentSessionId = sessionId;
300
+
301
+ const cwd = getCwd(event) ?? currentCwd;
302
+ if (cwd) currentCwd = cwd;
303
+
304
+ if (request.cwd && cwd !== request.cwd) continue;
305
+
306
+ const timestamp = getTimestamp(event);
307
+ const timestampMs = timestamp ? Date.parse(timestamp) : undefined;
308
+ const hasValidTimestamp = timestampMs !== undefined && !Number.isNaN(timestampMs);
309
+ if (request.hasTimeConstraint) {
310
+ if (!hasValidTimestamp) continue;
311
+ if (request.from && timestampMs < request.from.getTime()) continue;
312
+ if (request.to && timestampMs > request.to.getTime()) continue;
313
+ }
314
+
315
+ const text = textualizeEvent(event);
316
+ const termScore = scoreTerms(text, request);
317
+ const matchesTerms = request.hasTextConstraint ? termScore > 0 : true;
318
+ if (!matchesTerms) continue;
319
+
320
+ if (!request.hasTextConstraint && !hasValidTimestamp) continue;
321
+
322
+ hits.push({
323
+ path: filePath,
324
+ lineNumber: index + 1,
325
+ sessionId,
326
+ cwd,
327
+ timestamp: hasValidTimestamp ? timestamp : undefined,
328
+ timestampMs: hasValidTimestamp ? timestampMs : undefined,
329
+ text,
330
+ score: request.hasTextConstraint ? termScore : 1,
331
+ reason: buildReason(request, text),
332
+ });
333
+ }
334
+
335
+ return { success: true, ranges: mergeAdjacentHits(hits), scannedLines };
336
+ }
337
+
338
+ function mergeAdjacentHits(hits: LineHit[]): PendingRange[] {
339
+ const ranges: PendingRange[] = [];
340
+
341
+ for (const hit of hits) {
342
+ const last = ranges.at(-1);
343
+ if (last && last.path === hit.path && last.endLine + 1 === hit.lineNumber && last.reason === hit.reason) {
344
+ last.endLine = hit.lineNumber;
345
+ last.score += hit.score;
346
+ last.text += "\n" + hit.text;
347
+ last.sessionId ??= hit.sessionId;
348
+ last.cwd ??= hit.cwd;
349
+ if (!last.startTime && hit.timestamp) last.startTime = hit.timestamp;
350
+ if (hit.timestamp) last.endTime = hit.timestamp;
351
+ continue;
352
+ }
353
+
354
+ ranges.push({
355
+ path: hit.path,
356
+ startLine: hit.lineNumber,
357
+ endLine: hit.lineNumber,
358
+ sessionId: hit.sessionId,
359
+ cwd: hit.cwd,
360
+ startTime: hit.timestamp,
361
+ endTime: hit.timestamp,
362
+ score: hit.score,
363
+ reason: hit.reason,
364
+ text: hit.text,
365
+ });
366
+ }
367
+
368
+ return ranges;
369
+ }
370
+
371
+ function sortRanges(ranges: PendingRange[], textConstrained: boolean): PendingRange[] {
372
+ return [...ranges].sort((a, b) => {
373
+ if (textConstrained && b.score !== a.score) return b.score - a.score;
374
+ const timeCompare = Date.parse(a.startTime ?? "") - Date.parse(b.startTime ?? "");
375
+ if (!Number.isNaN(timeCompare) && timeCompare !== 0) return timeCompare;
376
+ const pathCompare = a.path.localeCompare(b.path);
377
+ if (pathCompare !== 0) return pathCompare;
378
+ return a.startLine - b.startLine;
379
+ });
380
+ }
381
+
382
+ function scoreTerms(text: string, request: ParsedAnchorRequest): number {
383
+ const lower = text.toLocaleLowerCase();
384
+ const matchedAll = request.all.filter((term) => lower.includes(term.toLocaleLowerCase()));
385
+ const matchedAny = request.any.filter((term) => lower.includes(term.toLocaleLowerCase()));
386
+
387
+ if (request.all.length > 0 && matchedAll.length !== request.all.length) return 0;
388
+ if (request.any.length > 0 && matchedAny.length === 0) return 0;
389
+
390
+ if (request.all.length === 0 && request.any.length === 0) return 1;
391
+ return matchedAll.length * 2 + matchedAny.length;
392
+ }
393
+
394
+ function buildReason(request: ParsedAnchorRequest, text: string): string {
395
+ if (!request.hasTextConstraint) {
396
+ if (request.hasTimeConstraint && request.cwd) return "cwd+time window";
397
+ if (request.hasTimeConstraint) return "time window";
398
+ return "cwd";
399
+ }
400
+
401
+ const lower = text.toLocaleLowerCase();
402
+ const parts: string[] = [];
403
+ if (request.all.length > 0) parts.push(`matched all: ${request.all.join(", ")}`);
404
+ const matchedAny = request.any.filter((term) => lower.includes(term.toLocaleLowerCase()));
405
+ if (matchedAny.length > 0) parts.push(`matched any: ${matchedAny.join(", ")}`);
406
+ return parts.join("; ");
407
+ }
408
+
409
+ function containsAny(text: string, terms: string[]): boolean {
410
+ const lower = text.toLocaleLowerCase();
411
+ return terms.some((term) => lower.includes(term.toLocaleLowerCase()));
412
+ }
413
+
414
+ function getTimestamp(event: unknown): string | undefined {
415
+ if (!isRecord(event)) return undefined;
416
+ if (typeof event.timestamp === "string") return event.timestamp;
417
+ if (isRecord(event.message) && typeof event.message.timestamp === "string") return event.message.timestamp;
418
+ return undefined;
419
+ }
420
+
421
+ function getSessionId(event: unknown): string | undefined {
422
+ if (!isRecord(event)) return undefined;
423
+ if (typeof event.sessionId === "string") return event.sessionId;
424
+ if (typeof event.session_id === "string") return event.session_id;
425
+ if (event.type === "session" && typeof event.id === "string") return event.id;
426
+ if (isRecord(event.session) && typeof event.session.id === "string") return event.session.id;
427
+ return undefined;
428
+ }
429
+
430
+ function getCwd(event: unknown): string | undefined {
431
+ if (!isRecord(event)) return undefined;
432
+ if (typeof event.cwd === "string") return event.cwd;
433
+ if (isRecord(event.session) && typeof event.session.cwd === "string") return event.session.cwd;
434
+ return undefined;
435
+ }
436
+
437
+ function textualizeEvent(event: unknown): string {
438
+ const parts: string[] = [];
439
+ collectStrings(event, parts);
440
+ return parts.join("\n");
441
+ }
442
+
443
+ const METADATA_TEXT_KEYS = new Set([
444
+ "type",
445
+ "id",
446
+ "parentId",
447
+ "sessionId",
448
+ "session_id",
449
+ "timestamp",
450
+ "cwd",
451
+ "role",
452
+ "customType",
453
+ ]);
454
+
455
+ function collectStrings(value: unknown, parts: string[], key?: string): void {
456
+ if (typeof value === "string") {
457
+ if (!key || !METADATA_TEXT_KEYS.has(key)) parts.push(value);
458
+ return;
459
+ }
460
+
461
+ if (Array.isArray(value)) {
462
+ for (const item of value) collectStrings(item, parts, key);
463
+ return;
464
+ }
465
+
466
+ if (!isRecord(value)) return;
467
+ for (const [childKey, item] of Object.entries(value)) collectStrings(item, parts, childKey);
468
+ }
469
+
470
+ function isRecord(value: unknown): value is Record<string, unknown> {
471
+ return typeof value === "object" && value !== null;
472
+ }