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.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/assets/agents/claude-researcher.md +43 -0
- package/assets/agents/investigative-researcher.md +44 -0
- package/assets/agents/multi-perspective-researcher.md +43 -0
- package/assets/skills/analyze-pdf.md +40 -0
- package/assets/skills/analyze-youtube.md +35 -0
- package/assets/skills/council.md +43 -0
- package/assets/skills/create-skill.md +31 -0
- package/assets/skills/extract-entities.md +63 -0
- package/assets/skills/extract-wisdom.md +18 -0
- package/assets/skills/first-principles.md +17 -0
- package/assets/skills/fyzz-chat-api.md +43 -0
- package/assets/skills/reflect.md +87 -0
- package/assets/skills/research.md +68 -0
- package/assets/skills/review.md +19 -0
- package/assets/skills/summarize.md +15 -0
- package/assets/templates/AGENTS.md.template +45 -0
- package/assets/templates/telos/BELIEFS.md +4 -0
- package/assets/templates/telos/CHALLENGES.md +4 -0
- package/assets/templates/telos/GOALS.md +12 -0
- package/assets/templates/telos/IDEAS.md +4 -0
- package/assets/templates/telos/IDENTITY.md +4 -0
- package/assets/templates/telos/LEARNED.md +4 -0
- package/assets/templates/telos/MISSION.md +4 -0
- package/assets/templates/telos/MODELS.md +4 -0
- package/assets/templates/telos/NARRATIVES.md +4 -0
- package/assets/templates/telos/PROJECTS.md +7 -0
- package/assets/templates/telos/STRATEGIES.md +4 -0
- package/bin/pal +24 -0
- package/bin/pal.bat +8 -0
- package/bin/pal.ps1 +30 -0
- package/package.json +82 -0
- package/src/cli/index.ts +344 -0
- package/src/cli/install.ts +86 -0
- package/src/cli/uninstall.ts +45 -0
- package/src/hooks/LoadContext.ts +41 -0
- package/src/hooks/SecurityValidator.ts +52 -0
- package/src/hooks/SkillGuard.ts +41 -0
- package/src/hooks/StopOrchestrator.ts +35 -0
- package/src/hooks/UserPromptOrchestrator.ts +35 -0
- package/src/hooks/handlers/backup.ts +41 -0
- package/src/hooks/handlers/failure.ts +136 -0
- package/src/hooks/handlers/rating.ts +409 -0
- package/src/hooks/handlers/relationship.ts +113 -0
- package/src/hooks/handlers/session-name.ts +121 -0
- package/src/hooks/handlers/synthesis.ts +109 -0
- package/src/hooks/handlers/tab.ts +8 -0
- package/src/hooks/handlers/update-counts.ts +151 -0
- package/src/hooks/handlers/work-learning.ts +183 -0
- package/src/hooks/handlers/work-session.ts +58 -0
- package/src/hooks/lib/claude-md.ts +121 -0
- package/src/hooks/lib/context.ts +433 -0
- package/src/hooks/lib/entities.ts +304 -0
- package/src/hooks/lib/export.ts +76 -0
- package/src/hooks/lib/inference.ts +91 -0
- package/src/hooks/lib/learning-category.ts +14 -0
- package/src/hooks/lib/log.ts +53 -0
- package/src/hooks/lib/models.ts +16 -0
- package/src/hooks/lib/paths.ts +80 -0
- package/src/hooks/lib/relationship.ts +135 -0
- package/src/hooks/lib/security.ts +122 -0
- package/src/hooks/lib/session-names.ts +247 -0
- package/src/hooks/lib/setup.ts +189 -0
- package/src/hooks/lib/signal-trends.ts +117 -0
- package/src/hooks/lib/signals.ts +37 -0
- package/src/hooks/lib/stdin.ts +18 -0
- package/src/hooks/lib/stop.ts +155 -0
- package/src/hooks/lib/time.ts +19 -0
- package/src/hooks/lib/token-usage.ts +42 -0
- package/src/hooks/lib/transcript.ts +76 -0
- package/src/hooks/lib/wisdom.ts +48 -0
- package/src/hooks/lib/work-tracking.ts +193 -0
- package/src/hooks/setup-check.ts +42 -0
- package/src/targets/claude/install.ts +145 -0
- package/src/targets/claude/uninstall.ts +101 -0
- package/src/targets/lib.ts +337 -0
- package/src/targets/opencode/install.ts +59 -0
- package/src/targets/opencode/plugin.ts +328 -0
- package/src/targets/opencode/uninstall.ts +57 -0
- package/src/tools/entity-save.ts +110 -0
- package/src/tools/export.ts +34 -0
- package/src/tools/fyzz-api.ts +104 -0
- package/src/tools/import.ts +123 -0
- package/src/tools/pattern-synthesis.ts +435 -0
- package/src/tools/pdf-download.ts +102 -0
- package/src/tools/relationship-reflect.ts +362 -0
- package/src/tools/session-summary.ts +206 -0
- package/src/tools/token-cost.ts +301 -0
- 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,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
|
+
}
|