portable-agent-layer 0.41.1 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,9 +10,9 @@
10
10
  * pending work without running anything.
11
11
  */
12
12
 
13
- import { existsSync, readdirSync, readFileSync } from "node:fs";
13
+ import { existsSync, readdirSync, readFileSync, renameSync } from "node:fs";
14
14
  import { resolve } from "node:path";
15
- import { paths } from "../hooks/lib/paths";
15
+ import { palHome, paths } from "../hooks/lib/paths";
16
16
  import {
17
17
  legacyJsonToProgress,
18
18
  type ProjectProgress,
@@ -21,6 +21,14 @@ import {
21
21
  writeProject,
22
22
  } from "../hooks/lib/projects";
23
23
  import { readThreads, type Thread, writeThreads } from "../tools/agent/thread";
24
+ import { appendSourceLog } from "../tools/knowledge/ingest";
25
+ import {
26
+ type Entity,
27
+ type EntityFrontmatter,
28
+ exists as knowledgeExists,
29
+ save as knowledgeSave,
30
+ slugify,
31
+ } from "../tools/knowledge/lib";
24
32
 
25
33
  // ── Types ─────────────────────────────────────────────────────────
26
34
 
@@ -175,9 +183,186 @@ const v2ThreadsToIsc: Migration = {
175
183
  },
176
184
  };
177
185
 
