portable-agent-layer 0.1.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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/assets/agents/claude-researcher.md +43 -0
  4. package/assets/agents/investigative-researcher.md +44 -0
  5. package/assets/agents/multi-perspective-researcher.md +43 -0
  6. package/assets/skills/analyze-pdf.md +40 -0
  7. package/assets/skills/analyze-youtube.md +35 -0
  8. package/assets/skills/council.md +43 -0
  9. package/assets/skills/create-skill.md +31 -0
  10. package/assets/skills/extract-entities.md +63 -0
  11. package/assets/skills/extract-wisdom.md +18 -0
  12. package/assets/skills/first-principles.md +17 -0
  13. package/assets/skills/fyzz-chat-api.md +43 -0
  14. package/assets/skills/reflect.md +87 -0
  15. package/assets/skills/research.md +68 -0
  16. package/assets/skills/review.md +19 -0
  17. package/assets/skills/summarize.md +15 -0
  18. package/assets/templates/AGENTS.md.template +45 -0
  19. package/assets/templates/telos/BELIEFS.md +4 -0
  20. package/assets/templates/telos/CHALLENGES.md +4 -0
  21. package/assets/templates/telos/GOALS.md +12 -0
  22. package/assets/templates/telos/IDEAS.md +4 -0
  23. package/assets/templates/telos/IDENTITY.md +4 -0
  24. package/assets/templates/telos/LEARNED.md +4 -0
  25. package/assets/templates/telos/MISSION.md +4 -0
  26. package/assets/templates/telos/MODELS.md +4 -0
  27. package/assets/templates/telos/NARRATIVES.md +4 -0
  28. package/assets/templates/telos/PROJECTS.md +7 -0
  29. package/assets/templates/telos/STRATEGIES.md +4 -0
  30. package/bin/pal +24 -0
  31. package/bin/pal.bat +8 -0
  32. package/bin/pal.ps1 +30 -0
  33. package/package.json +82 -0
  34. package/src/cli/index.ts +344 -0
  35. package/src/cli/install.ts +86 -0
  36. package/src/cli/uninstall.ts +45 -0
  37. package/src/hooks/LoadContext.ts +41 -0
  38. package/src/hooks/SecurityValidator.ts +52 -0
  39. package/src/hooks/SkillGuard.ts +41 -0
  40. package/src/hooks/StopOrchestrator.ts +35 -0
  41. package/src/hooks/UserPromptOrchestrator.ts +35 -0
  42. package/src/hooks/handlers/backup.ts +41 -0
  43. package/src/hooks/handlers/failure.ts +136 -0
  44. package/src/hooks/handlers/rating.ts +409 -0
  45. package/src/hooks/handlers/relationship.ts +113 -0
  46. package/src/hooks/handlers/session-name.ts +121 -0
  47. package/src/hooks/handlers/synthesis.ts +109 -0
  48. package/src/hooks/handlers/tab.ts +8 -0
  49. package/src/hooks/handlers/update-counts.ts +151 -0
  50. package/src/hooks/handlers/work-learning.ts +183 -0
  51. package/src/hooks/handlers/work-session.ts +58 -0
  52. package/src/hooks/lib/claude-md.ts +121 -0
  53. package/src/hooks/lib/context.ts +433 -0
  54. package/src/hooks/lib/entities.ts +304 -0
  55. package/src/hooks/lib/export.ts +76 -0
  56. package/src/hooks/lib/inference.ts +91 -0
  57. package/src/hooks/lib/learning-category.ts +14 -0
  58. package/src/hooks/lib/log.ts +53 -0
  59. package/src/hooks/lib/models.ts +16 -0
  60. package/src/hooks/lib/paths.ts +80 -0
  61. package/src/hooks/lib/relationship.ts +135 -0
  62. package/src/hooks/lib/security.ts +122 -0
  63. package/src/hooks/lib/session-names.ts +247 -0
  64. package/src/hooks/lib/setup.ts +189 -0
  65. package/src/hooks/lib/signal-trends.ts +117 -0
  66. package/src/hooks/lib/signals.ts +37 -0
  67. package/src/hooks/lib/stdin.ts +18 -0
  68. package/src/hooks/lib/stop.ts +155 -0
  69. package/src/hooks/lib/time.ts +19 -0
  70. package/src/hooks/lib/token-usage.ts +42 -0
  71. package/src/hooks/lib/transcript.ts +76 -0
  72. package/src/hooks/lib/wisdom.ts +48 -0
  73. package/src/hooks/lib/work-tracking.ts +193 -0
  74. package/src/hooks/setup-check.ts +42 -0
  75. package/src/targets/claude/install.ts +145 -0
  76. package/src/targets/claude/uninstall.ts +101 -0
  77. package/src/targets/lib.ts +337 -0
  78. package/src/targets/opencode/install.ts +59 -0
  79. package/src/targets/opencode/plugin.ts +328 -0
  80. package/src/targets/opencode/uninstall.ts +57 -0
  81. package/src/tools/entity-save.ts +110 -0
  82. package/src/tools/export.ts +34 -0
  83. package/src/tools/fyzz-api.ts +104 -0
  84. package/src/tools/import.ts +123 -0
  85. package/src/tools/pattern-synthesis.ts +435 -0
  86. package/src/tools/pdf-download.ts +102 -0
  87. package/src/tools/relationship-reflect.ts +362 -0
  88. package/src/tools/session-summary.ts +206 -0
  89. package/src/tools/token-cost.ts +301 -0
  90. package/src/tools/youtube-analyze.ts +105 -0
