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,121 @@
1
+ /**
2
+ * UserPromptSubmit handler: generates a 4-word name for the session.
3
+ *
4
+ * Architecture (fast-exit):
5
+ * - First prompt: instant deterministic name from keywords (<10ms),
6
+ * then spawns a detached background process to upgrade via inference.
7
+ * - Subsequent prompts: no-op (name already set).
8
+ *
9
+ * This avoids the 1-5s inference latency that previously blocked every first prompt.
10
+ */
11
+
12
+ // import { spawn } from "node:child_process";
13
+ import { inference } from "../lib/inference";
14
+ import { logDebug, logError } from "../lib/log";
15
+ import {
16
+ extractFallbackName,
17
+ readSessionNames,
18
+ writeSessionName,
19
+ } from "../lib/session-names";
20
+ import { logTokenUsage } from "../lib/token-usage";
21
+
22
+ const NAME_PROMPT =
23
+ "You generate concise 4-word session titles for AI coding sessions. " +
24
+ "Output EXACTLY 4 words in Title Case, no punctuation. Describe the specific task. " +
25
+ 'Example: "Fix Session Name Generation", "Debug Auth Token Refresh"';
26
+
27
+ export async function captureSessionName(
28
+ message: string,
29
+ sessionId: string
30
+ ): Promise<void> {
31
+ if (!sessionId) return;
32
+
33
+ // Skip if this session is already named
34
+ const names = readSessionNames();
35
+ if (names[sessionId]) return;
36
+
37
+ // 1. Instant deterministic name from keywords
38
+ const fallback = extractFallbackName(message);
39
+ writeSessionName(sessionId, fallback);
40
+ logDebug("session-name", `Deterministic name: "${fallback}"`);
41
+
42
+ // TODO: re-enable when a consumer exists (tab titles, dashboard)
43
+ // // 2. Spawn detached background process to upgrade with inference
44
+ // if (!process.env.ANTHROPIC_API_KEY) return;
45
+ // try {
46
+ // const promptB64 = Buffer.from(message.slice(0, 800)).toString("base64");
47
+ // const child = spawn(
48
+ // "bun",
49
+ // [import.meta.filename, "--upgrade", sessionId, promptB64, fallback],
50
+ // {
51
+ // detached: true,
52
+ // stdio: "ignore",
53
+ // env: { ...process.env, CLAUDECODE: undefined },
54
+ // }
55
+ // );
56
+ // child.unref();
57
+ // logDebug("session-name", "Spawned background inference upgrade");
58
+ // } catch {
59
+ // // Non-critical — deterministic name is already stored
60
+ // }
61
+ }
62
+
63
+ /**
64
+ * Background upgrade mode: called via --upgrade flag from a detached subprocess.
65
+ * Runs inference for a better name, writes only if the name hasn't changed since spawn.
66
+ */
67
+ async function upgradeWithInference(
68
+ sessionId: string,
69
+ promptB64: string,
70
+ expectedName: string
71
+ ): Promise<void> {
72
+ try {
73
+ // Version guard: if name changed since we were spawned, skip
74
+ const currentNames = readSessionNames();
75
+ if (currentNames[sessionId] !== expectedName) return;
76
+
77
+ const promptText = Buffer.from(promptB64, "base64").toString("utf-8");
78
+ const result = await inference({
79
+ system: NAME_PROMPT,
80
+ user: `Generate a 4-word title for: "${promptText}"`,
81
+ maxTokens: 20,
82
+ timeout: 10000,
83
+ });
84
+
85
+ if (result.usage) logTokenUsage("session-name", result.usage);
86
+ if (!result.success || !result.output) return;
87
+
88
+ let label = result.output
89
+ .replace(/^["']|["']$/g, "")
90
+ .replace(/[.!?,;:]/g, "")
91
+ .trim();
92
+
93
+ const words = label.split(/\s+/).slice(0, 4);
94
+ label = words
95
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
96
+ .join(" ");
97
+
98
+ const allSubstantial = words.every((w) => w.length >= 3);
99
+ if (!label || words.length !== 4 || !allSubstantial) return;
100
+
101
+ // Only write if name hasn't changed (re-read under guard)
102
+ const freshNames = readSessionNames();
103
+ if (freshNames[sessionId] !== expectedName) return;
104
+
105
+ writeSessionName(sessionId, label);
106
+ logDebug("session-name", `Background upgrade: "${label}"`);
107
+ } catch (err) {
108
+ logError("session-name:upgrade", err);
109
+ }
110
+ }
111
+
112
+ // Background upgrade entry point
113
+ if (process.argv[2] === "--upgrade") {
114
+ const sid = process.argv[3];
115
+ const prompt = process.argv[4];
116
+ const expected = process.argv[5];
117
+ if (sid && prompt && expected !== undefined) {
118
+ await upgradeWithInference(sid, prompt, expected);
119
+ }
120
+ process.exit(0);
121
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Auto-trigger pattern synthesis on Stop when conditions are met:
3
+ * - 7+ days since last synthesis report
4
+ * - 20+ new ratings since last synthesis
5
+ */
6
+
7
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { logDebug } from "../lib/log";
10
+ import { palPkg, paths } from "../lib/paths";
11
+
12
+ const MIN_DAYS_BETWEEN = 7;
13
+ const MIN_NEW_RATINGS = 20;
14
+
15
+ function getLastSynthesisDate(): Date | null {
16
+ try {
17
+ const synthDir = paths.synthesis();
18
+ if (!existsSync(synthDir)) return null;
19
+
20
+ const months = readdirSync(synthDir).sort().reverse();
21
+ for (const month of months) {
22
+ const monthDir = resolve(synthDir, month);
23
+ const files = readdirSync(monthDir)
24
+ .filter((f) => f.endsWith(".md"))
25
+ .sort()
26
+ .reverse();
27
+ if (files.length > 0) {
28
+ // Filename: YYYY-MM-DD_period-patterns.md
29
+ const dateStr = files[0].slice(0, 10);
30
+ const date = new Date(dateStr);
31
+ if (!Number.isNaN(date.getTime())) return date;
32
+ }
33
+ }
34
+ } catch {
35
+ /* ignore */
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function countRatingsSince(since: Date | null): number {
41
+ try {
42
+ const ratingsFile = resolve(paths.signals(), "ratings.jsonl");
43
+ if (!existsSync(ratingsFile)) return 0;
44
+
45
+ const lines = readFileSync(ratingsFile, "utf-8").trim().split("\n");
46
+ if (!since) return lines.length;
47
+
48
+ let count = 0;
49
+ for (const line of lines) {
50
+ try {
51
+ const entry = JSON.parse(line) as { ts?: string };
52
+ if (entry.ts && new Date(entry.ts) > since) count++;
53
+ } catch {
54
+ /* skip bad lines */
55
+ }
56
+ }
57
+ return count;
58
+ } catch {
59
+ return 0;
60
+ }
61
+ }
62
+
63
+ export async function checkSynthesisTrigger(): Promise<void> {
64
+ const lastDate = getLastSynthesisDate();
65
+ const now = new Date();
66
+
67
+ // Check days since last synthesis
68
+ if (lastDate) {
69
+ const daysSince = (now.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24);
70
+ if (daysSince < MIN_DAYS_BETWEEN) {
71
+ logDebug(
72
+ "synthesis",
73
+ `Skipping: only ${daysSince.toFixed(1)} days since last report`
74
+ );
75
+ return;
76
+ }
77
+ }
78
+
79
+ // Check new rating count
80
+ const newRatings = countRatingsSince(lastDate);
81
+ if (newRatings < MIN_NEW_RATINGS) {
82
+ logDebug(
83
+ "synthesis",
84
+ `Skipping: only ${newRatings} new ratings (need ${MIN_NEW_RATINGS})`
85
+ );
86
+ return;
87
+ }
88
+
89
+ logDebug(
90
+ "synthesis",
91
+ `Triggering: ${newRatings} new ratings, ${lastDate ? `last report: ${lastDate.toISOString().slice(0, 10)}` : "no previous report"}`
92
+ );
93
+
94
+ // Spawn synthesis as a detached process so it doesn't block the Stop handler
95
+ try {
96
+ const repoDir = palPkg();
97
+ const proc = Bun.spawn(["bun", "run", "tool:patterns"], {
98
+ cwd: repoDir,
99
+ stdout: "ignore",
100
+ stderr: "ignore",
101
+ stdin: "ignore",
102
+ });
103
+ // Don't await — let it run in background
104
+ proc.unref();
105
+ logDebug("synthesis", "Spawned pattern synthesis in background");
106
+ } catch (err) {
107
+ logDebug("synthesis", `Failed to spawn: ${err}`);
108
+ }
109
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Stop handler: resets terminal tab title to default.
3
+ */
4
+
5
+ export async function resetTab(): Promise<void> {
6
+ // Reset terminal tab title
7
+ process.stdout.write("\x1b]0;claude\x07");
8
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Stop handler: writes fresh counts to memory/state/counts.json
3
+ * so the session-start greeting can read a single file instead of
4
+ * scanning directories and JSONL files.
5
+ */
6
+
7
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { assets, ensureDir, paths } from "../lib/paths";
10
+
11
+ interface TokenSummary {
12
+ input: number;
13
+ output: number;
14
+ }
15
+
16
+ interface TokenUsage {
17
+ today: TokenSummary;
18
+ week: TokenSummary;
19
+ month: TokenSummary;
20
+ total: TokenSummary;
21
+ }
22
+
23
+ interface Counts {
24
+ signals: number;
25
+ telos: number;
26
+ skills: number;
27
+ sessions: number;
28
+ tokens: TokenUsage;
29
+ updatedAt: string;
30
+ }
31
+
32
+ function countJsonlLines(filepath: string): number {
33
+ try {
34
+ if (!existsSync(filepath)) return 0;
35
+ const content = readFileSync(filepath, "utf-8").trim();
36
+ return content ? content.split("\n").length : 0;
37
+ } catch {
38
+ return 0;
39
+ }
40
+ }
41
+
42
+ function countMdFiles(dir: string): number {
43
+ try {
44
+ if (!existsSync(dir)) return 0;
45
+ return readdirSync(dir).filter((f) => f.endsWith(".md")).length;
46
+ } catch {
47
+ return 0;
48
+ }
49
+ }
50
+
51
+ function getTokenUsage(): TokenUsage {
52
+ const today: TokenSummary = { input: 0, output: 0 };
53
+ const week: TokenSummary = { input: 0, output: 0 };
54
+ const month: TokenSummary = { input: 0, output: 0 };
55
+ const total: TokenSummary = { input: 0, output: 0 };
56
+
57
+ const filepath = resolve(paths.signals(), "token-usage.jsonl");
58
+ if (!existsSync(filepath)) return { today, week, month, total };
59
+
60
+ try {
61
+ const content = readFileSync(filepath, "utf-8").trim();
62
+ if (!content) return { today, week, month, total };
63
+
64
+ const now = new Date();
65
+ const todayPrefix = now.toISOString().slice(0, 10);
66
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
67
+ const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
68
+
69
+ for (const line of content.split("\n")) {
70
+ try {
71
+ const entry = JSON.parse(line) as {
72
+ ts?: string;
73
+ inputTokens?: number;
74
+ outputTokens?: number;
75
+ };
76
+ const input = entry.inputTokens ?? 0;
77
+ const output = entry.outputTokens ?? 0;
78
+ const ts = entry.ts ?? "";
79
+
80
+ total.input += input;
81
+ total.output += output;
82
+ if (ts >= monthAgo) {
83
+ month.input += input;
84
+ month.output += output;
85
+ }
86
+ if (ts >= weekAgo) {
87
+ week.input += input;
88
+ week.output += output;
89
+ }
90
+ if (ts.startsWith(todayPrefix)) {
91
+ today.input += input;
92
+ today.output += output;
93
+ }
94
+ } catch {
95
+ /* skip malformed line */
96
+ }
97
+ }
98
+ } catch {
99
+ /* file read error */
100
+ }
101
+
102
+ return { today, week, month, total };
103
+ }
104
+
105
+ function getCounts(): Counts {
106
+ const signalsDir = paths.signals();
107
+ const signals = countJsonlLines(resolve(signalsDir, "ratings.jsonl"));
108
+
109
+ const telos = countMdFiles(paths.telos());
110
+
111
+ // Count skills in the PAL skills dir
112
+ let skills = 0;
113
+ const skillsDir = assets.skills();
114
+ try {
115
+ if (existsSync(skillsDir)) {
116
+ skills = readdirSync(skillsDir, { withFileTypes: true }).filter((e) =>
117
+ e.isDirectory()
118
+ ).length;
119
+ }
120
+ } catch {
121
+ /* skip */
122
+ }
123
+
124
+ // Count named sessions
125
+ let sessions = 0;
126
+ try {
127
+ const namesPath = resolve(paths.state(), "session-names.json");
128
+ if (existsSync(namesPath)) {
129
+ sessions = Object.keys(JSON.parse(readFileSync(namesPath, "utf-8"))).length;
130
+ }
131
+ } catch {
132
+ /* skip */
133
+ }
134
+
135
+ const tokens = getTokenUsage();
136
+
137
+ return {
138
+ signals,
139
+ telos,
140
+ skills,
141
+ sessions,
142
+ tokens,
143
+ updatedAt: new Date().toISOString(),
144
+ };
145
+ }
146
+
147
+ export async function updateCounts(): Promise<void> {
148
+ const counts = getCounts();
149
+ const countsPath = resolve(ensureDir(paths.state()), "counts.json");
150
+ writeFileSync(countsPath, JSON.stringify(counts, null, 2), "utf-8");
151
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Stop handler: writes a structured learning file for significant sessions.
3
+ * Dedup: only writes once per session ID (tracks in a marker file).
4
+ * Threshold: >2000 chars transcript + at least 6 messages.
5
+ * Output: memory/learning/session/YYYY-MM/{datetime}_work_{slug}.md
6
+ */
7
+
8
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { inference } from "../lib/inference";
11
+ import { categorizeLearning } from "../lib/learning-category";
12
+ import { ensureDir, paths } from "../lib/paths";
13
+ import { fileTimestamp, monthPath } from "../lib/time";
14
+ import { logTokenUsage } from "../lib/token-usage";
15
+ import {
16
+ extractContent,
17
+ extractLastAssistant,
18
+ extractLastUser,
19
+ parseMessages,
20
+ } from "../lib/transcript";
21
+
22
+ function slugify(text: string): string {
23
+ return text
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9\s]/g, "")
26
+ .trim()
27
+ .split(/\s+/)
28
+ .slice(0, 4)
29
+ .join("-");
30
+ }
31
+
32
+ const MIN_NEW_MESSAGES = 10;
33
+
34
+ interface CaptureEntry {
35
+ filepath: string;
36
+ messageCount: number;
37
+ }
38
+
39
+ /** Get the previously captured entry for a session, if any */
40
+ function getPreviousCapture(sessionId: string): CaptureEntry | null {
41
+ const markerPath = resolve(paths.state(), "captured-learnings.json");
42
+ if (!existsSync(markerPath)) return null;
43
+ try {
44
+ const raw = JSON.parse(readFileSync(markerPath, "utf-8"));
45
+ if (Array.isArray(raw)) return null;
46
+ const entry = raw[sessionId];
47
+ if (!entry) return null;
48
+ // Migrate old string format
49
+ if (typeof entry === "string") return { filepath: entry, messageCount: 0 };
50
+ return entry as CaptureEntry;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function markCaptured(sessionId: string, filepath: string, messageCount: number): void {
57
+ const markerPath = resolve(paths.state(), "captured-learnings.json");
58
+ let data: Record<string, CaptureEntry> = {};
59
+ try {
60
+ if (existsSync(markerPath)) {
61
+ const raw = JSON.parse(readFileSync(markerPath, "utf-8"));
62
+ if (!Array.isArray(raw) && typeof raw === "object") {
63
+ for (const [k, v] of Object.entries(raw)) {
64
+ if (typeof v === "string") {
65
+ data[k] = { filepath: v, messageCount: 0 };
66
+ } else {
67
+ data[k] = v as CaptureEntry;
68
+ }
69
+ }
70
+ }
71
+ }
72
+ } catch {
73
+ /* start fresh */
74
+ }
75
+ data[sessionId] = { filepath, messageCount };
76
+ // Keep last 50
77
+ const entries = Object.entries(data);
78
+ if (entries.length > 50) {
79
+ data = Object.fromEntries(entries.slice(-50));
80
+ }
81
+ writeFileSync(markerPath, JSON.stringify(data, null, 2), "utf-8");
82
+ }
83
+
84
+ export async function captureWorkLearning(
85
+ transcript: string,
86
+ sessionId?: string
87
+ ): Promise<void> {
88
+ if (transcript.length < 2000) return;
89
+
90
+ const messages = parseMessages(transcript);
91
+ if (messages.length < 6) return;
92
+
93
+ // Skip if not enough new messages since last capture
94
+ if (sessionId) {
95
+ const prev = getPreviousCapture(sessionId);
96
+ if (prev && messages.length - prev.messageCount < MIN_NEW_MESSAGES) return;
97
+ }
98
+
99
+ const lastUser = extractLastUser(messages);
100
+ const lastAssistant = extractLastAssistant(messages);
101
+
102
+ const rawTitle = extractContent(lastUser).slice(0, 80) || "session";
103
+ const rawSummary = extractContent(lastAssistant).slice(0, 600);
104
+
105
+ // Generate title, summary, and insights in a single inference call
106
+ let title = rawTitle;
107
+ let summary = rawSummary;
108
+ let insights = "";
109
+ try {
110
+ const userMessages = messages
111
+ .filter((m) => m.role === "user")
112
+ .map((m) => extractContent(m).slice(0, 100))
113
+ .slice(-8)
114
+ .join("\n");
115
+ const result = await inference({
116
+ system:
117
+ "You summarize AI coding sessions between a human user and an AI assistant. The 'Human messages' are what the user said. The 'AI response' is what the assistant said. Produce: 1) a short title (5-10 words) describing what was accomplished, 2) a summary of what the AI assistant did for the user (2-4 sentences, write from the AI's perspective using 'we'), 3) insights — what worked well, what was surprising, or what should be done differently next time (2-3 bullet points, no markdown).",
118
+ user: `Human messages:\n${userMessages}\n\nAI response:\n${rawSummary.slice(0, 400)}`,
119
+ maxTokens: 250,
120
+ timeout: 8000,
121
+ jsonSchema: {
122
+ type: "object" as const,
123
+ additionalProperties: false,
124
+ properties: {
125
+ title: { type: "string" as const },
126
+ summary: { type: "string" as const },
127
+ insights: { type: "string" as const },
128
+ },
129
+ required: ["title", "summary", "insights"],
130
+ },
131
+ });
132
+ if (result.usage) logTokenUsage("work-learning", result.usage);
133
+ if (result.success && result.output) {
134
+ const parsed = JSON.parse(result.output) as {
135
+ title?: string;
136
+ summary?: string;
137
+ insights?: string;
138
+ };
139
+ if (parsed.title) title = parsed.title.slice(0, 100);
140
+ if (parsed.summary) summary = parsed.summary;
141
+ if (parsed.insights) insights = parsed.insights;
142
+ }
143
+ } catch {
144
+ // Fallback to raw values
145
+ }
146
+ const category = categorizeLearning(title, summary);
147
+
148
+ const slug = slugify(title);
149
+ const dir = ensureDir(resolve(paths.sessionLearning(), monthPath()));
150
+ const filename = `${fileTimestamp()}_${category}_${slug}.md`;
151
+
152
+ const content = [
153
+ "# Work Completion Learning",
154
+ `**Title:** ${title}`,
155
+ `**Category:** ${category.toUpperCase()}`,
156
+ `**Date:** ${new Date().toISOString().slice(0, 10)}`,
157
+ ...(sessionId ? [`**Session:** ${sessionId}`] : []),
158
+ "",
159
+ "## What Was Done",
160
+ summary,
161
+ "",
162
+ "## Insights",
163
+ insights || "*No insights captured.*",
164
+ "",
165
+ ].join("\n");
166
+
167
+ // Remove previous capture for this session (overwrite on continued conversations)
168
+ if (sessionId) {
169
+ const prev = getPreviousCapture(sessionId);
170
+ if (prev?.filepath && existsSync(prev.filepath)) {
171
+ try {
172
+ unlinkSync(prev.filepath);
173
+ } catch {
174
+ /* ignore */
175
+ }
176
+ }
177
+ }
178
+
179
+ const filepath = resolve(dir, filename);
180
+ writeFileSync(filepath, content, "utf-8");
181
+
182
+ if (sessionId) markCaptured(sessionId, filepath, messages.length);
183
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Stop handler: captures structured session records.
3
+ * Replaces the old work.ts handler.
4
+ */
5
+
6
+ import { readSessionNames } from "../lib/session-names";
7
+ import { now } from "../lib/time";
8
+ import {
9
+ extractContent,
10
+ extractLastAssistant,
11
+ extractLastUser,
12
+ parseMessages,
13
+ } from "../lib/transcript";
14
+ import {
15
+ detectStatus,
16
+ extractArtifacts,
17
+ extractHandoff,
18
+ type SessionRecord,
19
+ writeSession,
20
+ } from "../lib/work-tracking";
21
+
22
+ export async function captureWorkSession(
23
+ transcript: string,
24
+ sessionId?: string
25
+ ): Promise<void> {
26
+ try {
27
+ const messages = parseMessages(transcript);
28
+ if (messages.length < 2) return;
29
+
30
+ const id = sessionId || `session-${Date.now()}`;
31
+
32
+ // Look up session name
33
+ const names = readSessionNames();
34
+ const name = names[id] || "untitled session";
35
+
36
+ // Extract content
37
+ const lastUser = extractLastUser(messages);
38
+ const lastAssistant = extractLastAssistant(messages);
39
+ const lastAssistantText = extractContent(lastAssistant);
40
+ const summary = extractContent(lastUser).slice(0, 300);
41
+
42
+ const record: SessionRecord = {
43
+ sessionId: id,
44
+ name,
45
+ ts: now(),
46
+ cwd: process.cwd(),
47
+ status: detectStatus(lastAssistantText),
48
+ summary,
49
+ artifacts: extractArtifacts(messages),
50
+ handoff: extractHandoff(lastAssistantText),
51
+ messageCount: messages.length,
52
+ };
53
+
54
+ writeSession(record);
55
+ } catch {
56
+ // Non-critical
57
+ }
58
+ }