@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.
Files changed (33) hide show
  1. package/ARCHITECTURE.md +121 -0
  2. package/FEATURE.md +5 -0
  3. package/README.md +54 -0
  4. package/dist/cli/index.mjs +11 -11
  5. package/dist/daemon/index.mjs +3 -3
  6. package/dist/{daemon-nXyhvdzz.mjs → daemon-VIFoKc_z.mjs} +31 -6
  7. package/dist/daemon-VIFoKc_z.mjs.map +1 -0
  8. package/dist/daemon-mcp/index.mjs +51 -0
  9. package/dist/daemon-mcp/index.mjs.map +1 -1
  10. package/dist/{factory-Ygqe_bVZ.mjs → factory-e0k1HWuc.mjs} +2 -2
  11. package/dist/{factory-Ygqe_bVZ.mjs.map → factory-e0k1HWuc.mjs.map} +1 -1
  12. package/dist/hooks/load-project-context.mjs +276 -89
  13. package/dist/hooks/load-project-context.mjs.map +4 -4
  14. package/dist/hooks/stop-hook.mjs +152 -2
  15. package/dist/hooks/stop-hook.mjs.map +3 -3
  16. package/dist/{postgres-CKf-EDtS.mjs → postgres-DvEPooLO.mjs} +45 -10
  17. package/dist/postgres-DvEPooLO.mjs.map +1 -0
  18. package/dist/query-feedback-Dv43XKHM.mjs +76 -0
  19. package/dist/query-feedback-Dv43XKHM.mjs.map +1 -0
  20. package/dist/tools-C4SBZHga.mjs +1731 -0
  21. package/dist/tools-C4SBZHga.mjs.map +1 -0
  22. package/dist/{vault-indexer-Bi2cRmn7.mjs → vault-indexer-B-aJpRZC.mjs} +3 -2
  23. package/dist/{vault-indexer-Bi2cRmn7.mjs.map → vault-indexer-B-aJpRZC.mjs.map} +1 -1
  24. package/dist/{zettelkasten-cdajbnPr.mjs → zettelkasten-DhBKZQHF.mjs} +358 -3
  25. package/dist/zettelkasten-DhBKZQHF.mjs.map +1 -0
  26. package/package.json +1 -1
  27. package/src/hooks/ts/session-start/load-project-context.ts +36 -0
  28. package/src/hooks/ts/stop/stop-hook.ts +203 -1
  29. package/dist/daemon-nXyhvdzz.mjs.map +0 -1
  30. package/dist/postgres-CKf-EDtS.mjs.map +0 -1
  31. package/dist/tools-DcaJlYDN.mjs +0 -869
  32. package/dist/tools-DcaJlYDN.mjs.map +0 -1
  33. 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