@tekmidian/pai 0.8.4 → 0.9.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/ARCHITECTURE.md +121 -0
- package/FEATURE.md +5 -0
- package/README.md +54 -0
- package/dist/cli/index.mjs +11 -11
- package/dist/daemon/index.mjs +3 -3
- package/dist/{daemon-nXyhvdzz.mjs → daemon-VIFoKc_z.mjs} +31 -6
- package/dist/daemon-VIFoKc_z.mjs.map +1 -0
- package/dist/daemon-mcp/index.mjs +51 -0
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/{factory-Ygqe_bVZ.mjs → factory-e0k1HWuc.mjs} +2 -2
- package/dist/{factory-Ygqe_bVZ.mjs.map → factory-e0k1HWuc.mjs.map} +1 -1
- package/dist/hooks/load-project-context.mjs +276 -89
- package/dist/hooks/load-project-context.mjs.map +4 -4
- package/dist/hooks/stop-hook.mjs +152 -2
- package/dist/hooks/stop-hook.mjs.map +3 -3
- package/dist/{postgres-CKf-EDtS.mjs → postgres-DvEPooLO.mjs} +45 -10
- package/dist/postgres-DvEPooLO.mjs.map +1 -0
- package/dist/query-feedback-Dv43XKHM.mjs +76 -0
- package/dist/query-feedback-Dv43XKHM.mjs.map +1 -0
- package/dist/tools-C4SBZHga.mjs +1731 -0
- package/dist/tools-C4SBZHga.mjs.map +1 -0
- package/dist/{vault-indexer-Bi2cRmn7.mjs → vault-indexer-B-aJpRZC.mjs} +3 -2
- package/dist/{vault-indexer-Bi2cRmn7.mjs.map → vault-indexer-B-aJpRZC.mjs.map} +1 -1
- package/dist/{zettelkasten-cdajbnPr.mjs → zettelkasten-DhBKZQHF.mjs} +358 -3
- package/dist/zettelkasten-DhBKZQHF.mjs.map +1 -0
- package/package.json +1 -1
- package/src/hooks/ts/session-start/load-project-context.ts +36 -0
- package/src/hooks/ts/stop/stop-hook.ts +203 -1
- package/dist/daemon-nXyhvdzz.mjs.map +0 -1
- package/dist/postgres-CKf-EDtS.mjs.map +0 -1
- package/dist/tools-DcaJlYDN.mjs +0 -869
- package/dist/tools-DcaJlYDN.mjs.map +0 -1
- package/dist/zettelkasten-cdajbnPr.mjs.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { readFileSync } from 'fs';
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
|
|
4
4
|
import { join, basename, dirname } from 'path';
|
|
5
5
|
import { connect } from 'net';
|
|
6
6
|
import { randomUUID } from 'crypto';
|
|
@@ -23,6 +23,87 @@ import {
|
|
|
23
23
|
const DAEMON_SOCKET = process.env.PAI_SOCKET ?? '/tmp/pai.sock';
|
|
24
24
|
const DAEMON_TIMEOUT_MS = 3_000;
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* How many human messages must accumulate before triggering a mid-session
|
|
28
|
+
* auto-save. Overrideable via the PAI_AUTO_SAVE_INTERVAL env var.
|
|
29
|
+
*/
|
|
30
|
+
const AUTO_SAVE_INTERVAL = (() => {
|
|
31
|
+
const raw = process.env.PAI_AUTO_SAVE_INTERVAL;
|
|
32
|
+
if (raw) {
|
|
33
|
+
const n = parseInt(raw, 10);
|
|
34
|
+
if (!isNaN(n) && n > 0) return n;
|
|
35
|
+
}
|
|
36
|
+
return 15;
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Session-state helpers (mid-session auto-save)
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const SESSION_STATE_DIR = join(
|
|
44
|
+
process.env.HOME ?? '/tmp',
|
|
45
|
+
'.config',
|
|
46
|
+
'pai',
|
|
47
|
+
'session-state'
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
interface SessionState {
|
|
51
|
+
humanMessageCount: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readSessionState(sessionId: string): SessionState {
|
|
55
|
+
try {
|
|
56
|
+
const stateFile = join(SESSION_STATE_DIR, `${sessionId}.json`);
|
|
57
|
+
if (!existsSync(stateFile)) return { humanMessageCount: 0 };
|
|
58
|
+
const raw = readFileSync(stateFile, 'utf-8');
|
|
59
|
+
const parsed = JSON.parse(raw) as Partial<SessionState>;
|
|
60
|
+
return {
|
|
61
|
+
humanMessageCount: typeof parsed.humanMessageCount === 'number' ? parsed.humanMessageCount : 0,
|
|
62
|
+
};
|
|
63
|
+
} catch {
|
|
64
|
+
return { humanMessageCount: 0 };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writeSessionState(sessionId: string, state: SessionState): void {
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(SESSION_STATE_DIR, { recursive: true });
|
|
71
|
+
const stateFile = join(SESSION_STATE_DIR, `${sessionId}.json`);
|
|
72
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf-8');
|
|
73
|
+
} catch (e) {
|
|
74
|
+
console.error(`STOP-HOOK: Could not write session state: ${e}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function deleteSessionState(sessionId: string): void {
|
|
79
|
+
try {
|
|
80
|
+
const stateFile = join(SESSION_STATE_DIR, `${sessionId}.json`);
|
|
81
|
+
if (existsSync(stateFile)) {
|
|
82
|
+
unlinkSync(stateFile);
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Non-fatal
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Count human (user-role) messages in the transcript lines.
|
|
91
|
+
*/
|
|
92
|
+
function countHumanMessages(lines: string[]): number {
|
|
93
|
+
let count = 0;
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
try {
|
|
96
|
+
const entry = JSON.parse(line);
|
|
97
|
+
if (entry.type === 'user' && entry.message?.role === 'user') {
|
|
98
|
+
count++;
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// Skip invalid JSON
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return count;
|
|
105
|
+
}
|
|
106
|
+
|
|
26
107
|
// ---------------------------------------------------------------------------
|
|
27
108
|
// Helper: safely convert Claude content (string | Block[]) to plain text
|
|
28
109
|
// ---------------------------------------------------------------------------
|
|
@@ -146,6 +227,64 @@ function enqueueWithDaemon(payload: {
|
|
|
146
227
|
});
|
|
147
228
|
}
|
|
148
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Enqueue a session-summary work item with `force: true` for mid-session auto-save.
|
|
232
|
+
* Like the regular enqueueSessionSummaryWithDaemon but signals the daemon to
|
|
233
|
+
* summarise even though the session is still ongoing.
|
|
234
|
+
*/
|
|
235
|
+
function enqueueMidSessionSummaryWithDaemon(payload: {
|
|
236
|
+
cwd: string;
|
|
237
|
+
}): Promise<boolean> {
|
|
238
|
+
return new Promise((resolve) => {
|
|
239
|
+
let done = false;
|
|
240
|
+
let buffer = '';
|
|
241
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
242
|
+
|
|
243
|
+
function finish(ok: boolean): void {
|
|
244
|
+
if (done) return;
|
|
245
|
+
done = true;
|
|
246
|
+
if (timer !== null) { clearTimeout(timer); timer = null; }
|
|
247
|
+
try { client.destroy(); } catch { /* ignore */ }
|
|
248
|
+
resolve(ok);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const client = connect(DAEMON_SOCKET, () => {
|
|
252
|
+
const msg = JSON.stringify({
|
|
253
|
+
id: randomUUID(),
|
|
254
|
+
method: 'work_queue_enqueue',
|
|
255
|
+
params: {
|
|
256
|
+
type: 'session-summary',
|
|
257
|
+
priority: 3,
|
|
258
|
+
payload: {
|
|
259
|
+
cwd: payload.cwd,
|
|
260
|
+
force: true,
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
}) + '\n';
|
|
264
|
+
client.write(msg);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
client.on('data', (chunk: Buffer) => {
|
|
268
|
+
buffer += chunk.toString();
|
|
269
|
+
const nl = buffer.indexOf('\n');
|
|
270
|
+
if (nl === -1) return;
|
|
271
|
+
const line = buffer.slice(0, nl);
|
|
272
|
+
try {
|
|
273
|
+
const response = JSON.parse(line) as { ok: boolean; result?: { id: string } };
|
|
274
|
+
if (response.ok) {
|
|
275
|
+
console.error(`STOP-HOOK: Mid-session summary enqueued (id=${response.result?.id}).`);
|
|
276
|
+
}
|
|
277
|
+
} catch { /* ignore */ }
|
|
278
|
+
finish(true);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
client.on('error', () => finish(false));
|
|
282
|
+
client.on('end', () => { if (!done) finish(false); });
|
|
283
|
+
|
|
284
|
+
timer = setTimeout(() => finish(false), DAEMON_TIMEOUT_MS);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
149
288
|
/**
|
|
150
289
|
* Enqueue a session-summary work item with the daemon for AI-powered note generation.
|
|
151
290
|
* Non-blocking — if daemon is unavailable, silently skips (the mechanical note is sufficient).
|
|
@@ -450,12 +589,19 @@ async function main() {
|
|
|
450
589
|
|
|
451
590
|
let transcriptPath: string;
|
|
452
591
|
let cwd: string;
|
|
592
|
+
let stopHookActive: boolean = false;
|
|
593
|
+
let sessionId: string = '';
|
|
453
594
|
try {
|
|
454
595
|
const parsed = JSON.parse(input);
|
|
455
596
|
transcriptPath = parsed.transcript_path;
|
|
456
597
|
cwd = parsed.cwd || process.cwd();
|
|
598
|
+
stopHookActive = parsed.stop_hook_active === true;
|
|
599
|
+
// session_id may appear directly or be derivable from the transcript path
|
|
600
|
+
sessionId = parsed.session_id ?? basename(transcriptPath ?? '').replace(/\.jsonl$/, '');
|
|
457
601
|
console.error(`Transcript path: ${transcriptPath}`);
|
|
458
602
|
console.error(`Working directory: ${cwd}`);
|
|
603
|
+
console.error(`stop_hook_active: ${stopHookActive}`);
|
|
604
|
+
console.error(`session_id: ${sessionId}`);
|
|
459
605
|
} catch (e) {
|
|
460
606
|
console.error(`Error parsing input JSON: ${e}`);
|
|
461
607
|
process.exit(0);
|
|
@@ -478,6 +624,56 @@ async function main() {
|
|
|
478
624
|
|
|
479
625
|
const lines = transcript.trim().split('\n');
|
|
480
626
|
|
|
627
|
+
// ---------------------------------------------------------------------------
|
|
628
|
+
// Mid-session auto-save check
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
// When stop_hook_active is FALSE (normal Stop event, not a re-entry from our
|
|
631
|
+
// own exit-code-2 block), we check whether enough human messages have
|
|
632
|
+
// accumulated to warrant an interim session summary.
|
|
633
|
+
//
|
|
634
|
+
// When stop_hook_active is TRUE the hook is already in the blocked-loop mode
|
|
635
|
+
// we triggered on the previous fire, so we skip the check entirely and proceed
|
|
636
|
+
// with normal session-end logic.
|
|
637
|
+
//
|
|
638
|
+
// Failure of this entire block must never abort the normal flow — wrap it all.
|
|
639
|
+
if (!stopHookActive && sessionId) {
|
|
640
|
+
try {
|
|
641
|
+
const currentMsgCount = countHumanMessages(lines);
|
|
642
|
+
const state = readSessionState(sessionId);
|
|
643
|
+
const prevCount = state.humanMessageCount;
|
|
644
|
+
const newMessages = currentMsgCount - prevCount;
|
|
645
|
+
|
|
646
|
+
console.error(
|
|
647
|
+
`STOP-HOOK: human messages — total=${currentMsgCount} prev=${prevCount} new=${newMessages} interval=${AUTO_SAVE_INTERVAL}`
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
if (newMessages >= AUTO_SAVE_INTERVAL) {
|
|
651
|
+
// Reset the counter now so we don't double-trigger on the re-entry fire.
|
|
652
|
+
writeSessionState(sessionId, { humanMessageCount: currentMsgCount });
|
|
653
|
+
|
|
654
|
+
console.error(`STOP-HOOK: Auto-save threshold reached. Triggering mid-session summary.`);
|
|
655
|
+
|
|
656
|
+
// Fire-and-forget: push session-summary to daemon.
|
|
657
|
+
enqueueMidSessionSummaryWithDaemon({ cwd }).catch(() => {});
|
|
658
|
+
|
|
659
|
+
// Emit the blocking system-reminder to stdout so Claude re-reads it.
|
|
660
|
+
process.stdout.write(
|
|
661
|
+
`<system-reminder>\n[AUTO-SAVE] ${newMessages} messages processed. The daemon is now summarizing the session so far. Continue with your current task — this is background work.\n</system-reminder>\n`
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
// Exit code 2 blocks the Stop and injects the system-reminder into the
|
|
665
|
+
// conversation, setting stop_hook_active=true for the next fire.
|
|
666
|
+
process.exit(2);
|
|
667
|
+
} else {
|
|
668
|
+
// Update the stored count so we can measure delta on next fire.
|
|
669
|
+
writeSessionState(sessionId, { humanMessageCount: currentMsgCount });
|
|
670
|
+
}
|
|
671
|
+
} catch (autoSaveError) {
|
|
672
|
+
// Never let auto-save logic block the normal Stop flow.
|
|
673
|
+
console.error(`STOP-HOOK: Auto-save check failed (non-fatal): ${autoSaveError}`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
481
677
|
// Extract last user query for tab title / fallback
|
|
482
678
|
let lastUserQuery = '';
|
|
483
679
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
@@ -555,6 +751,12 @@ async function main() {
|
|
|
555
751
|
// avoiding a race where the session-end hook moves the JSONL before the worker reads it.
|
|
556
752
|
await enqueueSessionSummaryWithDaemon({ cwd });
|
|
557
753
|
|
|
754
|
+
// Clean up the session-state file now that the session has truly ended.
|
|
755
|
+
if (sessionId) {
|
|
756
|
+
deleteSessionState(sessionId);
|
|
757
|
+
console.error(`STOP-HOOK: Session state cleaned up for ${sessionId}.`);
|
|
758
|
+
}
|
|
759
|
+
|
|
558
760
|
console.error(`STOP-HOOK COMPLETED SUCCESSFULLY at ${new Date().toISOString()}\n`);
|
|
559
761
|
}
|
|
560
762
|
|