@@ -0,0 +1,328 @@
1
+ /**
2
+ * PAL plugin for opencode — thin adapter over shared hooks/lib/ modules.
3
+ *
4
+ * All business logic lives in hooks/lib/ so it stays in sync with Claude Code hooks.
5
+ * This plugin just wires opencode's hook API to those shared functions.
6
+ */
7
+
8
+ import { writeFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import type { Plugin, PluginInput } from "@opencode-ai/plugin";
11
+
12
+ const PAL_DIR = process.env.PAL_DIR || resolve(import.meta.dir, "../../..");
13
+
14
+ // Dynamic imports from shared lib — resolved at runtime via PAL_DIR
15
+ async function lib<T>(mod: string): Promise<T> {
16
+ return await import(resolve(PAL_DIR, "src", "hooks", "lib", mod));
17
+ }
18
+
19
+ type TranscriptMessage = { role: string; content: string };
20
+
21
+ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
22
+ // Pre-load shared modules
23
+ const { buildGreeting, buildSystemReminder } =
24
+ await lib<typeof import("../../hooks/lib/context")>("context.ts");
25
+ const { checkBashCommand, checkFilePath } =
26
+ await lib<typeof import("../../hooks/lib/security")>("security.ts");
27
+ const { paths, ensureDir } =
28
+ await lib<typeof import("../../hooks/lib/paths")>("paths.ts");
29
+ const { emitRating } =
30
+ await lib<typeof import("../../hooks/lib/signals")>("signals.ts");
31
+ const { now } = await lib<typeof import("../../hooks/lib/time")>("time.ts");
32
+ const { monthPath, fileTimestamp } =
33
+ await lib<typeof import("../../hooks/lib/time")>("time.ts");
34
+ const { logDebug, logError } =
35
+ await lib<typeof import("../../hooks/lib/log")>("log.ts");
36
+
37
+ // Load shared stop-orchestrator handler
38
+ const { runStopHandlers } = await lib<typeof import("../../hooks/lib/stop")>("stop.ts");
39
+ const { captureSessionName } = await lib<
40
+ typeof import("../../hooks/handlers/session-name")
41
+ >("../handlers/session-name.ts");
42
+
43
+ function partsToText(parts: Array<Record<string, unknown>>): string {
44
+ return parts
45
+ .filter((p) => p?.type === "text" && !p.ignored && !p.synthetic)
46
+ .map((p) => (typeof p.text === "string" ? p.text : ""))
47
+ .join(" ")
48
+ .trim();
49
+ }
50
+
51
+ async function buildSessionTranscript(sessionID: string): Promise<TranscriptMessage[]> {
52
+ const result = await client.session.messages({
53
+ path: { id: sessionID },
54
+ query: { directory },
55
+ });
56
+
57
+ if (result.error || !result.data) {
58
+ logDebug(
59
+ "opencode:session.messages",
60
+ `Failed to fetch messages (error=${Boolean(result.error)})`
61
+ );
62
+ return [];
63
+ }
64
+
65
+ const rows = result.data as Array<{
66
+ info: { role?: string };
67
+ parts: Array<Record<string, unknown>>;
68
+ }>;
69
+
70
+ return rows
71
+ .map((row) => {
72
+ const role = row?.info?.role ?? "unknown";
73
+ const content = partsToText(row?.parts ?? []);
74
+ return { role, content };
75
+ })
76
+ .filter((m) => m.content.length > 0);
77
+ }
78
+
79
+ const { categorizeLearning } =
80
+ await lib<typeof import("../../hooks/lib/learning-category")>("learning-category.ts");
81
+
82
+ // Local helpers for rating (thin wrappers around shared signals)
83
+ function handleRating(
84
+ rating: number,
85
+ context: string,
86
+ source: string,
87
+ detailedContext?: string,
88
+ userMessage?: string
89
+ ): void {
90
+ emitRating(rating, context, source);
91
+
92
+ if (rating < 5) {
93
+ const category = categorizeLearning(context, detailedContext ?? "");
94
+ const dir = ensureDir(resolve(paths.sessionLearning(), monthPath()));
95
+ const filename = `${fileTimestamp()}_${source}-rating-${rating}_${category}.md`;
96
+ writeFileSync(
97
+ resolve(dir, filename),
98
+ [
99
+ `# ${source === "explicit" ? "Low Rating" : "Implicit Low Rating"}: ${rating}/10`,
100
+ `**Title:** ${context.slice(0, 100) || "(low rating)"}`,
101
+ `**Date:** ${new Date().toISOString().slice(0, 10)}`,
102
+ `**Rating:** ${rating}/10`,
103
+ `**Source:** ${source}`,
104
+ `**Category:** ${category.toUpperCase()}`,
105
+ "",
106
+ "## Context",
107
+ context || "*(unavailable)*",
108
+ "",
109
+ ...(detailedContext ? ["## Analysis", detailedContext, ""] : []),
110
+ ].join("\n")
111
+ );
112
+ }
113
+
114
+ if (rating <= 3) {
115
+ const userPreview = userMessage?.slice(0, 400);
116
+ writeFileSync(
117
+ resolve(paths.state(), "pending-failure.json"),
118
+ JSON.stringify(
119
+ { rating, context, source, detailedContext, userPreview, ts: now() },
120
+ null,
121
+ 2
122
+ ),
123
+ "utf-8"
124
+ );
125
+ }
126
+ }
127
+
128
+ const PRAISE_PATTERNS =
129
+ /^(great\s*job|nice|perfect|awesome|excellent|thanks|thank\s*you|well\s*done|good\s*job|love\s*it|amazing|brilliant|fantastic|wonderful|superb|nailed\s*it)[.!?]?$/i;
130
+
131
+ return {
132
+ // --- Per-message: Inject dynamic system reminder ---
133
+ "experimental.chat.system.transform": async (_input, output) => {
134
+ const reminder = buildSystemReminder();
135
+ if (reminder) output.system.push(reminder);
136
+ },
137
+
138
+ // --- Session events: start and stop handling ---
139
+ event: async ({ event }) => {
140
+ logDebug("opencode:event", `Event: ${event.type}`);
141
+
142
+ if (event.type === "session.created" || event.type === "session.updated") {
143
+ const { regenerateIfNeeded } =
144
+ await lib<typeof import("../../hooks/lib/claude-md")>("claude-md.ts");
145
+ regenerateIfNeeded();
146
+ console.log(buildGreeting().join("\n"));
147
+ }
148
+
149
+ if (event.type === "session.idle" || event.type === "session.diff") {
150
+ logDebug("opencode:event", "Running stop handlers...");
151
+ try {
152
+ const sessionID = (event as { properties?: { sessionID?: string } })?.properties
153
+ ?.sessionID;
154
+ if (!sessionID) {
155
+ logDebug("opencode:event", "Skipping stop handlers: missing sessionID");
156
+ return;
157
+ }
158
+
159
+ const messages = await buildSessionTranscript(sessionID);
160
+ logDebug("opencode:event", `Got ${messages.length} transcript messages`);
161
+ if (messages.length < 2) return;
162
+
163
+ // Name session from first user message (if not already named)
164
+ const firstUser = messages.find((m: TranscriptMessage) => m.role === "user");
165
+ if (firstUser) {
166
+ await captureSessionName(firstUser.content, sessionID);
167
+ }
168
+
169
+ await runStopHandlers(JSON.stringify(messages), { sessionId: sessionID });
170
+ logDebug("opencode:event", "Stop handlers complete");
171
+ } catch (err) {
172
+ logError("opencode:session.stop", err);
173
+ }
174
+ }
175
+ },
176
+
177
+ // --- Capture ratings from user messages ---
178
+ "chat.message": async (_input, output) => {
179
+ const text =
180
+ output.parts
181
+ ?.filter((p) => p.type === "text")
182
+ .map((p) => p.text || "")
183
+ .join(" ") ?? "";
184
+
185
+ // Explicit rating
186
+ const match = text.match(
187
+ /(?:^|rating:?\s*|score:?\s*)(\d|10)(?:\s*(?:\/10|[-.])|$|\s)/i
188
+ );
189
+ if (match) {
190
+ const rating = parseInt(match[1], 10);
191
+ if (rating >= 1 && rating <= 10) {
192
+ handleRating(rating, text.slice(0, 200), "explicit", undefined, text);
193
+ return;
194
+ }
195
+ }
196
+
197
+ // Implicit sentiment: auto-enabled when ANTHROPIC_API_KEY is set
198
+ if (process.env.ANTHROPIC_API_KEY) {
199
+ const trimmed = text.trim();
200
+ if (PRAISE_PATTERNS.test(trimmed)) {
201
+ handleRating(8, trimmed, "implicit", undefined, trimmed);
202
+ return;
203
+ }
204
+
205
+ // Full implicit via API — only for medium-length messages
206
+ if (
207
+ trimmed.length >= 5 &&
208
+ trimmed.length <= 500 &&
209
+ !/^[/$`{]/.test(trimmed) &&
210
+ !trimmed.includes("\n\n")
211
+ ) {
212
+ const apiKey = process.env.ANTHROPIC_API_KEY;
213
+ if (apiKey) {
214
+ try {
215
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
216
+ method: "POST",
217
+ headers: {
218
+ "x-api-key": apiKey,
219
+ "anthropic-version": "2023-06-01",
220
+ "content-type": "application/json",
221
+ },
222
+ body: JSON.stringify({
223
+ model: (await lib<{ HAIKU_MODEL: string }>("models")).HAIKU_MODEL,
224
+ max_tokens: 100,
225
+ messages: [
226
+ {
227
+ role: "user",
228
+ content: `Rate the sentiment of this user message toward an AI assistant on a 1-10 scale (1=very negative, 5=neutral, 10=very positive). If the message has no clear sentiment toward the assistant, respond with just "neutral". Otherwise respond with just a JSON object: {"rating": N, "sentiment": "one-word"}\n\nMessage: "${trimmed.slice(0, 300)}"`,
229
+ },
230
+ ],
231
+ }),
232
+ });
233
+
234
+ if (response.ok) {
235
+ const data = (await response.json()) as {
236
+ content?: Array<{ text?: string }>;
237
+ };
238
+ const rText = data?.content?.[0]?.text?.trim();
239
+ if (rText && rText !== "neutral") {
240
+ try {
241
+ const parsed = JSON.parse(rText) as {
242
+ rating?: number;
243
+ sentiment?: string;
244
+ };
245
+ if (
246
+ typeof parsed.rating === "number" &&
247
+ parsed.rating >= 1 &&
248
+ parsed.rating <= 10 &&
249
+ parsed.rating !== 5
250
+ ) {
251
+ handleRating(
252
+ parsed.rating,
253
+ `${parsed.sentiment || "inferred"}: ${trimmed.slice(0, 150)}`,
254
+ "implicit",
255
+ undefined,
256
+ trimmed
257
+ );
258
+ }
259
+ } catch {
260
+ // Ignore parse errors
261
+ }
262
+ }
263
+ }
264
+ } catch {
265
+ // Ignore API errors
266
+ }
267
+ }
268
+ }
269
+ }
270
+ },
271
+
272
+ // --- Security: block dangerous tool executions ---
273
+ "tool.execute.before": async (
274
+ _input: { tool: string; sessionID: string; callID: string },
275
+ output: { args: Record<string, unknown> | string }
276
+ ) => {
277
+ const toolName = _input.tool;
278
+
279
+ if (toolName === "shell" || toolName === "bash") {
280
+ const cmd =
281
+ typeof output.args === "string"
282
+ ? output.args
283
+ : ((output.args?.command as string) ?? "");
284
+ const reason = checkBashCommand(cmd);
285
+ if (reason) {
286
+ throw new Error(`PAL Security: Blocked — ${reason}`);
287
+ }
288
+ }
289
+
290
+ if (toolName === "write" || toolName === "edit" || toolName === "patch") {
291
+ const args = output.args as Record<string, string>;
292
+ const filePath = args?.file_path ?? args?.filePath ?? args?.path ?? "";
293
+ const fileReason = checkFilePath(filePath);
294
+ if (fileReason) {
295
+ throw new Error(`PAL Security: ${fileReason}`);
296
+ }
297
+ }
298
+ },
299
+
300
+ // --- Capture work state after tool use ---
301
+ "tool.execute.after": async (
302
+ input: { tool: string; sessionID: string; callID: string; args: unknown },
303
+ _output: { title: string; output: string; metadata: unknown }
304
+ ) => {
305
+ try {
306
+ writeFileSync(
307
+ resolve(ensureDir(paths.state()), "current-work.json"),
308
+ JSON.stringify({ ts: now(), tool: input.tool, cwd: directory }, null, 2)
309
+ );
310
+ } catch {
311
+ // Ignore write errors
312
+ }
313
+ },
314
+
315
+ // --- Inject PAL_DIR into shell environment ---
316
+ "shell.env": async (
317
+ _input: { cwd: string; sessionID?: string; callID?: string },
318
+ output: { env: Record<string, string> }
319
+ ) => {
320
+ output.env.PAL_DIR = PAL_DIR;
321
+ if (process.env.PAL_DEBUG) {
322
+ output.env.PAL_DEBUG = process.env.PAL_DEBUG;
323
+ }
324
+ },
325
+ };
326
+ };
327
+
328
+ export default PALPlugin;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * PAL — opencode uninstaller (TypeScript)
3
+ * Removes plugin and AGENTS.md.
4
+ */
5
+
6
+ import { unlinkSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { platform } from "../../hooks/lib/paths";
9
+ import { log, removeAgentsFromOpencode, removeSkills } from "../lib";
10
+
11
+ const OC_GLOBAL_DIR = platform.opencodeDir() || "";
12
+
13
+ const PAL_CLAUDE_DIR = platform.claudeDir() || "";
14
+
15
+ if (!OC_GLOBAL_DIR || !PAL_CLAUDE_DIR) {
16
+ log.error("PAL_OPENCODE_DIR or PAL_CLAUDE_DIR not set");
17
+ process.exit(1);
18
+ }
19
+
20
+ // --- Remove plugin ---
21
+ const pluginPath = resolve(OC_GLOBAL_DIR, "plugins", "pal-plugin.ts");
22
+ try {
23
+ unlinkSync(pluginPath);
24
+ log.success("Removed PAL plugin");
25
+ } catch {
26
+ log.info("No PAL plugin found");
27
+ }
28
+
29
+ // --- Remove skills ---
30
+ const removed = removeSkills(resolve(PAL_CLAUDE_DIR, "skills"));
31
+ if (removed.length > 0)
32
+ log.success(`Removed ${removed.length} skill(s): ${removed.join(", ")}`);
33
+
34
+ // --- Remove agents ---
35
+ const removedAgents = removeAgentsFromOpencode(resolve(OC_GLOBAL_DIR, "agents"));
36
+ if (removedAgents.length > 0)
37
+ log.success(
38
+ `Removed ${removedAgents.length} opencode agent(s): ${removedAgents.join(", ")}`
39
+ );
40
+
41
+ // --- Remove AGENTS.md and CLAUDE.md symlink ---
42
+ const agentsMd = resolve(OC_GLOBAL_DIR, "AGENTS.md");
43
+ const claudeMd = resolve(PAL_CLAUDE_DIR, "CLAUDE.md");
44
+ try {
45
+ unlinkSync(claudeMd);
46
+ log.success("Removed ~/.claude/CLAUDE.md");
47
+ } catch {
48
+ /* gone */
49
+ }
50
+ try {
51
+ unlinkSync(agentsMd);
52
+ log.success("Removed ~/.config/opencode/AGENTS.md");
53
+ } catch {
54
+ /* gone */
55
+ }
56
+
57
+ log.success("opencode uninstall complete");
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Entity Save — Deduplicate and persist extracted entities.
4
+ *
5
+ * Accepts extracted people/companies JSON via stdin or --file,
6
+ * deduplicates against the entity index, and saves.
7
+ *
8
+ * Usage:
9
+ * echo '{"people":[...],"companies":[...]}' | bun run ai:entity-save -- --source "https://example.com"
10
+ * bun run ai:entity-save -- --file /path/to/extracted.json --source "https://example.com"
11
+ */
12
+
13
+ import { readFileSync } from "node:fs";
14
+ import { parseArgs } from "node:util";
15
+ import { loadEntityIndex, processEntities } from "../hooks/lib/entities";
16
+
17
+ const { values } = parseArgs({
18
+ args: Bun.argv.slice(2),
19
+ options: {
20
+ source: { type: "string", short: "s", default: "manual" },
21
+ file: { type: "string", short: "f" },
22
+ },
23
+ strict: true,
24
+ });
25
+
26
+ const sourceId = values.source ?? "manual";
27
+
28
+ let raw: string;
29
+ if (values.file) {
30
+ raw = readFileSync(values.file, "utf-8");
31
+ } else {
32
+ raw = await Bun.stdin.text();
33
+ }
34
+
35
+ if (!raw.trim()) {
36
+ console.error("Error: No input provided. Pipe JSON via stdin or use --file.");
37
+ process.exit(1);
38
+ }
39
+
40
+ let data: {
41
+ people: Array<Record<string, unknown>>;
42
+ companies: Array<Record<string, unknown>>;
43
+ links?: Array<Record<string, unknown>>;
44
+ sources?: Array<Record<string, unknown>>;
45
+ };
46
+ try {
47
+ data = JSON.parse(raw);
48
+ } catch {
49
+ console.error("Error: Invalid JSON input.");
50
+ process.exit(1);
51
+ }
52
+
53
+ if (!Array.isArray(data.people) || !Array.isArray(data.companies)) {
54
+ console.error('Error: JSON must have "people" and "companies" arrays.');
55
+ process.exit(1);
56
+ }
57
+ data.links ??= [];
58
+ data.sources ??= [];
59
+
60
+ const before = loadEntityIndex();
61
+ const counts = (idx: ReturnType<typeof loadEntityIndex>) => ({
62
+ people: Object.keys(idx.people).length,
63
+ companies: Object.keys(idx.companies).length,
64
+ links: Object.keys(idx.links).length,
65
+ sources: Object.keys(idx.sources).length,
66
+ });
67
+ const cb = counts(before);
68
+
69
+ const result = processEntities(
70
+ {
71
+ people: data.people as Array<{ name: string; [key: string]: unknown }>,
72
+ companies: data.companies as Array<{
73
+ name: string;
74
+ domain: string | null;
75
+ [key: string]: unknown;
76
+ }>,
77
+ links: data.links as Array<{ url: string; [key: string]: unknown }>,
78
+ sources: data.sources as Array<{
79
+ url: string | null;
80
+ author: string | null;
81
+ publication: string | null;
82
+ [key: string]: unknown;
83
+ }>,
84
+ },
85
+ sourceId
86
+ );
87
+
88
+ const ca = counts(loadEntityIndex());
89
+
90
+ console.log(
91
+ JSON.stringify(
92
+ {
93
+ saved: {
94
+ people: result.people.length,
95
+ companies: result.companies.length,
96
+ links: result.links.length,
97
+ sources: result.sources.length,
98
+ },
99
+ new: {
100
+ people: ca.people - cb.people,
101
+ companies: ca.companies - cb.companies,
102
+ links: ca.links - cb.links,
103
+ sources: ca.sources - cb.sources,
104
+ },
105
+ total: ca,
106
+ },
107
+ null,
108
+ 2
109
+ )
110
+ );
@@ -0,0 +1,34 @@
1
+ /**
2
+ * PAL Export — Zips all gitignored personal files (memory, telos, state)
3
+ * into a portable archive for transfer between machines.
4
+ *
5
+ * Usage: bun run tool:export [output-path] [--dry-run]
6
+ * Default output: pal-export-YYYYMMDD-HHmmss.zip in the repo root.
7
+ */
8
+
9
+ import { resolve } from "node:path";
10
+ import { collectExportFiles, exportZip, timestamp } from "../hooks/lib/export";
11
+ import { palHome } from "../hooks/lib/paths";
12
+
13
+ const args = process.argv.slice(2);
14
+ const dryRun = args.includes("--dry-run");
15
+ const pathArg = args.find((a) => a !== "--dry-run");
16
+
17
+ const outputPath = pathArg || resolve(palHome(), `pal-export-${timestamp()}.zip`);
18
+
19
+ if (dryRun) {
20
+ const files = collectExportFiles();
21
+ if (files.length === 0) {
22
+ console.log("Nothing to export — no gitignored personal files found.");
23
+ } else {
24
+ console.log(`Would export ${files.length} files → ${outputPath}\n`);
25
+ for (const f of files) console.log(` ${f}`);
26
+ }
27
+ } else {
28
+ const count = exportZip(outputPath);
29
+ if (count === 0) {
30
+ console.log("Nothing to export — no gitignored personal files found.");
31
+ } else {
32
+ console.log(`Exported ${count} files → ${outputPath}`);
33
+ }
34
+ }
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Fyzz Chat API — CLI wrapper for programmatic conversation access.
4
+ *
5
+ * Reads the API key from FYZZ_API_KEY env var (never printed to stdout).
6
+ * Returns JSON responses from the Fyzz Chat REST API.
7
+ *
8
+ * Usage:
9
+ * bun run ai:fyzz-api -- conversations [--limit 20] [--search "query"] [--project-id <id>] [--cursor <cursor>]
10
+ * bun run ai:fyzz-api -- conversations <id>
11
+ * bun run ai:fyzz-api -- projects
12
+ */
13
+
14
+ import { parseArgs } from "node:util";
15
+
16
+ function loadApiKey(): string {
17
+ const key = process.env.FYZZ_API_KEY;
18
+ if (!key) {
19
+ console.error("Error: FYZZ_API_KEY environment variable is not set.");
20
+ console.error("Set it in your shell profile or PAL settings.json env section.");
21
+ process.exit(1);
22
+ }
23
+ return key;
24
+ }
25
+
26
+ async function apiFetch(path: string, params?: Record<string, string>): Promise<unknown> {
27
+ const apiKey = loadApiKey();
28
+ const baseUrl = process.env.FYZZ_BASE_URL ?? "http://localhost:3000";
29
+
30
+ const url = new URL(`/api/v1${path}`, baseUrl);
31
+ if (params) {
32
+ for (const [k, v] of Object.entries(params)) {
33
+ if (v !== undefined && v !== "") url.searchParams.set(k, v);
34
+ }
35
+ }
36
+
37
+ const response = await fetch(url.toString(), {
38
+ headers: { Authorization: `Bearer ${apiKey}` },
39
+ });
40
+
41
+ if (!response.ok) {
42
+ console.error(`Error: ${response.status} ${response.statusText}`);
43
+ const body = await response.text();
44
+ if (body) console.error(body);
45
+ process.exit(1);
46
+ }
47
+
48
+ return response.json();
49
+ }
50
+
51
+ const args = process.argv.slice(2);
52
+ const command = args[0];
53
+
54
+ if (!command || command === "--help" || command === "-h") {
55
+ console.log("Usage:");
56
+ console.log(" bun run ai:fyzz-api -- conversations List conversations");
57
+ console.log(
58
+ " bun run ai:fyzz-api -- conversations <id> Get conversation with messages"
59
+ );
60
+ console.log(" bun run ai:fyzz-api -- projects List projects");
61
+ console.log("");
62
+ console.log("Options for 'conversations' (list mode):");
63
+ console.log(" --limit <n> Max results (default 20)");
64
+ console.log(" --search <query> Search in titles and messages");
65
+ console.log(" --project-id <id> Filter by project");
66
+ console.log(" --cursor <cursor> Pagination cursor");
67
+ process.exit(0);
68
+ }
69
+
70
+ if (command === "conversations") {
71
+ const secondArg = args[1];
72
+
73
+ if (secondArg && !secondArg.startsWith("--")) {
74
+ const result = await apiFetch(`/conversations/${secondArg}`);
75
+ console.log(JSON.stringify(result, null, 2));
76
+ } else {
77
+ const { values } = parseArgs({
78
+ args: args.slice(1),
79
+ options: {
80
+ limit: { type: "string", short: "l", default: "20" },
81
+ search: { type: "string", short: "s" },
82
+ "project-id": { type: "string", short: "p" },
83
+ cursor: { type: "string", short: "c" },
84
+ },
85
+ strict: true,
86
+ });
87
+
88
+ const params: Record<string, string> = {};
89
+ if (values.limit) params.limit = values.limit;
90
+ if (values.search) params.search = values.search;
91
+ if (values["project-id"]) params.projectId = values["project-id"];
92
+ if (values.cursor) params.cursor = values.cursor;
93
+
94
+ const result = await apiFetch("/conversations", params);
95
+ console.log(JSON.stringify(result, null, 2));
96
+ }
97
+ } else if (command === "projects") {
98
+ const result = await apiFetch("/projects");
99
+ console.log(JSON.stringify(result, null, 2));
100
+ } else {
101
+ console.error(`Unknown command: ${command}`);
102
+ console.error("Run with --help for usage.");
103
+ process.exit(1);
104
+ }