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.
- package/.husky/install.mjs +8 -0
- package/README.md +2 -1
- package/assets/skills/analyze-youtube/SKILL.md +1 -1
- package/assets/skills/entities/SKILL.md +95 -0
- package/assets/templates/PAL/README.md +1 -1
- package/package.json +10 -12
- package/src/cli/index.ts +8 -0
- package/src/cli/knowledge.ts +620 -0
- package/src/cli/migrate.ts +188 -3
- package/src/hooks/lib/export.ts +1 -1
- package/src/hooks/lib/paths.ts +2 -1
- package/src/targets/lib.ts +23 -36
- package/src/tools/knowledge/graph.ts +395 -0
- package/src/tools/knowledge/ingest.ts +409 -0
- package/src/tools/knowledge/lib.ts +493 -0
- package/assets/skills/extract-entities/SKILL.md +0 -62
- package/assets/skills/extract-entities/tools/entity-save.ts +0 -110
- package/src/hooks/lib/entities.ts +0 -304
- package/src/tools/export.ts +0 -40
- package/src/tools/import.ts +0 -111
package/src/cli/migrate.ts
CHANGED
|
@@ -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
|
|
package/src/hooks/lib/export.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared export logic — zips user state directories.
|
|
3
|
-
* Used by
|
|
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";
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -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
|
-
|
|
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")),
|
package/src/targets/lib.ts
CHANGED
|
@@ -273,22 +273,22 @@ export function loadCodexHooksTemplate(
|
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
/**
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
}
|