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,123 @@
1
+ /**
2
+ * PAL Import — Extracts a PAL export archive into the repo,
3
+ * restoring personal files (memory, telos, state).
4
+ *
5
+ * Usage: bun run tool:import [path-to-zip] [--dry-run]
6
+ * If no path is given, finds the latest pal-export-*.zip and asks for confirmation.
7
+ * Then run: bun run install:all to re-create symlinks and hooks.
8
+ */
9
+
10
+ import { readdirSync, statSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import { createInterface } from "node:readline";
13
+ import AdmZip from "adm-zip";
14
+ import { palHome } from "../hooks/lib/paths";
15
+
16
+ const repoRoot = palHome();
17
+ const args = process.argv.slice(2);
18
+ const dryRun = args.includes("--dry-run");
19
+ const pathArg = args.find((a) => a !== "--dry-run");
20
+
21
+ async function confirm(message: string): Promise<boolean> {
22
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
23
+ return new Promise((res) => {
24
+ rl.question(`${message} [y/N] `, (answer) => {
25
+ rl.close();
26
+ res(answer.trim().toLowerCase() === "y");
27
+ });
28
+ });
29
+ }
30
+
31
+ function findLatestExport(): string | null {
32
+ const files = readdirSync(repoRoot)
33
+ .filter((f) => f.startsWith("pal-export-") && f.endsWith(".zip"))
34
+ .sort()
35
+ .reverse();
36
+
37
+ // Also check backups/
38
+ try {
39
+ const backupDir = resolve(repoRoot, "backups");
40
+ const backups = readdirSync(backupDir)
41
+ .filter(
42
+ (f) =>
43
+ (f.startsWith("pal-export-") || f.startsWith("pal-backup-")) &&
44
+ f.endsWith(".zip")
45
+ )
46
+ .map((f) => ({ name: f, path: resolve(backupDir, f) }))
47
+ .sort((a, b) => b.name.localeCompare(a.name));
48
+ if (backups.length > 0) files.push(backups[0].name);
49
+ } catch {
50
+ // No backups dir
51
+ }
52
+
53
+ if (files.length === 0) return null;
54
+
55
+ // Find the most recent by mtime across both locations
56
+ const candidates = [
57
+ ...readdirSync(repoRoot)
58
+ .filter((f) => f.startsWith("pal-export-") && f.endsWith(".zip"))
59
+ .map((f) => resolve(repoRoot, f)),
60
+ ];
61
+ try {
62
+ const backupDir = resolve(repoRoot, "backups");
63
+ candidates.push(
64
+ ...readdirSync(backupDir)
65
+ .filter(
66
+ (f) =>
67
+ (f.startsWith("pal-export-") || f.startsWith("pal-backup-")) &&
68
+ f.endsWith(".zip")
69
+ )
70
+ .map((f) => resolve(backupDir, f))
71
+ );
72
+ } catch {
73
+ // No backups dir
74
+ }
75
+
76
+ if (candidates.length === 0) return null;
77
+ return candidates.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
78
+ }
79
+
80
+ // Resolve zip path
81
+ let zipPath: string;
82
+
83
+ if (pathArg) {
84
+ zipPath = resolve(pathArg);
85
+ } else {
86
+ const latest = findLatestExport();
87
+ if (!latest) {
88
+ console.error(
89
+ "No export or backup files found. Provide a path: bun run tool:import <path-to-zip>"
90
+ );
91
+ process.exit(1);
92
+ }
93
+ console.log(`Found: ${latest}`);
94
+ const zip = new AdmZip(latest);
95
+ const entries = zip.getEntries();
96
+ console.log(
97
+ `Contains ${entries.length} files, created ${statSync(latest).mtime.toISOString().slice(0, 16).replace("T", " ")}`
98
+ );
99
+
100
+ if (!(await confirm("Import this file?"))) {
101
+ console.log("Cancelled.");
102
+ process.exit(0);
103
+ }
104
+ zipPath = latest;
105
+ }
106
+
107
+ // Import
108
+ const zip = new AdmZip(zipPath);
109
+ const entries = zip.getEntries();
110
+
111
+ if (entries.length === 0) {
112
+ console.log("Archive is empty — nothing to import.");
113
+ process.exit(0);
114
+ }
115
+
116
+ if (dryRun) {
117
+ console.log(`Would import ${entries.length} files → ${repoRoot}\n`);
118
+ for (const e of entries) console.log(` ${e.entryName}`);
119
+ } else {
120
+ zip.extractAllTo(repoRoot, true);
121
+ console.log(`Imported ${entries.length} files → ${repoRoot}`);
122
+ console.log("\nRun 'bun run install:all' to re-create symlinks and hooks.");
123
+ }
@@ -0,0 +1,435 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * LearningPatternSynthesis — Aggregate ratings into actionable patterns.
4
+ *
5
+ * Analyzes memory/signals/ratings.jsonl to find recurring frustration/success
6
+ * patterns and generates synthesis reports.
7
+ *
8
+ * Usage:
9
+ * bun run tool:patterns # Analyze last 7 days (default)
10
+ * bun run tool:patterns -- --month # Analyze last 30 days
11
+ * bun run tool:patterns -- --all # Analyze all ratings
12
+ * bun run tool:patterns -- --dry-run # Preview without writing
13
+ */
14
+
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { resolve } from "node:path";
17
+ import { parseArgs } from "node:util";
18
+ import { HAIKU_MODEL } from "../hooks/lib/models";
19
+ import { palHome } from "../hooks/lib/paths";
20
+
21
+ // ── Paths ──
22
+
23
+ const RATINGS_FILE = resolve(palHome(), "memory", "signals", "ratings.jsonl");
24
+ const SYNTHESIS_DIR = resolve(palHome(), "memory", "learning", "synthesis");
25
+
26
+ // ── Types ──
27
+
28
+ interface Rating {
29
+ ts: string;
30
+ rating: number;
31
+ context: string;
32
+ source: "explicit" | "implicit";
33
+ response_preview?: string;
34
+ }
35
+
36
+ interface PatternGroup {
37
+ pattern: string;
38
+ count: number;
39
+ avgRating: number;
40
+ examples: string[];
41
+ }
42
+
43
+ interface SynthesisResult {
44
+ period: string;
45
+ totalRatings: number;
46
+ avgRating: number;
47
+ frustrations: PatternGroup[];
48
+ successes: PatternGroup[];
49
+ topIssues: string[];
50
+ recommendations: string[];
51
+ }
52
+
53
+ // ── Pattern Detection ──
54
+
55
+ const FRUSTRATION_PATTERNS: Record<string, RegExp> = {
56
+ "Time/Performance Issues": /time|slow|delay|hang|wait|long|minutes|hours/i,
57
+ "Incomplete Work": /incomplete|missing|partial|didn't finish|not done/i,
58
+ "Wrong Approach": /wrong|incorrect|not what|misunderstand|mistake/i,
59
+ "Over-engineering": /over-?engineer|too complex|unnecessary|bloat/i,
60
+ "Tool/System Failures": /fail|error|broken|crash|bug|issue/i,
61
+ "Communication Problems": /unclear|confus|didn't ask|should have asked/i,
62
+ "Repetitive Issues": /again|repeat|still|same problem/i,
63
+ };
64
+
65
+ const SUCCESS_PATTERNS: Record<string, RegExp> = {
66
+ "Quick Resolution": /quick|fast|efficient|smooth/i,
67
+ "Good Understanding": /understood|clear|exactly|perfect/i,
68
+ "Proactive Help": /proactive|anticipat|helpful|above and beyond/i,
69
+ "Clean Implementation": /clean|simple|elegant|well done/i,
70
+ };
71
+
72
+ function detectPatterns(
73
+ summaries: string[],
74
+ patterns: Record<string, RegExp>
75
+ ): Map<string, string[]> {
76
+ const results = new Map<string, string[]>();
77
+ for (const summary of summaries) {
78
+ for (const [name, pattern] of Object.entries(patterns)) {
79
+ if (pattern.test(summary)) {
80
+ const arr = results.get(name) ?? [];
81
+ arr.push(summary);
82
+ results.set(name, arr);
83
+ }
84
+ }
85
+ }
86
+ return results;
87
+ }
88
+
89
+ function toPatternGroups(
90
+ grouped: Map<string, string[]>,
91
+ ratings: Rating[]
92
+ ): PatternGroup[] {
93
+ const groups: PatternGroup[] = [];
94
+
95
+ for (const [pattern, examples] of grouped.entries()) {
96
+ const matching = ratings.filter((r) => examples.some((e) => e === r.context));
97
+ const avgRating =
98
+ matching.length > 0
99
+ ? matching.reduce((sum, r) => sum + r.rating, 0) / matching.length
100
+ : 5;
101
+
102
+ groups.push({
103
+ pattern,
104
+ count: examples.length,
105
+ avgRating,
106
+ examples: examples.slice(0, 3),
107
+ });
108
+ }
109
+
110
+ return groups.sort((a, b) => b.count - a.count);
111
+ }
112
+
113
+ // ── Analysis ──
114
+
115
+ async function generateRecommendations(
116
+ frustrations: PatternGroup[],
117
+ successes: PatternGroup[],
118
+ avgRating: number
119
+ ): Promise<string[]> {
120
+ const apiKey = process.env.ANTHROPIC_API_KEY;
121
+ if (!apiKey || frustrations.length === 0) {
122
+ // Fallback: generic recommendations
123
+ if (frustrations.length === 0)
124
+ return ["Continue current patterns - no major issues detected"];
125
+ return frustrations
126
+ .slice(0, 3)
127
+ .map(
128
+ (f) =>
129
+ `Address "${f.pattern}" (${f.count} occurrences, avg ${f.avgRating.toFixed(1)}/10)`
130
+ );
131
+ }
132
+
133
+ try {
134
+ const context = [
135
+ `Average rating: ${avgRating.toFixed(1)}/10`,
136
+ "",
137
+ "Top frustration patterns:",
138
+ ...frustrations
139
+ .slice(0, 5)
140
+ .map(
141
+ (f) =>
142
+ `- ${f.pattern} (${f.count}x, avg ${f.avgRating.toFixed(1)}): ${f.examples.slice(0, 2).join("; ")}`
143
+ ),
144
+ "",
145
+ successes.length > 0 ? "Success patterns:" : "",
146
+ ...successes
147
+ .slice(0, 3)
148
+ .map((s) => `- ${s.pattern} (${s.count}x, avg ${s.avgRating.toFixed(1)})`),
149
+ ]
150
+ .filter(Boolean)
151
+ .join("\n");
152
+
153
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
154
+ method: "POST",
155
+ headers: {
156
+ "x-api-key": apiKey,
157
+ "anthropic-version": "2023-06-01",
158
+ "content-type": "application/json",
159
+ },
160
+ body: JSON.stringify({
161
+ model: HAIKU_MODEL,
162
+ max_tokens: 300,
163
+ messages: [{ role: "user", content: context }],
164
+ system:
165
+ "You analyze AI assistant interaction patterns. Given frustration and success patterns from user ratings, generate 3-5 recommendations. Each MUST reference a specific example from the data — no generic advice like 'ask clarifying questions' or 'communicate better'. Every recommendation should name the concrete situation and the concrete fix. One sentence each. Return a JSON array of strings.",
166
+ output_config: {
167
+ format: {
168
+ type: "json_schema",
169
+ schema: {
170
+ type: "object",
171
+ additionalProperties: false,
172
+ properties: {
173
+ recommendations: {
174
+ type: "array",
175
+ items: { type: "string" },
176
+ },
177
+ },
178
+ required: ["recommendations"],
179
+ },
180
+ },
181
+ },
182
+ }),
183
+ signal: AbortSignal.timeout(15000),
184
+ });
185
+
186
+ if (response.ok) {
187
+ const data = (await response.json()) as { content?: Array<{ text?: string }> };
188
+ const text = data?.content?.[0]?.text?.trim();
189
+ if (text) {
190
+ const parsed = JSON.parse(text) as { recommendations: string[] };
191
+ if (parsed.recommendations?.length > 0) return parsed.recommendations.slice(0, 5);
192
+ }
193
+ }
194
+ } catch {
195
+ // Fallback silently
196
+ }
197
+
198
+ return frustrations
199
+ .slice(0, 3)
200
+ .map((f) => `Address "${f.pattern}" (${f.count} occurrences)`);
201
+ }
202
+
203
+ async function analyzeRatings(
204
+ ratings: Rating[],
205
+ period: string
206
+ ): Promise<SynthesisResult> {
207
+ if (ratings.length === 0) {
208
+ return {
209
+ period,
210
+ totalRatings: 0,
211
+ avgRating: 0,
212
+ frustrations: [],
213
+ successes: [],
214
+ topIssues: [],
215
+ recommendations: [],
216
+ };
217
+ }
218
+
219
+ const avgRating = ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length;
220
+
221
+ const frustrationRatings = ratings.filter((r) => r.rating <= 4);
222
+ const successRatings = ratings.filter((r) => r.rating >= 7);
223
+
224
+ const frustrationGroups = detectPatterns(
225
+ frustrationRatings.map((r) => r.context),
226
+ FRUSTRATION_PATTERNS
227
+ );
228
+ const successGroups = detectPatterns(
229
+ successRatings.map((r) => r.context),
230
+ SUCCESS_PATTERNS
231
+ );
232
+
233
+ const frustrations = toPatternGroups(frustrationGroups, frustrationRatings);
234
+ const successes = toPatternGroups(successGroups, successRatings);
235
+
236
+ const topIssues = frustrations
237
+ .slice(0, 3)
238
+ .map(
239
+ (f) => `${f.pattern} (${f.count} occurrences, avg rating ${f.avgRating.toFixed(1)})`
240
+ );
241
+
242
+ const recommendations = await generateRecommendations(
243
+ frustrations,
244
+ successes,
245
+ avgRating
246
+ );
247
+
248
+ return {
249
+ period,
250
+ totalRatings: ratings.length,
251
+ avgRating,
252
+ frustrations,
253
+ successes,
254
+ topIssues,
255
+ recommendations,
256
+ };
257
+ }
258
+
259
+ // ── Report ──
260
+
261
+ function formatReport(result: SynthesisResult): string {
262
+ const date = new Date().toISOString().slice(0, 10);
263
+ const lines: string[] = [
264
+ "# Learning Pattern Synthesis",
265
+ "",
266
+ `**Period:** ${result.period}`,
267
+ `**Generated:** ${date}`,
268
+ `**Total Ratings:** ${result.totalRatings}`,
269
+ `**Average Rating:** ${result.avgRating.toFixed(1)}/10`,
270
+ "",
271
+ "---",
272
+ "",
273
+ "## Top Issues",
274
+ "",
275
+ ];
276
+
277
+ if (result.topIssues.length > 0) {
278
+ for (let i = 0; i < result.topIssues.length; i++) {
279
+ lines.push(`${i + 1}. ${result.topIssues[i]}`);
280
+ }
281
+ } else {
282
+ lines.push("No significant issues detected");
283
+ }
284
+
285
+ lines.push("", "## Frustration Patterns", "");
286
+ if (result.frustrations.length === 0) {
287
+ lines.push("*No frustration patterns detected*");
288
+ } else {
289
+ for (const f of result.frustrations) {
290
+ lines.push(
291
+ `### ${f.pattern}`,
292
+ "",
293
+ `- **Occurrences:** ${f.count}`,
294
+ `- **Avg Rating:** ${f.avgRating.toFixed(1)}`,
295
+ `- **Examples:**`,
296
+ ...f.examples.map((e) => ` - "${e}"`),
297
+ ""
298
+ );
299
+ }
300
+ }
301
+
302
+ lines.push("", "## Success Patterns", "");
303
+ if (result.successes.length === 0) {
304
+ lines.push("*No success patterns detected*");
305
+ } else {
306
+ for (const s of result.successes) {
307
+ lines.push(
308
+ `### ${s.pattern}`,
309
+ "",
310
+ `- **Occurrences:** ${s.count}`,
311
+ `- **Avg Rating:** ${s.avgRating.toFixed(1)}`,
312
+ `- **Examples:**`,
313
+ ...s.examples.map((e) => ` - "${e}"`),
314
+ ""
315
+ );
316
+ }
317
+ }
318
+
319
+ lines.push(
320
+ "",
321
+ "## Recommendations",
322
+ "",
323
+ ...result.recommendations.map((r, i) => `${i + 1}. ${r}`),
324
+ ""
325
+ );
326
+
327
+ return lines.join("\n");
328
+ }
329
+
330
+ function writeReport(result: SynthesisResult, period: string): string {
331
+ const date = new Date().toISOString().slice(0, 10);
332
+ const monthDir = resolve(SYNTHESIS_DIR, date.slice(0, 7));
333
+ if (!existsSync(monthDir)) mkdirSync(monthDir, { recursive: true });
334
+
335
+ const slug = period.toLowerCase().replace(/\s+/g, "-");
336
+ const filename = `${date}_${slug}-patterns.md`;
337
+ const filepath = resolve(monthDir, filename);
338
+
339
+ writeFileSync(filepath, formatReport(result), "utf-8");
340
+ return filepath;
341
+ }
342
+
343
+ // ── CLI ──
344
+
345
+ const { values } = parseArgs({
346
+ args: Bun.argv.slice(2),
347
+ options: {
348
+ week: { type: "boolean" },
349
+ month: { type: "boolean" },
350
+ all: { type: "boolean" },
351
+ "dry-run": { type: "boolean" },
352
+ help: { type: "boolean", short: "h" },
353
+ },
354
+ });
355
+
356
+ if (values.help) {
357
+ console.log(`
358
+ LearningPatternSynthesis — Aggregate ratings into actionable patterns
359
+
360
+ Usage:
361
+ bun run tool:patterns Analyze last 7 days (default)
362
+ bun run tool:patterns -- --month Analyze last 30 days
363
+ bun run tool:patterns -- --all Analyze all ratings
364
+ bun run tool:patterns -- --dry-run Preview without writing
365
+
366
+ Output: Creates synthesis report in memory/learning/synthesis/YYYY-MM/
367
+ `);
368
+ process.exit(0);
369
+ }
370
+
371
+ if (!existsSync(RATINGS_FILE)) {
372
+ console.log("No ratings file found at:", RATINGS_FILE);
373
+ process.exit(0);
374
+ }
375
+
376
+ // Read ratings
377
+ const allRatings: Rating[] = readFileSync(RATINGS_FILE, "utf-8")
378
+ .split("\n")
379
+ .filter((l) => l.trim())
380
+ .map((l) => {
381
+ try {
382
+ return JSON.parse(l);
383
+ } catch {
384
+ return null;
385
+ }
386
+ })
387
+ .filter((r): r is Rating => r !== null);
388
+
389
+ console.log(`Loaded ${allRatings.length} total ratings`);
390
+
391
+ // Determine period
392
+ let period = "Weekly";
393
+ const cutoff = new Date();
394
+
395
+ if (values.month) {
396
+ period = "Monthly";
397
+ cutoff.setDate(cutoff.getDate() - 30);
398
+ } else if (values.all) {
399
+ period = "All Time";
400
+ cutoff.setTime(0);
401
+ } else {
402
+ cutoff.setDate(cutoff.getDate() - 7);
403
+ }
404
+
405
+ const filtered = allRatings.filter((r) => new Date(r.ts).getTime() >= cutoff.getTime());
406
+ console.log(`Analyzing ${filtered.length} ratings for ${period.toLowerCase()} period`);
407
+
408
+ if (filtered.length === 0) {
409
+ console.log("No ratings in this period");
410
+ process.exit(0);
411
+ }
412
+
413
+ const result = await analyzeRatings(filtered, period);
414
+
415
+ console.log(`\nAverage Rating: ${result.avgRating.toFixed(1)}/10`);
416
+ console.log(`Frustration Patterns: ${result.frustrations.length}`);
417
+ console.log(`Success Patterns: ${result.successes.length}`);
418
+
419
+ if (result.topIssues.length > 0) {
420
+ console.log("\nTop Issues:");
421
+ for (const issue of result.topIssues) {
422
+ console.log(` - ${issue}`);
423
+ }
424
+ }
425
+
426
+ if (values["dry-run"]) {
427
+ console.log("\n[DRY RUN] Would write synthesis report");
428
+ console.log("\nRecommendations:");
429
+ for (const rec of result.recommendations) {
430
+ console.log(` - ${rec}`);
431
+ }
432
+ } else {
433
+ const filepath = writeReport(result, period);
434
+ console.log(`\nCreated synthesis report: ${filepath}`);
435
+ }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * PDF Download — Downloads a PDF from a URL and saves it to an organized local archive.
5
+ *
6
+ * Saves to: {PAL_ROOT}/memory/downloads/{YYYY}/{MM}/{DD}/{filename}.pdf
7
+ *
8
+ * Usage:
9
+ * bun run ai:pdf-download -- <url> [--filename <name.pdf>]
10
+ *
11
+ * Returns JSON with the saved file path for downstream reading.
12
+ */
13
+
14
+ import { mkdir } from "node:fs/promises";
15
+ import { basename, join } from "node:path";
16
+ import { parseArgs } from "node:util";
17
+ import { palHome } from "../hooks/lib/paths";
18
+
19
+ const DOWNLOADS_DIR = join(palHome(), "memory", "downloads");
20
+
21
+ function buildDatePath(): string {
22
+ const now = new Date();
23
+ const yyyy = now.getFullYear().toString();
24
+ const mm = (now.getMonth() + 1).toString().padStart(2, "0");
25
+ const dd = now.getDate().toString().padStart(2, "0");
26
+ return join(yyyy, mm, dd);
27
+ }
28
+
29
+ function extractFilename(url: string, override?: string): string {
30
+ if (override) {
31
+ return override.endsWith(".pdf") ? override : `${override}.pdf`;
32
+ }
33
+ const urlPath = new URL(url).pathname;
34
+ const name = basename(urlPath);
35
+ return name.endsWith(".pdf") ? name : `${name}.pdf`;
36
+ }
37
+
38
+ async function main() {
39
+ const { positionals, values } = parseArgs({
40
+ allowPositionals: true,
41
+ options: {
42
+ filename: { type: "string", short: "f" },
43
+ },
44
+ });
45
+
46
+ const url = positionals[0];
47
+ if (!url) {
48
+ console.error("Usage: bun run ai:pdf-download -- <url> [--filename <name.pdf>]");
49
+ process.exit(1);
50
+ }
51
+
52
+ // Validate URL
53
+ let parsed: URL;
54
+ try {
55
+ parsed = new URL(url);
56
+ } catch {
57
+ console.error(`Error: Invalid URL: ${url}`);
58
+ process.exit(1);
59
+ }
60
+
61
+ if (!["http:", "https:"].includes(parsed.protocol)) {
62
+ console.error(`Error: Only HTTP/HTTPS URLs are supported.`);
63
+ process.exit(1);
64
+ }
65
+
66
+ // Download
67
+ const response = await fetch(url);
68
+ if (!response.ok) {
69
+ console.error(`Error: HTTP ${response.status} ${response.statusText}`);
70
+ process.exit(1);
71
+ }
72
+
73
+ const contentType = response.headers.get("content-type") ?? "";
74
+ if (!contentType.includes("pdf") && !url.endsWith(".pdf")) {
75
+ console.error(`Warning: Content-Type is "${contentType}", may not be a PDF.`);
76
+ }
77
+
78
+ const buffer = await response.arrayBuffer();
79
+
80
+ // Build destination path
81
+ const datePath = buildDatePath();
82
+ const dir = join(DOWNLOADS_DIR, datePath);
83
+ await mkdir(dir, { recursive: true });
84
+
85
+ const filename = extractFilename(url, values.filename);
86
+ const filePath = join(dir, filename);
87
+
88
+ // Write file
89
+ await Bun.write(filePath, buffer);
90
+
91
+ const result = {
92
+ path: filePath,
93
+ filename,
94
+ size: buffer.byteLength,
95
+ url,
96
+ downloadedAt: new Date().toISOString(),
97
+ };
98
+
99
+ console.log(JSON.stringify(result, null, 2));
100
+ }
101
+
102
+ main();