portable-agent-layer 0.41.0 → 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.
@@ -0,0 +1,620 @@
1
+ /**
2
+ * pal cli knowledge — query and manage the knowledge store.
3
+ *
4
+ * Thin presentation layer over src/tools/knowledge/{lib,graph}.ts. Owns
5
+ * formatting + argv parsing only; all entity logic lives in the tools.
6
+ *
7
+ * Subcommands:
8
+ * search <query> Substring search across title/tags/body
9
+ * graph <slug> [--hops N] BFS traversal from a slug (default 2 hops)
10
+ * stats Counts, hubs, isolated nodes
11
+ * hubs Top 10 most-connected entities
12
+ * find <tag> Entities tagged with <tag>
13
+ * show <slug> Print one entity (frontmatter + body)
14
+ * add <domain> <name> Create entity (interactive unless flags given)
15
+ * --tags ai,research Comma-separated tags
16
+ * --related slug:type Repeatable; type ∈ RELATION_TYPES
17
+ * --quality 0-10
18
+ * --status seedling|budding|evergreen
19
+ * --type <subtype> Free-form sub-type (default by domain)
20
+ * ls [domain] List entities, optionally by one domain
21
+ */
22
+
23
+ import { readFileSync } from "node:fs";
24
+ import { parseArgs } from "node:util";
25
+ import * as clack from "@clack/prompts";
26
+ import { buildGraph, resolveSlug, stats, traverse } from "../tools/knowledge/graph";
27
+ import {
28
+ type CompanyInput,
29
+ ingestEntities,
30
+ type PersonInput,
31
+ } from "../tools/knowledge/ingest";
32
+ import {
33
+ DOMAINS,
34
+ type Domain,
35
+ type Entity,
36
+ getOrCreate,
37
+ list,
38
+ load,
39
+ RELATION_TYPES,
40
+ type Related,
41
+ type RelationType,
42
+ STATUSES,
43
+ type Status,
44
+ } from "../tools/knowledge/lib";
45
+
46
+ // ── Dispatcher ─────────────────────────────────────────────────────
47
+
48
+ export async function runKnowledge(args: string[]): Promise<number> {
49
+ const [sub, ...rest] = args;
50
+ switch (sub) {
51
+ case "search":
52
+ return cmdSearch(rest);
53
+ case "graph":
54
+ return cmdGraph(rest);
55
+ case "stats":
56
+ return cmdStats();
57
+ case "hubs":
58
+ return cmdHubs();
59
+ case "find":
60
+ return cmdFind(rest);
61
+ case "show":
62
+ return cmdShow(rest);
63
+ case "add":
64
+ return cmdAdd(rest);
65
+ case "ls":
66
+ return cmdLs(rest);
67
+ case "ingest":
68
+ return cmdIngest(rest);
69
+ case undefined:
70
+ case "help":
71
+ case "--help":
72
+ case "-h":
73
+ showHelp();
74
+ return 0;
75
+ default:
76
+ console.error(`Unknown subcommand: ${sub}\n`);
77
+ showHelp();
78
+ return 1;
79
+ }
80
+ }
81
+
82
+ // ── Helpers ────────────────────────────────────────────────────────
83
+
84
+ function showHelp(): void {
85
+ console.log(`
86
+ Usage:
87
+ pal cli knowledge <subcommand> [args]
88
+
89
+ Subcommands:
90
+ search <query> Substring search across title, tags, body
91
+ graph <slug> [--hops N] BFS traversal from a slug (default 2 hops)
92
+ stats Counts, hubs, isolated nodes
93
+ hubs Top 10 most-connected entities
94
+ find <tag> Entities tagged with <tag>
95
+ show <slug> Print one entity (frontmatter + body)
96
+ add <domain> <name> Create entity (interactive unless flags given)
97
+ --tags ai,research Comma-separated tags
98
+ --related slug:type Typed relation (repeatable)
99
+ --quality 0-10 Default 5
100
+ --status seedling|budding|evergreen Default seedling
101
+ --type <subtype> Free-form sub-type
102
+ ls [domain] List entities (optionally by one domain)
103
+ ingest [--file F] Upsert JSON from stdin (or --file)
104
+ --source <id> Provenance tag (default "manual")
105
+
106
+ Domains: People, Companies, Ideas, Research
107
+ Relation types: ${RELATION_TYPES.join(", ")}
108
+ `);
109
+ }
110
+
111
+ function isDomain(s: string): s is Domain {
112
+ return (DOMAINS as readonly string[]).includes(s);
113
+ }
114
+
115
+ function isStatus(s: string): s is Status {
116
+ return (STATUSES as readonly string[]).includes(s);
117
+ }
118
+
119
+ function isRelationType(s: string): s is RelationType {
120
+ return (RELATION_TYPES as readonly string[]).includes(s);
121
+ }
122
+
123
+ function shortLine(entity: Entity, extra?: string): string {
124
+ const tags = entity.frontmatter.tags.length
125
+ ? ` [${entity.frontmatter.tags.join(", ")}]`
126
+ : "";
127
+ const tail = extra ? ` ${extra}` : "";
128
+ return ` ${entity.domain}/${entity.slug} — ${entity.frontmatter.title}${tags}${tail}`;
129
+ }
130
+
131
+ // ── search ─────────────────────────────────────────────────────────
132
+
133
+ interface SearchHit {
134
+ entity: Entity;
135
+ score: number;
136
+ }
137
+
138
+ /**
139
+ * Strip PAL-emitted source markup lines from a body before search scoring.
140
+ * Keeps the markers on disk (for provenance + idempotency) but excludes
141
+ * them from the search corpus — otherwise source IDs leak into results
142
+ * and every entity from a batch matches substrings of the source ID.
143
+ * ISC-22.
144
+ */
145
+ const PAL_HEADING_RE = /^### \d{4}-\d{2}-\d{2} — /;
146
+ function bodyForSearch(body: string): string {
147
+ return body
148
+ .split("\n")
149
+ .filter((line) => !PAL_HEADING_RE.test(line) && !line.startsWith("<!-- src:"))
150
+ .join("\n");
151
+ }
152
+
153
+ function scoreEntity(entity: Entity, q: string): number {
154
+ const lower = q.toLowerCase();
155
+ let score = 0;
156
+ if (entity.slug.includes(lower)) score += 5;
157
+ if (entity.frontmatter.title.toLowerCase().includes(lower)) score += 4;
158
+ for (const tag of entity.frontmatter.tags) {
159
+ if (tag.toLowerCase().includes(lower)) score += 2;
160
+ }
161
+ // Count body occurrences (cap at 10 to avoid runaway weighting on huge bodies)
162
+ const body = bodyForSearch(entity.body).toLowerCase();
163
+ let pos = body.indexOf(lower);
164
+ let bodyHits = 0;
165
+ while (pos !== -1 && bodyHits < 10) {
166
+ bodyHits++;
167
+ pos = body.indexOf(lower, pos + lower.length);
168
+ }
169
+ score += bodyHits;
170
+ return score;
171
+ }
172
+
173
+ function cmdSearch(args: string[]): number {
174
+ const q = args[0];
175
+ if (!q) {
176
+ console.error("Usage: pal cli knowledge search <query>");
177
+ return 1;
178
+ }
179
+ const hits: SearchHit[] = [];
180
+ for (const e of list()) {
181
+ const s = scoreEntity(e, q);
182
+ if (s > 0) hits.push({ entity: e, score: s });
183
+ }
184
+ hits.sort((a, b) => b.score - a.score || a.entity.slug.localeCompare(b.entity.slug));
185
+ if (hits.length === 0) {
186
+ console.log(`No matches for "${q}".`);
187
+ return 0;
188
+ }
189
+ console.log(`\n🔎 ${hits.length} match${hits.length === 1 ? "" : "es"} for "${q}":\n`);
190
+ for (const h of hits) console.log(shortLine(h.entity, `(score: ${h.score})`));
191
+ console.log();
192
+ return 0;
193
+ }
194
+
195
+ // ── graph ──────────────────────────────────────────────────────────
196
+
197
+ function cmdGraph(args: string[]): number {
198
+ const { values, positionals } = parseArgs({
199
+ args,
200
+ options: { hops: { type: "string" } },
201
+ allowPositionals: true,
202
+ strict: false,
203
+ });
204
+ const query = positionals[0];
205
+ if (!query) {
206
+ console.error("Usage: pal cli knowledge graph <slug> [--hops N]");
207
+ return 1;
208
+ }
209
+ const hops = values.hops ? Number(values.hops) : 2;
210
+ if (!Number.isInteger(hops) || hops < 1) {
211
+ console.error("--hops must be a positive integer");
212
+ return 1;
213
+ }
214
+ const g = buildGraph();
215
+ const slug = resolveSlug(g, query);
216
+ if (!slug) {
217
+ console.error(`No entity matching "${query}".`);
218
+ return 1;
219
+ }
220
+ const start = g.nodes.get(slug);
221
+ if (!start) {
222
+ console.error(`Slug "${slug}" resolved but not in graph.`);
223
+ return 1;
224
+ }
225
+ const trail = traverse(g, slug, hops);
226
+ console.log(`\n🗺 ${start.domain}/${slug} — "${start.title}"`);
227
+ console.log(` ${hops} hop${hops === 1 ? "" : "s"} · ${trail.length - 1} reachable\n`);
228
+ const byHop = new Map<number, typeof trail>();
229
+ for (const t of trail) {
230
+ if (t.hop === 0) continue;
231
+ const bucket = byHop.get(t.hop);
232
+ if (bucket) bucket.push(t);
233
+ else byHop.set(t.hop, [t]);
234
+ }
235
+ for (const [hop, items] of [...byHop.entries()].sort((a, b) => a[0] - b[0])) {
236
+ console.log(` hop ${hop}:`);
237
+ for (const t of items) {
238
+ const edge = t.viaEdge;
239
+ const labelSuffix = edge?.label ? `:${edge.label}` : "";
240
+ const via = edge ? `via ${edge.edgeType}${labelSuffix} (w=${edge.weight})` : "";
241
+ console.log(` → ${t.node.domain}/${t.node.slug} — "${t.node.title}" ${via}`);
242
+ }
243
+ }
244
+ console.log();
245
+ return 0;
246
+ }
247
+
248
+ // ── stats ──────────────────────────────────────────────────────────
249
+
250
+ function cmdStats(): number {
251
+ const g = buildGraph();
252
+ const s = stats(g);
253
+ console.log(`\n📊 Knowledge stats\n`);
254
+ console.log(` Nodes: ${s.nodes}`);
255
+ for (const d of DOMAINS) {
256
+ console.log(` ${d.padEnd(10)} ${s.nodesByDomain[d]}`);
257
+ }
258
+ console.log(` Edges: ${s.edges}`);
259
+ console.log(` related: ${s.edgesByType.related}`);
260
+ console.log(` wikilink: ${s.edgesByType.wikilink}`);
261
+ console.log(` tag: ${s.edgesByType.tag}`);
262
+ console.log(` Avg connections per node: ${s.avgConnections}`);
263
+ console.log(` Isolated nodes: ${s.isolatedNodes}`);
264
+ if (s.mostConnected) {
265
+ console.log(` Most connected: ${s.mostConnected.slug} (${s.mostConnected.count})`);
266
+ }
267
+ console.log();
268
+ return 0;
269
+ }
270
+
271
+ // ── hubs ───────────────────────────────────────────────────────────
272
+
273
+ function cmdHubs(): number {
274
+ const g = buildGraph();
275
+ const counts = new Map<string, Set<string>>();
276
+ for (const edge of g.edges) {
277
+ const fromSet = counts.get(edge.from) ?? new Set<string>();
278
+ fromSet.add(edge.to);
279
+ counts.set(edge.from, fromSet);
280
+ const toSet = counts.get(edge.to) ?? new Set<string>();
281
+ toSet.add(edge.from);
282
+ counts.set(edge.to, toSet);
283
+ }
284
+ const ranked = [...counts.entries()]
285
+ .map(([slug, set]) => ({ slug, count: set.size }))
286
+ .sort((a, b) => b.count - a.count || a.slug.localeCompare(b.slug))
287
+ .slice(0, 10);
288
+ console.log(`\n🔗 Top hubs\n`);
289
+ if (ranked.length === 0) {
290
+ console.log(" (no connected nodes)\n");
291
+ return 0;
292
+ }
293
+ for (const [i, r] of ranked.entries()) {
294
+ const node = g.nodes.get(r.slug);
295
+ const label = node ? `${node.domain}/${r.slug} — "${node.title}"` : r.slug;
296
+ console.log(` ${String(i + 1).padStart(2)}. ${label} (${r.count} connections)`);
297
+ }
298
+ console.log();
299
+ return 0;
300
+ }
301
+
302
+ // ── find ───────────────────────────────────────────────────────────
303
+
304
+ function cmdFind(args: string[]): number {
305
+ const tag = args[0]?.toLowerCase();
306
+ if (!tag) {
307
+ console.error("Usage: pal cli knowledge find <tag>");
308
+ return 1;
309
+ }
310
+ // Accept both the bare tag and the topic-prefixed form so users don't
311
+ // need to know which kind a given concept was stored as.
312
+ const prefixedTag = tag.startsWith("topic:") ? tag : `topic:${tag}`;
313
+ const matches = list().filter((e) =>
314
+ e.frontmatter.tags.some((t) => {
315
+ const lower = t.toLowerCase();
316
+ return lower === tag || lower === prefixedTag;
317
+ })
318
+ );
319
+ console.log(
320
+ `\n🏷 ${matches.length} entit${matches.length === 1 ? "y" : "ies"} tagged "${tag}":\n`
321
+ );
322
+ for (const e of matches.sort(
323
+ (a, b) => a.domain.localeCompare(b.domain) || a.slug.localeCompare(b.slug)
324
+ )) {
325
+ console.log(shortLine(e));
326
+ }
327
+ console.log();
328
+ return 0;
329
+ }
330
+
331
+ // ── show ───────────────────────────────────────────────────────────
332
+
333
+ function cmdShow(args: string[]): number {
334
+ const query = args[0];
335
+ if (!query) {
336
+ console.error("Usage: pal cli knowledge show <slug>");
337
+ return 1;
338
+ }
339
+ const g = buildGraph();
340
+ const slug = resolveSlug(g, query);
341
+ if (!slug) {
342
+ console.error(`No entity matching "${query}".`);
343
+ return 1;
344
+ }
345
+ const node = g.nodes.get(slug);
346
+ if (!node) {
347
+ console.error(`Slug "${slug}" resolved but not in graph.`);
348
+ return 1;
349
+ }
350
+ const entity = load(node.domain, slug);
351
+ if (!entity) {
352
+ console.error(`File missing for ${node.domain}/${slug}.`);
353
+ return 1;
354
+ }
355
+ console.log(`\n${entity.domain}/${entity.slug}`);
356
+ console.log("─".repeat(50));
357
+ for (const [k, v] of Object.entries(entity.frontmatter)) {
358
+ if (k === "related") continue;
359
+ console.log(` ${k}: ${JSON.stringify(v)}`);
360
+ }
361
+ if (entity.frontmatter.related.length > 0) {
362
+ console.log(` related:`);
363
+ for (const r of entity.frontmatter.related) {
364
+ const target = g.nodes.get(r.slug);
365
+ const tail = target ? ` — "${target.title}"` : "";
366
+ console.log(` - ${r.type} → ${r.slug}${tail}`);
367
+ }
368
+ }
369
+ if (entity.body.trim()) {
370
+ console.log(`\n${entity.body.trim()}`);
371
+ }
372
+ console.log();
373
+ return 0;
374
+ }
375
+
376
+ // ── add ────────────────────────────────────────────────────────────
377
+
378
+ interface AddFlags {
379
+ tags: string[];
380
+ related: Related[];
381
+ quality?: number;
382
+ status?: Status;
383
+ type?: string;
384
+ body?: string;
385
+ }
386
+
387
+ function parseRelatedFlag(value: string): Related {
388
+ const [slug, type] = value.split(":");
389
+ if (!slug || !type) {
390
+ throw new Error(`--related must be slug:type, got "${value}"`);
391
+ }
392
+ if (!isRelationType(type)) {
393
+ throw new Error(
394
+ `--related type must be one of: ${RELATION_TYPES.join(", ")} (got "${type}")`
395
+ );
396
+ }
397
+ return { slug, type };
398
+ }
399
+
400
+ function parseAddFlags(args: string[]): AddFlags {
401
+ const { values } = parseArgs({
402
+ args,
403
+ options: {
404
+ tags: { type: "string" },
405
+ related: { type: "string", multiple: true },
406
+ quality: { type: "string" },
407
+ status: { type: "string" },
408
+ type: { type: "string" },
409
+ body: { type: "string" },
410
+ },
411
+ allowPositionals: true,
412
+ strict: false,
413
+ });
414
+ const tags = values.tags
415
+ ? String(values.tags)
416
+ .split(",")
417
+ .map((t) => t.trim())
418
+ .filter(Boolean)
419
+ : [];
420
+ const related = ((values.related as string[] | undefined) ?? []).map(parseRelatedFlag);
421
+ const quality = values.quality != null ? Number(values.quality) : undefined;
422
+ if (quality != null && (!Number.isInteger(quality) || quality < 0 || quality > 10)) {
423
+ throw new Error("--quality must be an integer 0-10");
424
+ }
425
+ const status = values.status as string | undefined;
426
+ if (status && !isStatus(status)) {
427
+ throw new Error(`--status must be one of: ${STATUSES.join(", ")}`);
428
+ }
429
+ return {
430
+ tags,
431
+ related,
432
+ quality,
433
+ status: status as Status | undefined,
434
+ type: values.type as string | undefined,
435
+ body: values.body as string | undefined,
436
+ };
437
+ }
438
+
439
+ async function cmdAdd(args: string[]): Promise<number> {
440
+ // First two positionals: domain, name.
441
+ const positional = args.filter((a) => !a.startsWith("--"));
442
+ const domainStr = positional[0];
443
+ const name = positional[1];
444
+ if (!domainStr || !isDomain(domainStr)) {
445
+ console.error(`Usage: pal cli knowledge add <${DOMAINS.join("|")}> <name> [flags]`);
446
+ return 1;
447
+ }
448
+ if (!name) {
449
+ console.error("Missing <name>");
450
+ return 1;
451
+ }
452
+
453
+ const flagArgs = args.slice(args.indexOf(name) + 1);
454
+ const hasAnyFlag = flagArgs.some((a) => a.startsWith("--"));
455
+ let flags: AddFlags;
456
+ try {
457
+ flags = parseAddFlags(flagArgs);
458
+ } catch (e) {
459
+ console.error(e instanceof Error ? e.message : String(e));
460
+ return 1;
461
+ }
462
+
463
+ // Interactive mode only when no flags and we have a TTY.
464
+ if (!hasAnyFlag && process.stdin.isTTY) {
465
+ const enriched = await runInteractiveAdd(flags);
466
+ if (enriched === null) return 1;
467
+ flags = enriched;
468
+ }
469
+
470
+ const entity = getOrCreate({
471
+ domain: domainStr,
472
+ name,
473
+ tags: flags.tags,
474
+ related: flags.related,
475
+ quality: flags.quality,
476
+ status: flags.status,
477
+ type: flags.type,
478
+ body: flags.body,
479
+ });
480
+ console.log(`✓ ${entity.domain}/${entity.slug} — "${entity.frontmatter.title}"`);
481
+ return 0;
482
+ }
483
+
484
+ async function runInteractiveAdd(prefilled: AddFlags): Promise<AddFlags | null> {
485
+ clack.intro("Add knowledge entry");
486
+ const tagsInput = await clack.text({
487
+ message: "Tags (comma-separated, optional):",
488
+ placeholder: "ai, research",
489
+ });
490
+ if (clack.isCancel(tagsInput)) {
491
+ clack.cancel("Cancelled");
492
+ return null;
493
+ }
494
+ const tags = String(tagsInput || "")
495
+ .split(",")
496
+ .map((t) => t.trim())
497
+ .filter(Boolean);
498
+
499
+ const statusSel = await clack.select({
500
+ message: "Status:",
501
+ options: STATUSES.map((s) => ({ value: s, label: s })),
502
+ initialValue: prefilled.status ?? "seedling",
503
+ });
504
+ if (clack.isCancel(statusSel)) {
505
+ clack.cancel("Cancelled");
506
+ return null;
507
+ }
508
+
509
+ const qualityInput = await clack.text({
510
+ message: "Quality (0-10):",
511
+ placeholder: "5",
512
+ initialValue: String(prefilled.quality ?? 5),
513
+ });
514
+ if (clack.isCancel(qualityInput)) {
515
+ clack.cancel("Cancelled");
516
+ return null;
517
+ }
518
+
519
+ clack.outro("Saved");
520
+ return {
521
+ ...prefilled,
522
+ tags: prefilled.tags.length ? prefilled.tags : tags,
523
+ status: statusSel as Status,
524
+ quality: Number(qualityInput),
525
+ };
526
+ }
527
+
528
+ // ── ls ─────────────────────────────────────────────────────────────
529
+
530
+ function cmdLs(args: string[]): number {
531
+ const domainArg = args[0];
532
+ if (domainArg && !isDomain(domainArg)) {
533
+ console.error(`Domain must be one of: ${DOMAINS.join(", ")}`);
534
+ return 1;
535
+ }
536
+ const target = domainArg ? (domainArg as Domain) : undefined;
537
+ const entries = list(target).sort(
538
+ (a, b) => a.domain.localeCompare(b.domain) || a.slug.localeCompare(b.slug)
539
+ );
540
+ const noun = entries.length === 1 ? "entity" : "entities";
541
+ const scope = target ? ` in ${target}` : "";
542
+ console.log(`\n📁 ${entries.length} ${noun}${scope}\n`);
543
+ for (const e of entries) console.log(shortLine(e));
544
+ console.log();
545
+ return 0;
546
+ }
547
+
548
+ // ── ingest ─────────────────────────────────────────────────────────
549
+
550
+ interface IngestPayload {
551
+ people?: PersonInput[];
552
+ companies?: CompanyInput[];
553
+ }
554
+
555
+ async function readIngestInput(file: string | undefined): Promise<string | null> {
556
+ if (file) return readFileSync(file, "utf-8");
557
+ if (process.stdin.isTTY) return null;
558
+ return await Bun.stdin.text();
559
+ }
560
+
561
+ async function cmdIngest(args: string[]): Promise<number> {
562
+ const { values } = parseArgs({
563
+ args,
564
+ options: {
565
+ source: { type: "string", short: "s", default: "manual" },
566
+ file: { type: "string", short: "f" },
567
+ },
568
+ strict: true,
569
+ });
570
+
571
+ const sourceId = values.source ?? "manual";
572
+ const raw = await readIngestInput(values.file);
573
+
574
+ if (raw === null || !raw.trim()) {
575
+ console.error(
576
+ "Usage: echo '<JSON>' | pal cli knowledge ingest --source <id>\n" +
577
+ " or: pal cli knowledge ingest --file <path> --source <id>"
578
+ );
579
+ return 1;
580
+ }
581
+
582
+ let data: IngestPayload;
583
+ try {
584
+ data = JSON.parse(raw) as IngestPayload;
585
+ } catch {
586
+ console.error("Error: invalid JSON input.");
587
+ return 1;
588
+ }
589
+
590
+ if (!Array.isArray(data.people) && !Array.isArray(data.companies)) {
591
+ console.error(
592
+ 'Error: JSON must include at least one of "people" or "companies" arrays.'
593
+ );
594
+ return 1;
595
+ }
596
+
597
+ const result = ingestEntities(
598
+ { people: data.people ?? [], companies: data.companies ?? [] },
599
+ sourceId
600
+ );
601
+
602
+ const summary = {
603
+ source: sourceId,
604
+ people: {
605
+ total: result.people.length,
606
+ created: result.people.filter((p) => p.created).length,
607
+ updated: result.people.filter((p) => !p.created).length,
608
+ slugs: result.people.map((p) => p.slug),
609
+ },
610
+ companies: {
611
+ total: result.companies.length,
612
+ created: result.companies.filter((c) => c.created).length,
613
+ updated: result.companies.filter((c) => !c.created).length,
614
+ slugs: result.companies.map((c) => c.slug),
615
+ },
616
+ };
617
+
618
+ console.log(JSON.stringify(summary, null, 2));
619
+ return 0;
620
+ }