186
+ // ── v3-entities-to-knowledge: entity-index.json → knowledge/*.md ──
187
+
188
+ interface LegacyPerson {
189
+ id: string;
190
+ name: string;
191
+ first_seen: string;
192
+ occurrences: number;
193
+ source_ids: string[];
194
+ }
195
+
196
+ interface LegacyCompany {
197
+ id: string;
198
+ name: string;
199
+ domain: string | null;
200
+ first_seen: string;
201
+ occurrences: number;
202
+ source_ids: string[];
203
+ }
204
+
205
+ interface LegacyIndex {
206
+ version?: string;
207
+ people?: Record<string, LegacyPerson>;
208
+ companies?: Record<string, LegacyCompany>;
209
+ links?: Record<string, unknown>;
210
+ sources?: Record<string, unknown>;
211
+ }
212
+
213
+ function legacyEntitiesPath(): string {
214
+ // Read from PAL_HOME-aware location to match where the legacy store lived;
215
+ // computed locally now that paths.entities() is being retired alongside this migration.
216
+ const home = palHome();
217
+ const dir = resolve(home, "memory", "entities");
218
+ return resolve(dir, "entity-index.json");
219
+ }
220
+
221
+ function readLegacyIndex(): LegacyIndex | null {
222
+ const p = legacyEntitiesPath();
223
+ if (!existsSync(p)) return null;
224
+ try {
225
+ return JSON.parse(readFileSync(p, "utf-8")) as LegacyIndex;
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ function countLegacyEntries(idx: LegacyIndex): number {
232
+ return Object.keys(idx.people ?? {}).length + Object.keys(idx.companies ?? {}).length;
233
+ }
234
+
235
+ function legacyPersonToEntity(legacy: LegacyPerson): Entity {
236
+ const fm: EntityFrontmatter = {
237
+ title: legacy.name,
238
+ type: "person",
239
+ tags: [],
240
+ created: legacy.first_seen,
241
+ updated: legacy.first_seen,
242
+ quality: 5,
243
+ status: "seedling",
244
+ related: [],
245
+ legacy_id: legacy.id,
246
+ occurrences: legacy.occurrences,
247
+ };
248
+ let body = "";
249
+ for (const sourceId of legacy.source_ids) {
250
+ body = appendSourceLog(body, sourceId, null, {}, legacy.first_seen);
251
+ }
252
+ return { domain: "People", slug: slugify(legacy.name), frontmatter: fm, body };
253
+ }
254
+
255
+ function legacyCompanyToEntity(legacy: LegacyCompany): Entity {
256
+ const baseKey = legacy.domain?.trim() ? legacy.domain : legacy.name;
257
+ const fm: EntityFrontmatter = {
258
+ title: legacy.name,
259
+ type: "company",
260
+ tags: [],
261
+ created: legacy.first_seen,
262
+ updated: legacy.first_seen,
263
+ quality: 5,
264
+ status: "seedling",
265
+ related: [],
266
+ legacy_id: legacy.id,
267
+ occurrences: legacy.occurrences,
268
+ };
269
+ if (legacy.domain) fm.domain_name = legacy.domain;
270
+ let body = "";
271
+ for (const sourceId of legacy.source_ids) {
272
+ body = appendSourceLog(body, sourceId, null, {}, legacy.first_seen);
273
+ }
274
+ return { domain: "Companies", slug: slugify(baseKey), frontmatter: fm, body };
275
+ }
276
+
277
+ const v3EntitiesToKnowledge: Migration = {
278
+ id: "v3-entities-to-knowledge",
279
+ description: "Migrate legacy entity-index.json to knowledge/{People,Companies}/*.md",
280
+
281
+ check() {
282
+ const idx = readLegacyIndex();
283
+ if (!idx) return { pending: false };
284
+ const total = countLegacyEntries(idx);
285
+ if (total === 0) return { pending: false };
286
+ // Skip if every entity already exists in the new store (idempotent).
287
+ let remaining = 0;
288
+ for (const p of Object.values(idx.people ?? {})) {
289
+ if (!knowledgeExists("People", slugify(p.name))) remaining++;
290
+ }
291
+ for (const c of Object.values(idx.companies ?? {})) {
292
+ const key = c.domain?.trim() ? c.domain : c.name;
293
+ if (!knowledgeExists("Companies", slugify(key))) remaining++;
294
+ }
295
+ return {
296
+ pending: remaining > 0,
297
+ detail: remaining > 0 ? `${remaining} of ${total} entries to migrate` : undefined,
298
+ };
299
+ },
300
+
301
+ run(dryRun = false): MigrationResult {
302
+ const idx = readLegacyIndex();
303
+ if (!idx) return { migrated: 0, skipped: 0, results: [] };
304
+
305
+ let migrated = 0;
306
+ let skipped = 0;
307
+ const results: string[] = [];
308
+
309
+ // Refuse to silently drop links/sources if a future legacy index has them.
310
+ const linksCount = Object.keys(idx.links ?? {}).length;
311
+ const sourcesCount = Object.keys(idx.sources ?? {}).length;
312
+ if (linksCount > 0 || sourcesCount > 0) {
313
+ results.push(
314
+ `aborted: legacy index has ${linksCount} link(s) and ${sourcesCount} source(s) — no destination in new store`
315
+ );
316
+ return { migrated: 0, skipped: linksCount + sourcesCount, results };
317
+ }
318
+
319
+ for (const legacy of Object.values(idx.people ?? {})) {
320
+ const entity = legacyPersonToEntity(legacy);
321
+ if (knowledgeExists(entity.domain, entity.slug)) {
322
+ skipped++;
323
+ results.push(`People/${entity.slug}: skipped (already in new store)`);
324
+ continue;
325
+ }
326
+ if (!dryRun) knowledgeSave(entity);
327
+ migrated++;
328
+ results.push(`People/${entity.slug}: ${dryRun ? "would migrate" : "migrated"}`);
329
+ }
330
+
331
+ for (const legacy of Object.values(idx.companies ?? {})) {
332
+ const entity = legacyCompanyToEntity(legacy);
333
+ if (knowledgeExists(entity.domain, entity.slug)) {
334
+ skipped++;
335
+ results.push(`Companies/${entity.slug}: skipped (already in new store)`);
336
+ continue;
337
+ }
338
+ if (!dryRun) knowledgeSave(entity);
339
+ migrated++;
340
+ results.push(`Companies/${entity.slug}: ${dryRun ? "would migrate" : "migrated"}`);
341
+ }
342
+
343
+ // After a successful, non-dry-run migration, archive the legacy file so
344
+ // re-runs don't repeatedly load and skip its contents.
345
+ if (!dryRun && migrated > 0) {
346
+ const src = legacyEntitiesPath();
347
+ if (existsSync(src)) {
348
+ const date = new Date().toISOString().slice(0, 10);
349
+ const archived = `${src}.migrated-${date}`;
350
+ try {
351
+ renameSync(src, archived);
352
+ results.push(`archived legacy index → ${archived}`);
353
+ } catch (e) {
354
+ results.push(`warn: could not rename legacy index (${(e as Error).message})`);
355
+ }
356
+ }
357
+ }
358
+
359
+ return { migrated, skipped, results };
360
+ },
361
+ };
362
+
178
363
  // ── Registry ──────────────────────────────────────────────────────
179
364
 
180
- const MIGRATIONS: Migration[] = [v1Projects, v2ThreadsToIsc];
365
+ const MIGRATIONS: Migration[] = [v1Projects, v2ThreadsToIsc, v3EntitiesToKnowledge];
181
366
 
182
367
  // ── Public API ────────────────────────────────────────────────────
183
368
 
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Shared export logic — zips user state directories.
3
- * Used by tools/export.ts (manual) and handlers/backup.ts (automatic).
3
+ * Used by cli/index.ts (pal cli export) and handlers/backup.ts (automatic).
4
4
  */
5
5
 
6
6
  import { existsSync, readdirSync } from "node:fs";
@@ -47,7 +47,8 @@ export const paths = {
47
47
  wisdom: () => ensureDir(home("memory", "wisdom", "frames")),
48
48
  wisdomState: () => ensureDir(home("memory", "wisdom", "state")),
49
49
  relationship: () => ensureDir(home("memory", "relationship")),
50
- entities: () => ensureDir(home("memory", "entities")),
50
+ knowledge: () => ensureDir(home("memory", "knowledge")),
51
+ knowledgeDomain: (d: string) => ensureDir(home("memory", "knowledge", d)),
51
52
  failures: () => ensureDir(home("memory", "learning", "failures")),
52
53
  retrievalIndex: () => home("memory", "learning", ".retrieval-index.json"),
53
54
  progress: () => ensureDir(home("memory", "state", "progress")),
@@ -273,22 +273,22 @@ export function loadCodexHooksTemplate(
273
273
  }
274
274
  }
275
275
 
276
- /** Merge PAL hooks into an existing Codex hooks.json. Deduplicates by canonical command path. */
277
- export function mergeCodexHooks(existing: CodexHooks, template: CodexHooks): CodexHooks {
278
- const result: CodexHooks = { ...existing };
279
- if (!template.hooks) return result;
280
- result.hooks ??= {};
281
-
282
- // Collect canonical paths of PAL template commands so we can evict stale variants
283
- const palCanonical = new Set(
284
- Object.values(template.hooks).flatMap((groups) =>
276
+ /** Collect canonical command paths from a Codex hooks template (PAL-managed commands). */
277
+ function collectPalCanonical(template: CodexHooks): Set<string> {
278
+ return new Set(
279
+ Object.values(template.hooks ?? {}).flatMap((groups) =>
285
280
  groups.flatMap((g) => g.hooks.map((h) => canonicalCmd(h.command)))
286
281
  )
287
282
  );
283
+ }
288
284
 
289
- // Strip any existing entries (nested or flat) whose canonical path matches a PAL command
290
- for (const event of Object.keys(result.hooks)) {
291
- result.hooks[event] = (result.hooks[event] ?? [])
285
+ /** Strip entries (nested or flat) whose canonical command matches a PAL-managed command. */
286
+ function stripPalHooks(
287
+ hooks: Record<string, CodexHookGroup[]>,
288
+ palCanonical: Set<string>
289
+ ): void {
290
+ for (const event of Object.keys(hooks)) {
291
+ hooks[event] = (hooks[event] ?? [])
292
292
  .map((g) => {
293
293
  const flat = g as unknown as CodexHookCommand;
294
294
  if (!g.hooks && flat.command && palCanonical.has(canonicalCmd(flat.command))) {
@@ -300,10 +300,18 @@ export function mergeCodexHooks(existing: CodexHooks, template: CodexHooks): Cod
300
300
  return filtered.length > 0 ? { ...g, hooks: filtered } : null;
301
301
  })
302
302
  .filter((g): g is CodexHookGroup => g !== null);
303
- if (result.hooks[event].length === 0) delete result.hooks[event];
303
+ if (hooks[event].length === 0) delete hooks[event];
304
304
  }
305
+ }
306
+
307
+ /** Merge PAL hooks into an existing Codex hooks.json. Deduplicates by canonical command path. */
308
+ export function mergeCodexHooks(existing: CodexHooks, template: CodexHooks): CodexHooks {
309
+ const result: CodexHooks = { ...existing };
310
+ if (!template.hooks) return result;
311
+ result.hooks ??= {};
312
+
313
+ stripPalHooks(result.hooks, collectPalCanonical(template));
305
314
 
306
- // Add fresh template entries
307
315
  for (const [event, groups] of Object.entries(template.hooks)) {
308
316
  const current = result.hooks[event] ?? [];
309
317
  for (const group of groups) current.push(group);
@@ -320,28 +328,7 @@ export function unmergeCodexHooks(
320
328
  const result: CodexHooks = { ...existing };
321
329
  if (!template.hooks || !result.hooks) return result;
322
330
 
323
- // Match by canonical path so prefix variants (PAL_AGENT=codex, etc.) are all removed
324
- const palCanonical = new Set(
325
- Object.values(template.hooks).flatMap((groups) =>
326
- groups.flatMap((g) => g.hooks.map((h) => canonicalCmd(h.command)))
327
- )
328
- );
329
-
330
- for (const event of Object.keys(result.hooks)) {
331
- result.hooks[event] = (result.hooks[event] ?? [])
332
- .map((g) => {
333
- const flat = g as unknown as CodexHookCommand;
334
- if (!g.hooks && flat.command && palCanonical.has(canonicalCmd(flat.command))) {
335
- return null;
336
- }
337
- const filtered = (g.hooks ?? []).filter(
338
- (h) => !palCanonical.has(canonicalCmd(h.command))
339
- );
340
- return filtered.length > 0 ? { ...g, hooks: filtered } : null;
341
- })
342
- .filter((g): g is CodexHookGroup => g !== null);
343
- if (result.hooks[event].length === 0) delete result.hooks[event];
344
- }
331
+ stripPalHooks(result.hooks, collectPalCanonical(template));
345
332
  if (Object.keys(result.hooks).length === 0) delete result.hooks;
346
333
  return result;
347
334
  }