@tekmidian/pai 0.7.1 → 0.7.3
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/dist/cli/index.mjs +1 -1
- package/dist/daemon/index.mjs +1 -1
- package/dist/{daemon-DJoesjez.mjs → daemon-D8ZxcFhU.mjs} +615 -5
- package/dist/daemon-D8ZxcFhU.mjs.map +1 -0
- package/dist/hooks/context-compression-hook.mjs +140 -22
- package/dist/hooks/context-compression-hook.mjs.map +2 -2
- package/dist/hooks/load-project-context.mjs +74 -4
- package/dist/hooks/load-project-context.mjs.map +3 -3
- package/dist/hooks/stop-hook.mjs +70 -1
- package/dist/hooks/stop-hook.mjs.map +3 -3
- package/dist/hooks/sync-todo-to-md.mjs.map +1 -1
- package/dist/skills/Reconstruct/SKILL.md +232 -0
- package/package.json +1 -1
- package/plugins/productivity/plugin.json +1 -1
- package/plugins/productivity/skills/Reconstruct/SKILL.md +232 -0
- package/src/hooks/ts/lib/project-utils/index.ts +1 -0
- package/src/hooks/ts/lib/project-utils/session-notes.ts +24 -2
- package/src/hooks/ts/lib/project-utils.ts +1 -0
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +159 -37
- package/src/hooks/ts/session-start/load-project-context.ts +101 -3
- package/src/hooks/ts/stop/stop-hook.ts +66 -0
- package/dist/daemon-DJoesjez.mjs.map +0 -1
package/dist/cli/index.mjs
CHANGED
|
@@ -3793,7 +3793,7 @@ function cmdLogs(opts) {
|
|
|
3793
3793
|
}
|
|
3794
3794
|
function registerDaemonCommands(daemonCmd) {
|
|
3795
3795
|
daemonCmd.command("serve").description("Start the PAI daemon in the foreground").action(async () => {
|
|
3796
|
-
const { serve } = await import("../daemon-
|
|
3796
|
+
const { serve } = await import("../daemon-D8ZxcFhU.mjs").then((n) => n.t);
|
|
3797
3797
|
const { loadConfig: lc, ensureConfigDir } = await import("../config-BuhHWyOK.mjs").then((n) => n.r);
|
|
3798
3798
|
ensureConfigDir();
|
|
3799
3799
|
await serve(lc());
|
package/dist/daemon/index.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import "../indexer-D53l5d1U.mjs";
|
|
|
8
8
|
import { t as PaiClient } from "../ipc-client-CoyUHPod.mjs";
|
|
9
9
|
import { i as ensureConfigDir, o as loadConfig } from "../config-BuhHWyOK.mjs";
|
|
10
10
|
import "../factory-Ygqe_bVZ.mjs";
|
|
11
|
-
import { n as serve } from "../daemon-
|
|
11
|
+
import { n as serve } from "../daemon-D8ZxcFhU.mjs";
|
|
12
12
|
import "../state-C6_vqz7w.mjs";
|
|
13
13
|
import "../tools-DcaJlYDN.mjs";
|
|
14
14
|
import "../detector-jGBuYQJM.mjs";
|
|
@@ -8,7 +8,7 @@ import { t as createStorageBackend } from "./factory-Ygqe_bVZ.mjs";
|
|
|
8
8
|
import { C as setStorageBackend, E as startTime, O as storageBackend, S as setStartTime, T as shutdownRequested, _ as setLastIndexTime, a as indexSchedulerTimer, b as setRegistryDb, c as lastVaultIndexTime, d as setDaemonConfig, f as setEmbedInProgress, g as setLastEmbedTime, h as setIndexSchedulerTimer, i as indexInProgress, k as vaultIndexInProgress, l as notificationConfig, m as setIndexInProgress, n as embedInProgress, o as lastEmbedTime, p as setEmbedSchedulerTimer, r as embedSchedulerTimer, s as lastIndexTime, t as daemonConfig, u as registryDb, v as setLastVaultIndexTime, w as setVaultIndexInProgress, x as setShutdownRequested, y as setNotificationConfig } from "./state-C6_vqz7w.mjs";
|
|
9
9
|
import { a as toolProjectDetect, c as toolProjectList, d as toolMemorySearch, i as toolSessionRoute, l as toolProjectTodo, n as toolRegistrySearch, o as toolProjectHealth, r as toolSessionList, s as toolProjectInfo, u as toolMemoryGet } from "./tools-DcaJlYDN.mjs";
|
|
10
10
|
import { t as detectTopicShift } from "./detector-jGBuYQJM.mjs";
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { homedir, setPriority } from "node:os";
|
|
13
13
|
import { basename, dirname, join } from "node:path";
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
@@ -1117,6 +1117,30 @@ function findTodoPath(cwd) {
|
|
|
1117
1117
|
/**
|
|
1118
1118
|
* Session note creation, editing, checkpointing, renaming, and finalization.
|
|
1119
1119
|
*/
|
|
1120
|
+
/** Get or create the YYYY/MM subdirectory for the current month inside notesDir. */
|
|
1121
|
+
function getMonthDir(notesDir) {
|
|
1122
|
+
const now = /* @__PURE__ */ new Date();
|
|
1123
|
+
const monthDir = join$1(notesDir, String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, "0"));
|
|
1124
|
+
if (!existsSync$1(monthDir)) mkdirSync$1(monthDir, { recursive: true });
|
|
1125
|
+
return monthDir;
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Get the next note number (4-digit format: 0001, 0002, etc.).
|
|
1129
|
+
* Numbers are scoped per YYYY/MM directory.
|
|
1130
|
+
*/
|
|
1131
|
+
function getNextNoteNumber(notesDir) {
|
|
1132
|
+
const files = readdirSync$1(getMonthDir(notesDir)).filter((f) => f.match(/^\d{3,4}[\s_-]/)).sort();
|
|
1133
|
+
if (files.length === 0) return "0001";
|
|
1134
|
+
let maxNumber = 0;
|
|
1135
|
+
for (const file of files) {
|
|
1136
|
+
const digitMatch = file.match(/^(\d+)/);
|
|
1137
|
+
if (digitMatch) {
|
|
1138
|
+
const num = parseInt(digitMatch[1], 10);
|
|
1139
|
+
if (num > maxNumber) maxNumber = num;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
return String(maxNumber + 1).padStart(4, "0");
|
|
1143
|
+
}
|
|
1120
1144
|
/**
|
|
1121
1145
|
* Get the current (latest) note file path, or null if none exists.
|
|
1122
1146
|
* Searches current month → previous month → flat notesDir (legacy).
|
|
@@ -1139,6 +1163,41 @@ function getCurrentNotePath(notesDir) {
|
|
|
1139
1163
|
if (prevFound) return prevFound;
|
|
1140
1164
|
return findLatestIn(notesDir);
|
|
1141
1165
|
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Create a new session note.
|
|
1168
|
+
* Format: "NNNN - YYYY-MM-DD - New Session.md" filed into YYYY/MM subdirectory.
|
|
1169
|
+
* Claude MUST rename at session end with a meaningful description.
|
|
1170
|
+
*/
|
|
1171
|
+
function createSessionNote(notesDir, description) {
|
|
1172
|
+
const noteNumber = getNextNoteNumber(notesDir);
|
|
1173
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1174
|
+
const monthDir = getMonthDir(notesDir);
|
|
1175
|
+
const filename = `${noteNumber} - ${date} - New Session.md`;
|
|
1176
|
+
const filepath = join$1(monthDir, filename);
|
|
1177
|
+
writeFileSync$1(filepath, `# Session ${noteNumber}: ${description}
|
|
1178
|
+
|
|
1179
|
+
**Date:** ${date}
|
|
1180
|
+
**Status:** In Progress
|
|
1181
|
+
|
|
1182
|
+
---
|
|
1183
|
+
|
|
1184
|
+
## Work Done
|
|
1185
|
+
|
|
1186
|
+
<!-- PAI will add completed work here during session -->
|
|
1187
|
+
|
|
1188
|
+
---
|
|
1189
|
+
|
|
1190
|
+
## Next Steps
|
|
1191
|
+
|
|
1192
|
+
<!-- To be filled at session end -->
|
|
1193
|
+
|
|
1194
|
+
---
|
|
1195
|
+
|
|
1196
|
+
**Tags:** #Session
|
|
1197
|
+
`);
|
|
1198
|
+
console.error(`Created session note: ${filename}`);
|
|
1199
|
+
return filepath;
|
|
1200
|
+
}
|
|
1142
1201
|
/** Add work items to the "Work Done" section of a session note. */
|
|
1143
1202
|
function addWorkToSessionNote(notePath, workItems, sectionTitle) {
|
|
1144
1203
|
if (!existsSync$1(notePath)) {
|
|
@@ -1170,16 +1229,30 @@ function sanitizeForFilename(str) {
|
|
|
1170
1229
|
}
|
|
1171
1230
|
/**
|
|
1172
1231
|
* Return true if the candidate string should be rejected as a meaningful name.
|
|
1173
|
-
* Rejects file paths, shebangs, timestamps,
|
|
1232
|
+
* Rejects file paths, shebangs, timestamps, system noise, XML tags, hashes, etc.
|
|
1174
1233
|
*/
|
|
1175
1234
|
function isMeaninglessCandidate(text) {
|
|
1176
1235
|
const t = text.trim();
|
|
1177
1236
|
if (!t) return true;
|
|
1178
|
-
if (t.
|
|
1237
|
+
if (t.length < 5) return true;
|
|
1238
|
+
if (t.startsWith("/") || t.startsWith("~")) return true;
|
|
1179
1239
|
if (t.startsWith("#!")) return true;
|
|
1180
1240
|
if (t.includes("[object Object]")) return true;
|
|
1181
1241
|
if (/^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/.test(t)) return true;
|
|
1182
1242
|
if (/^\d{1,2}:\d{2}(:\d{2})?(\s*(AM|PM))?$/i.test(t)) return true;
|
|
1243
|
+
if (/^<[a-z-]+[\s/>]/i.test(t)) return true;
|
|
1244
|
+
if (/^[0-9a-f]{10,}$/i.test(t)) return true;
|
|
1245
|
+
if (/^Exit code \d+/i.test(t)) return true;
|
|
1246
|
+
if (/^Error:/i.test(t)) return true;
|
|
1247
|
+
if (/^This session is being continued/i.test(t)) return true;
|
|
1248
|
+
if (/^\(Bash completed/i.test(t)) return true;
|
|
1249
|
+
if (/^Task Notification$/i.test(t)) return true;
|
|
1250
|
+
if (/^New Session$/i.test(t)) return true;
|
|
1251
|
+
if (/^Recovered Session$/i.test(t)) return true;
|
|
1252
|
+
if (/^Continued Session$/i.test(t)) return true;
|
|
1253
|
+
if (/^Untitled Session$/i.test(t)) return true;
|
|
1254
|
+
if (/^Context Compression$/i.test(t)) return true;
|
|
1255
|
+
if (/^[A-Fa-f0-9]{8,}\s+Output$/i.test(t)) return true;
|
|
1183
1256
|
return false;
|
|
1184
1257
|
}
|
|
1185
1258
|
/**
|
|
@@ -1328,6 +1401,540 @@ ${stateLines}
|
|
|
1328
1401
|
console.error("TODO.md ## Continue section updated");
|
|
1329
1402
|
}
|
|
1330
1403
|
|
|
1404
|
+
//#endregion
|
|
1405
|
+
//#region src/daemon/templates/session-summary-prompt.ts
|
|
1406
|
+
/**
|
|
1407
|
+
* Build the prompt string to send to Haiku for session summarization.
|
|
1408
|
+
*
|
|
1409
|
+
* Returns a single string suitable for piping to `claude --model haiku --print`.
|
|
1410
|
+
*/
|
|
1411
|
+
function buildSessionSummaryPrompt(params) {
|
|
1412
|
+
const { userMessages, gitLog, cwd, date, filesModified, existingNote } = params;
|
|
1413
|
+
const userSection = userMessages.length > 0 ? userMessages.map((m, i) => `[${i + 1}] ${m}`).join("\n\n") : "(No user messages extracted)";
|
|
1414
|
+
const gitSection = gitLog.trim() || "(No git commits during this session)";
|
|
1415
|
+
const filesSection = filesModified && filesModified.length > 0 ? filesModified.map((f) => `- ${f}`).join("\n") : "";
|
|
1416
|
+
return `You are summarizing a coding session. Given the user messages and git commits below, write a session note.
|
|
1417
|
+
|
|
1418
|
+
Project directory: ${cwd}
|
|
1419
|
+
Date: ${date}
|
|
1420
|
+
|
|
1421
|
+
Focus on:
|
|
1422
|
+
- What problems were encountered and how they were solved
|
|
1423
|
+
- Key architectural decisions and their rationale
|
|
1424
|
+
- What was built (reference actual files and code patterns)
|
|
1425
|
+
- What was left unfinished or needs follow-up
|
|
1426
|
+
|
|
1427
|
+
Do NOT include:
|
|
1428
|
+
- Mechanical metadata (token counts, checkpoint timestamps)
|
|
1429
|
+
- System messages or tool results verbatim
|
|
1430
|
+
- Generic descriptions — be specific about what happened
|
|
1431
|
+
- Markdown frontmatter or YAML headers
|
|
1432
|
+
${existingNote ? `\nAn existing session note is provided below. Merge the new information into it,
|
|
1433
|
+
preserving what was already written. Add new work items and update the summary.
|
|
1434
|
+
Do NOT duplicate existing content.
|
|
1435
|
+
|
|
1436
|
+
EXISTING NOTE:
|
|
1437
|
+
${existingNote}
|
|
1438
|
+
` : ""}
|
|
1439
|
+
Format your response EXACTLY as follows (no extra text before or after):
|
|
1440
|
+
|
|
1441
|
+
# Session: [Descriptive Title - 5-8 words summarizing the main accomplishment]
|
|
1442
|
+
|
|
1443
|
+
**Date:** ${date}
|
|
1444
|
+
**Status:** In Progress
|
|
1445
|
+
|
|
1446
|
+
---
|
|
1447
|
+
|
|
1448
|
+
## Work Done
|
|
1449
|
+
|
|
1450
|
+
[Organize by theme, not chronologically. Group related work under descriptive bullet points.
|
|
1451
|
+
Use checkbox format: - [x] for completed items, - [ ] for incomplete items.
|
|
1452
|
+
Include specific file names, function names, and technical details.]
|
|
1453
|
+
|
|
1454
|
+
## Key Decisions
|
|
1455
|
+
|
|
1456
|
+
[List important choices made during the session with brief rationale.
|
|
1457
|
+
Skip this section entirely if no significant decisions were made.]
|
|
1458
|
+
|
|
1459
|
+
## Known Issues
|
|
1460
|
+
|
|
1461
|
+
[What was left unfinished, bugs discovered, or follow-up items needed.
|
|
1462
|
+
Skip this section entirely if nothing is pending.]
|
|
1463
|
+
|
|
1464
|
+
---
|
|
1465
|
+
|
|
1466
|
+
USER MESSAGES:
|
|
1467
|
+
${userSection}
|
|
1468
|
+
|
|
1469
|
+
GIT COMMITS:
|
|
1470
|
+
${gitSection}
|
|
1471
|
+
${filesSection ? `\nFILES MODIFIED:\n${filesSection}` : ""}`;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
//#endregion
|
|
1475
|
+
//#region src/daemon/session-summary-worker.ts
|
|
1476
|
+
/**
|
|
1477
|
+
* session-summary-worker.ts — AI-powered session note generation
|
|
1478
|
+
*
|
|
1479
|
+
* Processes `session-summary` work items by:
|
|
1480
|
+
* 1. Finding the current session's JSONL transcript
|
|
1481
|
+
* 2. Extracting user messages and assistant context
|
|
1482
|
+
* 3. Gathering git commits from the session period
|
|
1483
|
+
* 4. Spawning Haiku via `claude` CLI to generate a structured summary
|
|
1484
|
+
* 5. Writing the summary to the project's session note
|
|
1485
|
+
*
|
|
1486
|
+
* Designed to run inside the daemon's work queue worker. All errors are
|
|
1487
|
+
* thrown (not swallowed) so the work queue retry logic handles them.
|
|
1488
|
+
*/
|
|
1489
|
+
/** Minimum interval between summaries for the same project (ms). */
|
|
1490
|
+
const SUMMARY_COOLDOWN_MS = 1800 * 1e3;
|
|
1491
|
+
/** Maximum JSONL content to feed to Haiku (characters). */
|
|
1492
|
+
const MAX_JSONL_CHARS = 5e4;
|
|
1493
|
+
/** Maximum user messages to include in the prompt. */
|
|
1494
|
+
const MAX_USER_MESSAGES = 30;
|
|
1495
|
+
/** Timeout for the claude CLI process (ms). */
|
|
1496
|
+
const CLAUDE_TIMEOUT_MS = 6e4;
|
|
1497
|
+
/** File tracking last summary timestamps per project. */
|
|
1498
|
+
const COOLDOWN_FILE = join(homedir(), ".config", "pai", "summary-cooldowns.json");
|
|
1499
|
+
/** Claude Code projects directory. */
|
|
1500
|
+
const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
1501
|
+
function loadCooldowns() {
|
|
1502
|
+
try {
|
|
1503
|
+
if (existsSync(COOLDOWN_FILE)) return JSON.parse(readFileSync(COOLDOWN_FILE, "utf-8"));
|
|
1504
|
+
} catch {}
|
|
1505
|
+
return {};
|
|
1506
|
+
}
|
|
1507
|
+
function saveCooldowns(cooldowns) {
|
|
1508
|
+
try {
|
|
1509
|
+
writeFileSync(COOLDOWN_FILE, JSON.stringify(cooldowns, null, 2), "utf-8");
|
|
1510
|
+
} catch {}
|
|
1511
|
+
}
|
|
1512
|
+
function isOnCooldown(cwd) {
|
|
1513
|
+
const lastRun = loadCooldowns()[cwd];
|
|
1514
|
+
if (!lastRun) return false;
|
|
1515
|
+
return Date.now() - lastRun < SUMMARY_COOLDOWN_MS;
|
|
1516
|
+
}
|
|
1517
|
+
function markCooldown(cwd) {
|
|
1518
|
+
const cooldowns = loadCooldowns();
|
|
1519
|
+
cooldowns[cwd] = Date.now();
|
|
1520
|
+
const cutoff = Date.now() - 1440 * 60 * 1e3;
|
|
1521
|
+
for (const key of Object.keys(cooldowns)) if (cooldowns[key] < cutoff) delete cooldowns[key];
|
|
1522
|
+
saveCooldowns(cooldowns);
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Encode a cwd path the same way Claude Code does for its project directories.
|
|
1526
|
+
* Replaces /, space, dot, and hyphen with -.
|
|
1527
|
+
*/
|
|
1528
|
+
function encodeProjectPath(cwd) {
|
|
1529
|
+
return cwd.replace(/[\/\s.\-]/g, "-");
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Find the most recently modified JSONL file for the given project.
|
|
1533
|
+
*
|
|
1534
|
+
* Claude Code stores transcripts in:
|
|
1535
|
+
* ~/.claude/projects/<encoded-path>/sessions/*.jsonl
|
|
1536
|
+
* ~/.claude/projects/<encoded-path>/<uuid>.jsonl (legacy)
|
|
1537
|
+
*/
|
|
1538
|
+
function findLatestJsonl(cwd) {
|
|
1539
|
+
const projectDir = join(CLAUDE_PROJECTS_DIR, encodeProjectPath(cwd));
|
|
1540
|
+
if (!existsSync(projectDir)) {
|
|
1541
|
+
process.stderr.write(`[session-summary] No Claude project dir found: ${projectDir}\n`);
|
|
1542
|
+
return null;
|
|
1543
|
+
}
|
|
1544
|
+
const candidates = [];
|
|
1545
|
+
const sessionsDir = join(projectDir, "sessions");
|
|
1546
|
+
if (existsSync(sessionsDir)) try {
|
|
1547
|
+
for (const f of readdirSync(sessionsDir)) {
|
|
1548
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
1549
|
+
const fullPath = join(sessionsDir, f);
|
|
1550
|
+
try {
|
|
1551
|
+
const st = statSync(fullPath);
|
|
1552
|
+
candidates.push({
|
|
1553
|
+
path: fullPath,
|
|
1554
|
+
mtime: st.mtimeMs
|
|
1555
|
+
});
|
|
1556
|
+
} catch {}
|
|
1557
|
+
}
|
|
1558
|
+
} catch {}
|
|
1559
|
+
try {
|
|
1560
|
+
for (const f of readdirSync(projectDir)) {
|
|
1561
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
1562
|
+
const fullPath = join(projectDir, f);
|
|
1563
|
+
try {
|
|
1564
|
+
const st = statSync(fullPath);
|
|
1565
|
+
candidates.push({
|
|
1566
|
+
path: fullPath,
|
|
1567
|
+
mtime: st.mtimeMs
|
|
1568
|
+
});
|
|
1569
|
+
} catch {}
|
|
1570
|
+
}
|
|
1571
|
+
} catch {}
|
|
1572
|
+
if (candidates.length === 0) {
|
|
1573
|
+
process.stderr.write(`[session-summary] No JSONL files found in ${projectDir}\n`);
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1576
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
1577
|
+
return candidates[0].path;
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* Parse a JSONL transcript and extract relevant content.
|
|
1581
|
+
* Filters noise, truncates to MAX_JSONL_CHARS from the end of the file.
|
|
1582
|
+
*/
|
|
1583
|
+
function extractFromJsonl(jsonlPath) {
|
|
1584
|
+
const result = {
|
|
1585
|
+
userMessages: [],
|
|
1586
|
+
filesModified: [],
|
|
1587
|
+
sessionStartTime: ""
|
|
1588
|
+
};
|
|
1589
|
+
let raw;
|
|
1590
|
+
try {
|
|
1591
|
+
raw = readFileSync(jsonlPath, "utf-8");
|
|
1592
|
+
} catch (e) {
|
|
1593
|
+
throw new Error(`Could not read JSONL at ${jsonlPath}: ${e}`);
|
|
1594
|
+
}
|
|
1595
|
+
if (raw.length > MAX_JSONL_CHARS) {
|
|
1596
|
+
const truncPoint = raw.indexOf("\n", raw.length - MAX_JSONL_CHARS);
|
|
1597
|
+
raw = truncPoint >= 0 ? raw.slice(truncPoint + 1) : raw.slice(-MAX_JSONL_CHARS);
|
|
1598
|
+
}
|
|
1599
|
+
const lines = raw.trim().split("\n");
|
|
1600
|
+
const seenMessages = /* @__PURE__ */ new Set();
|
|
1601
|
+
for (const line of lines) {
|
|
1602
|
+
if (!line.trim()) continue;
|
|
1603
|
+
let entry;
|
|
1604
|
+
try {
|
|
1605
|
+
entry = JSON.parse(line);
|
|
1606
|
+
} catch {
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
1609
|
+
if (entry.timestamp && !result.sessionStartTime) result.sessionStartTime = String(entry.timestamp);
|
|
1610
|
+
if (entry.type === "user") {
|
|
1611
|
+
const msg = entry.message;
|
|
1612
|
+
if (msg?.content) {
|
|
1613
|
+
const text = contentToText$1(msg.content);
|
|
1614
|
+
if (text && !isNoise(text) && !seenMessages.has(text)) {
|
|
1615
|
+
seenMessages.add(text);
|
|
1616
|
+
result.userMessages.push(text.slice(0, 500));
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
if (entry.type === "assistant") {
|
|
1621
|
+
const msg = entry.message;
|
|
1622
|
+
if (msg?.content && Array.isArray(msg.content)) {
|
|
1623
|
+
for (const block of msg.content) if (block.type === "tool_use") {
|
|
1624
|
+
const name = block.name;
|
|
1625
|
+
const input = block.input;
|
|
1626
|
+
if ((name === "Edit" || name === "Write") && input?.file_path) {
|
|
1627
|
+
const fp = String(input.file_path);
|
|
1628
|
+
if (!result.filesModified.includes(fp)) result.filesModified.push(fp);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
if (result.userMessages.length > MAX_USER_MESSAGES) result.userMessages = result.userMessages.slice(-MAX_USER_MESSAGES);
|
|
1635
|
+
return result;
|
|
1636
|
+
}
|
|
1637
|
+
/** Convert Claude content (string or content block array) to plain text. */
|
|
1638
|
+
function contentToText$1(content) {
|
|
1639
|
+
if (typeof content === "string") return content;
|
|
1640
|
+
if (Array.isArray(content)) return content.map((c) => {
|
|
1641
|
+
if (typeof c === "string") return c;
|
|
1642
|
+
const block = c;
|
|
1643
|
+
if (block?.text) return String(block.text);
|
|
1644
|
+
if (block?.content) return String(block.content);
|
|
1645
|
+
return "";
|
|
1646
|
+
}).join(" ").trim();
|
|
1647
|
+
return "";
|
|
1648
|
+
}
|
|
1649
|
+
/** Filter out noise entries that shouldn't be included in the summary. */
|
|
1650
|
+
function isNoise(text) {
|
|
1651
|
+
if (!text || text.length < 3) return true;
|
|
1652
|
+
if (text.includes("<task-notification>")) return true;
|
|
1653
|
+
if (text.includes("[object Object]")) return true;
|
|
1654
|
+
if (text.startsWith("<system-reminder>")) return true;
|
|
1655
|
+
if (/^(yes|ok|sure|go|continue|weiter|thanks|thank you)\.?$/i.test(text.trim())) return true;
|
|
1656
|
+
if (text.startsWith("Tool Result:") || text.startsWith("tool_result")) return true;
|
|
1657
|
+
return false;
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* Get git log for the session period.
|
|
1661
|
+
* Falls back gracefully if git is not available or the dir is not a repo.
|
|
1662
|
+
*/
|
|
1663
|
+
async function getGitContext(cwd, sinceTime) {
|
|
1664
|
+
let since = "6 hours ago";
|
|
1665
|
+
if (sinceTime) {
|
|
1666
|
+
const asNum = Number(sinceTime);
|
|
1667
|
+
if (!isNaN(asNum) && asNum > 1e9) since = (/* @__PURE__ */ new Date(asNum * 1e3)).toISOString();
|
|
1668
|
+
else since = sinceTime;
|
|
1669
|
+
}
|
|
1670
|
+
try {
|
|
1671
|
+
const { execFile: execFileCb } = await import("node:child_process");
|
|
1672
|
+
const { promisify } = await import("node:util");
|
|
1673
|
+
const { stdout } = await promisify(execFileCb)("git", [
|
|
1674
|
+
"log",
|
|
1675
|
+
"--format=%h %ai %s",
|
|
1676
|
+
`--since=${since}`,
|
|
1677
|
+
"--stat",
|
|
1678
|
+
"--no-color"
|
|
1679
|
+
], {
|
|
1680
|
+
cwd,
|
|
1681
|
+
timeout: 1e4,
|
|
1682
|
+
env: {
|
|
1683
|
+
...process.env,
|
|
1684
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
1685
|
+
}
|
|
1686
|
+
});
|
|
1687
|
+
return stdout.trim();
|
|
1688
|
+
} catch {
|
|
1689
|
+
return "";
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* Find the `claude` CLI binary.
|
|
1694
|
+
* Checks PATH first, then common installation locations.
|
|
1695
|
+
*/
|
|
1696
|
+
function findClaudeBinary() {
|
|
1697
|
+
const candidates = [
|
|
1698
|
+
"claude",
|
|
1699
|
+
join(homedir(), ".claude", "local", "claude"),
|
|
1700
|
+
"/usr/local/bin/claude",
|
|
1701
|
+
join(homedir(), ".local", "bin", "claude")
|
|
1702
|
+
];
|
|
1703
|
+
for (const candidate of candidates) try {
|
|
1704
|
+
if (candidate === "claude") return candidate;
|
|
1705
|
+
if (existsSync(candidate)) return candidate;
|
|
1706
|
+
} catch {}
|
|
1707
|
+
return null;
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Spawn Haiku via the claude CLI to generate a session summary.
|
|
1711
|
+
* Pipes the prompt via stdin to `claude --model haiku --print --no-input`.
|
|
1712
|
+
* Returns the generated text, or null if spawning fails.
|
|
1713
|
+
*/
|
|
1714
|
+
async function spawnHaikuSummarizer(prompt) {
|
|
1715
|
+
const claudeBin = findClaudeBinary();
|
|
1716
|
+
if (!claudeBin) {
|
|
1717
|
+
process.stderr.write("[session-summary] Claude CLI not found in PATH or common locations.\n");
|
|
1718
|
+
return null;
|
|
1719
|
+
}
|
|
1720
|
+
const { spawn } = await import("node:child_process");
|
|
1721
|
+
return new Promise((resolve) => {
|
|
1722
|
+
let timer = null;
|
|
1723
|
+
const child = spawn(claudeBin, [
|
|
1724
|
+
"--model",
|
|
1725
|
+
"haiku",
|
|
1726
|
+
"-p",
|
|
1727
|
+
"--no-session-persistence"
|
|
1728
|
+
], {
|
|
1729
|
+
env: { ...process.env },
|
|
1730
|
+
stdio: [
|
|
1731
|
+
"pipe",
|
|
1732
|
+
"pipe",
|
|
1733
|
+
"pipe"
|
|
1734
|
+
]
|
|
1735
|
+
});
|
|
1736
|
+
let stdout = "";
|
|
1737
|
+
let stderr = "";
|
|
1738
|
+
child.stdout.on("data", (chunk) => {
|
|
1739
|
+
stdout += chunk.toString();
|
|
1740
|
+
});
|
|
1741
|
+
child.stderr.on("data", (chunk) => {
|
|
1742
|
+
stderr += chunk.toString();
|
|
1743
|
+
});
|
|
1744
|
+
child.on("error", (err) => {
|
|
1745
|
+
if (timer) {
|
|
1746
|
+
clearTimeout(timer);
|
|
1747
|
+
timer = null;
|
|
1748
|
+
}
|
|
1749
|
+
process.stderr.write(`[session-summary] Haiku spawn error: ${err.message}\n`);
|
|
1750
|
+
resolve(null);
|
|
1751
|
+
});
|
|
1752
|
+
child.on("close", (code) => {
|
|
1753
|
+
if (timer) {
|
|
1754
|
+
clearTimeout(timer);
|
|
1755
|
+
timer = null;
|
|
1756
|
+
}
|
|
1757
|
+
if (code !== 0) {
|
|
1758
|
+
process.stderr.write(`[session-summary] Haiku exited with code ${code}: ${stderr.slice(0, 300)}\n`);
|
|
1759
|
+
resolve(null);
|
|
1760
|
+
} else resolve(stdout.trim() || null);
|
|
1761
|
+
});
|
|
1762
|
+
timer = setTimeout(() => {
|
|
1763
|
+
process.stderr.write("[session-summary] Haiku timed out — killing process.\n");
|
|
1764
|
+
child.kill("SIGTERM");
|
|
1765
|
+
resolve(null);
|
|
1766
|
+
}, CLAUDE_TIMEOUT_MS);
|
|
1767
|
+
child.stdin.write(prompt);
|
|
1768
|
+
child.stdin.end();
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Write (or update) the session note with the AI-generated summary.
|
|
1773
|
+
*
|
|
1774
|
+
* Strategy:
|
|
1775
|
+
* - Find the current month's latest note
|
|
1776
|
+
* - If it's from today, update it with the new summary
|
|
1777
|
+
* - If it's from a different day, create a new note
|
|
1778
|
+
*/
|
|
1779
|
+
function writeSessionNote(cwd, summaryText, filesModified) {
|
|
1780
|
+
const notesInfo = findNotesDir(cwd);
|
|
1781
|
+
let notePath = getCurrentNotePath(notesInfo.path);
|
|
1782
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1783
|
+
if (notePath) {
|
|
1784
|
+
const noteFilename = basename(notePath);
|
|
1785
|
+
const dateMatch = noteFilename.match(/(\d{4}-\d{2}-\d{2})/);
|
|
1786
|
+
if ((dateMatch ? dateMatch[1] : "") === today) {
|
|
1787
|
+
updateNoteWithSummary(notePath, summaryText);
|
|
1788
|
+
process.stderr.write(`[session-summary] Updated existing note: ${noteFilename}\n`);
|
|
1789
|
+
} else notePath = createNoteFromSummary(notesInfo.path, summaryText);
|
|
1790
|
+
} else notePath = createNoteFromSummary(notesInfo.path, summaryText);
|
|
1791
|
+
if (notePath) {
|
|
1792
|
+
const titleMatch = summaryText.match(/^# Session:\s*(.+)$/m);
|
|
1793
|
+
if (titleMatch) {
|
|
1794
|
+
const title = titleMatch[1].trim();
|
|
1795
|
+
if (title.length > 5 && title.length < 80) {
|
|
1796
|
+
const newPath = renameSessionNote(notePath, title);
|
|
1797
|
+
if (newPath !== notePath) notePath = newPath;
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
return notePath;
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* Update an existing session note's Work Done section with AI-generated content.
|
|
1805
|
+
*/
|
|
1806
|
+
function updateNoteWithSummary(notePath, summaryText) {
|
|
1807
|
+
if (!existsSync(notePath)) return;
|
|
1808
|
+
let content = readFileSync(notePath, "utf-8");
|
|
1809
|
+
const workDoneMatch = summaryText.match(/## Work Done\n\n([\s\S]*?)(?=\n## Key Decisions|\n## Known Issues|\n\*\*Tags|\n$)/);
|
|
1810
|
+
if (workDoneMatch) {
|
|
1811
|
+
const aiWorkContent = workDoneMatch[1].trim();
|
|
1812
|
+
const sectionHeader = `\n### AI Summary (${(/* @__PURE__ */ new Date()).toISOString().split("T")[1].split(".")[0]})\n\n${aiWorkContent}\n`;
|
|
1813
|
+
const nextStepsIdx = content.indexOf("## Next Steps");
|
|
1814
|
+
const knownIssuesIdx = content.indexOf("## Known Issues");
|
|
1815
|
+
const insertBefore = knownIssuesIdx !== -1 ? knownIssuesIdx : nextStepsIdx !== -1 ? nextStepsIdx : content.length;
|
|
1816
|
+
content = content.slice(0, insertBefore) + sectionHeader + "\n" + content.slice(insertBefore);
|
|
1817
|
+
}
|
|
1818
|
+
const decisionsMatch = summaryText.match(/## Key Decisions\n\n([\s\S]*?)(?=\n## Known Issues|\n\*\*Tags|\n$)/);
|
|
1819
|
+
if (decisionsMatch) {
|
|
1820
|
+
const decisions = decisionsMatch[1].trim();
|
|
1821
|
+
if (decisions && !content.includes("## Key Decisions")) {
|
|
1822
|
+
const nextStepsIdx = content.indexOf("## Next Steps");
|
|
1823
|
+
const insertAt = nextStepsIdx !== -1 ? nextStepsIdx : content.length;
|
|
1824
|
+
content = content.slice(0, insertAt) + `## Key Decisions\n\n${decisions}\n\n` + content.slice(insertAt);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
const issuesMatch = summaryText.match(/## Known Issues\n\n([\s\S]*?)(?=\n\*\*Tags|\n$)/);
|
|
1828
|
+
if (issuesMatch) {
|
|
1829
|
+
const issues = issuesMatch[1].trim();
|
|
1830
|
+
if (issues && !content.includes("## Known Issues")) {
|
|
1831
|
+
const nextStepsIdx = content.indexOf("## Next Steps");
|
|
1832
|
+
const insertAt = nextStepsIdx !== -1 ? nextStepsIdx : content.length;
|
|
1833
|
+
content = content.slice(0, insertAt) + `## Known Issues\n\n${issues}\n\n` + content.slice(insertAt);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
writeFileSync(notePath, content, "utf-8");
|
|
1837
|
+
}
|
|
1838
|
+
/**
|
|
1839
|
+
* Create a brand new session note from the AI summary.
|
|
1840
|
+
*/
|
|
1841
|
+
function createNoteFromSummary(notesDir, summaryText) {
|
|
1842
|
+
try {
|
|
1843
|
+
const notePath = createSessionNote(notesDir, "New Session");
|
|
1844
|
+
const noteFilename = basename(notePath);
|
|
1845
|
+
const numberMatch = noteFilename.match(/^(\d+)/);
|
|
1846
|
+
const noteNumber = numberMatch ? numberMatch[1] : "0000";
|
|
1847
|
+
const titleMatch = summaryText.match(/^# Session:\s*(.+)$/m);
|
|
1848
|
+
writeFileSync(notePath, `# Session ${noteNumber}: ${titleMatch ? titleMatch[1].trim() : "New Session"}
|
|
1849
|
+
|
|
1850
|
+
**Date:** ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}
|
|
1851
|
+
**Status:** In Progress
|
|
1852
|
+
|
|
1853
|
+
---
|
|
1854
|
+
|
|
1855
|
+
${summaryText.replace(/^# Session:.*$/m, "").replace(/^\*\*Date:\*\*.*$/m, "").replace(/^\*\*Status:\*\*.*$/m, "").replace(/^---$/m, "").trim()}
|
|
1856
|
+
|
|
1857
|
+
---
|
|
1858
|
+
|
|
1859
|
+
## Next Steps
|
|
1860
|
+
|
|
1861
|
+
<!-- To be filled at session end -->
|
|
1862
|
+
|
|
1863
|
+
---
|
|
1864
|
+
|
|
1865
|
+
**Tags:** #Session
|
|
1866
|
+
`, "utf-8");
|
|
1867
|
+
process.stderr.write(`[session-summary] Created AI-powered note: ${noteFilename}\n`);
|
|
1868
|
+
return notePath;
|
|
1869
|
+
} catch (e) {
|
|
1870
|
+
process.stderr.write(`[session-summary] Failed to create note: ${e}\n`);
|
|
1871
|
+
return null;
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* Process a `session-summary` work item.
|
|
1876
|
+
*
|
|
1877
|
+
* This is the main function called by work-queue-worker.ts.
|
|
1878
|
+
* Throws on fatal errors (work queue will retry with backoff).
|
|
1879
|
+
*/
|
|
1880
|
+
async function handleSessionSummary(payload) {
|
|
1881
|
+
const { cwd, sessionId, projectSlug, transcriptPath, force } = payload;
|
|
1882
|
+
if (!cwd) throw new Error("session-summary payload missing cwd");
|
|
1883
|
+
process.stderr.write(`[session-summary] Starting for ${cwd}${sessionId ? ` (session=${sessionId})` : ""}${force ? " (force=true)" : ""}\n`);
|
|
1884
|
+
if (!force && isOnCooldown(cwd)) {
|
|
1885
|
+
process.stderr.write("[session-summary] Skipping — last summary was less than 30 minutes ago.\n");
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
let jsonlPath = transcriptPath || null;
|
|
1889
|
+
if (jsonlPath && !existsSync(jsonlPath)) {
|
|
1890
|
+
process.stderr.write(`[session-summary] Provided transcript path not found: ${jsonlPath}\n`);
|
|
1891
|
+
jsonlPath = null;
|
|
1892
|
+
}
|
|
1893
|
+
if (!jsonlPath) jsonlPath = findLatestJsonl(cwd);
|
|
1894
|
+
if (!jsonlPath) {
|
|
1895
|
+
process.stderr.write("[session-summary] No JSONL transcript found — skipping.\n");
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
process.stderr.write(`[session-summary] Using transcript: ${jsonlPath}\n`);
|
|
1899
|
+
const extracted = extractFromJsonl(jsonlPath);
|
|
1900
|
+
if (extracted.userMessages.length === 0) {
|
|
1901
|
+
process.stderr.write("[session-summary] No user messages found in transcript — skipping.\n");
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
process.stderr.write(`[session-summary] Extracted ${extracted.userMessages.length} user messages, ${extracted.filesModified.length} modified files.\n`);
|
|
1905
|
+
const gitLog = await getGitContext(cwd, extracted.sessionStartTime);
|
|
1906
|
+
if (gitLog) process.stderr.write(`[session-summary] Got git context (${gitLog.split("\n").length} lines).\n`);
|
|
1907
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1908
|
+
const existingNotePath = getCurrentNotePath(findNotesDir(cwd).path);
|
|
1909
|
+
let existingNote;
|
|
1910
|
+
if (existingNotePath) {
|
|
1911
|
+
const dateMatch = basename(existingNotePath).match(/(\d{4}-\d{2}-\d{2})/);
|
|
1912
|
+
if (dateMatch && dateMatch[1] === today) try {
|
|
1913
|
+
existingNote = readFileSync(existingNotePath, "utf-8");
|
|
1914
|
+
} catch {}
|
|
1915
|
+
}
|
|
1916
|
+
const prompt = buildSessionSummaryPrompt({
|
|
1917
|
+
userMessages: extracted.userMessages,
|
|
1918
|
+
gitLog,
|
|
1919
|
+
cwd,
|
|
1920
|
+
date: today,
|
|
1921
|
+
filesModified: extracted.filesModified,
|
|
1922
|
+
existingNote
|
|
1923
|
+
});
|
|
1924
|
+
process.stderr.write(`[session-summary] Sending ${prompt.length} char prompt to Haiku...\n`);
|
|
1925
|
+
const summaryText = await spawnHaikuSummarizer(prompt);
|
|
1926
|
+
if (!summaryText) {
|
|
1927
|
+
process.stderr.write("[session-summary] Haiku did not produce output — falling back to mechanical checkpoint.\n");
|
|
1928
|
+
markCooldown(cwd);
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
process.stderr.write(`[session-summary] Haiku produced ${summaryText.length} char summary.\n`);
|
|
1932
|
+
const notePath = writeSessionNote(cwd, summaryText, extracted.filesModified);
|
|
1933
|
+
if (notePath) process.stderr.write(`[session-summary] Session note written: ${basename(notePath)}\n`);
|
|
1934
|
+
markCooldown(cwd);
|
|
1935
|
+
process.stderr.write("[session-summary] Done.\n");
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1331
1938
|
//#endregion
|
|
1332
1939
|
//#region src/daemon/work-queue-worker.ts
|
|
1333
1940
|
/**
|
|
@@ -1336,8 +1943,9 @@ ${stateLines}
|
|
|
1336
1943
|
* Runs every 5 seconds to drain the queue.
|
|
1337
1944
|
* Handles 'session-end' work items by reading the transcript, extracting
|
|
1338
1945
|
* work summaries, updating the session note, and updating TODO.md.
|
|
1946
|
+
* Handles 'session-summary' items by spawning Haiku for AI-powered note generation.
|
|
1339
1947
|
*
|
|
1340
|
-
* Other item types (
|
|
1948
|
+
* Other item types (note-update, todo-update, topic-detect)
|
|
1341
1949
|
* are stubs — they log and complete immediately, ready for future expansion.
|
|
1342
1950
|
*/
|
|
1343
1951
|
const WORKER_INTERVAL_MS = 5e3;
|
|
@@ -1391,6 +1999,8 @@ async function processNextItem() {
|
|
|
1391
1999
|
await handleSessionEnd(item);
|
|
1392
2000
|
break;
|
|
1393
2001
|
case "session-summary":
|
|
2002
|
+
await handleSessionSummary(item.payload);
|
|
2003
|
+
break;
|
|
1394
2004
|
case "note-update":
|
|
1395
2005
|
case "todo-update":
|
|
1396
2006
|
case "topic-detect":
|
|
@@ -2145,4 +2755,4 @@ var daemon_exports = /* @__PURE__ */ __exportAll({ serve: () => serve });
|
|
|
2145
2755
|
|
|
2146
2756
|
//#endregion
|
|
2147
2757
|
export { serve as n, daemon_exports as t };
|
|
2148
|
-
//# sourceMappingURL=daemon-
|
|
2758
|
+
//# sourceMappingURL=daemon-D8ZxcFhU.mjs.map
|