@tekmidian/pai 0.5.2 → 0.5.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/ARCHITECTURE.md +84 -0
- package/README.md +38 -0
- package/dist/cli/index.mjs +66 -6
- package/dist/cli/index.mjs.map +1 -1
- package/dist/hooks/context-compression-hook.mjs +333 -33
- package/dist/hooks/context-compression-hook.mjs.map +3 -3
- package/dist/hooks/post-compact-inject.mjs +51 -0
- package/dist/hooks/post-compact-inject.mjs.map +7 -0
- package/package.json +1 -1
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +292 -70
- package/src/hooks/ts/session-start/post-compact-inject.ts +85 -0
package/ARCHITECTURE.md
CHANGED
|
@@ -500,6 +500,90 @@ PAI implements six Luhmann-inspired operations on the vault's dual representatio
|
|
|
500
500
|
|
|
501
501
|
---
|
|
502
502
|
|
|
503
|
+
## Hook System
|
|
504
|
+
|
|
505
|
+
PAI ships a comprehensive set of lifecycle hooks that integrate with Claude Code's hook events. Hooks are TypeScript source files (`src/hooks/ts/`) compiled to `.mjs` modules and deployed to `~/.claude/Hooks/`.
|
|
506
|
+
|
|
507
|
+
### Hook Architecture
|
|
508
|
+
|
|
509
|
+
```
|
|
510
|
+
Claude Code Event
|
|
511
|
+
│
|
|
512
|
+
├── stdin: JSON { session_id, transcript_path, cwd, hook_event_name }
|
|
513
|
+
│
|
|
514
|
+
├── Hook Process (.mjs)
|
|
515
|
+
│ ├── Reads stdin for context
|
|
516
|
+
│ ├── Performs side effects (file writes, notifications)
|
|
517
|
+
│ └── Writes stdout (injected as <system-reminder> into conversation)
|
|
518
|
+
│
|
|
519
|
+
└── stderr: diagnostic logs (visible in Claude Code's hook output)
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
**Key constraint:** Not all hook events support stdout injection. `SessionStart` does. `PreCompact` does not. This matters for context preservation.
|
|
523
|
+
|
|
524
|
+
### Hook Inventory
|
|
525
|
+
|
|
526
|
+
| Hook | Event | Purpose |
|
|
527
|
+
|------|-------|---------|
|
|
528
|
+
| `load-core-context.mjs` | SessionStart | Loads PAI skill system and core configuration |
|
|
529
|
+
| `load-project-context.mjs` | SessionStart | Detects project, loads notes dir, TODO, session note |
|
|
530
|
+
| `initialize-session.mjs` | SessionStart | Creates numbered session note, registers in PAI registry |
|
|
531
|
+
| `post-compact-inject.mjs` | SessionStart (compact) | Reads saved state and injects into post-compaction context |
|
|
532
|
+
| `security-validator.mjs` | PreToolUse (Bash) | Validates shell commands against security rules |
|
|
533
|
+
| `capture-all-events.mjs` | All events | Observability — logs every hook event to session timeline |
|
|
534
|
+
| `context-compression-hook.mjs` | PreCompact | Extracts session state, saves checkpoint, writes temp file for relay |
|
|
535
|
+
| `capture-tool-output.mjs` | PostToolUse | Records tool inputs/outputs for observability dashboard |
|
|
536
|
+
| `update-tab-on-action.mjs` | PostToolUse | Updates terminal tab title based on current activity |
|
|
537
|
+
| `sync-todo-to-md.mjs` | PostToolUse (TodoWrite) | Syncs Claude's internal TODO list to `Notes/TODO.md` |
|
|
538
|
+
| `cleanup-session-files.mjs` | UserPromptSubmit | Cleans up stale temp files between prompts |
|
|
539
|
+
| `update-tab-titles.mjs` | UserPromptSubmit | Sets terminal tab title from session context |
|
|
540
|
+
| `stop-hook.mjs` | Stop | Writes work items to session note, sends notification |
|
|
541
|
+
| `capture-session-summary.mjs` | SessionEnd | Final session summary written to session note |
|
|
542
|
+
| `subagent-stop-hook.mjs` | SubagentStop | Captures sub-agent completion for observability |
|
|
543
|
+
|
|
544
|
+
### Context Preservation Relay
|
|
545
|
+
|
|
546
|
+
The most critical hook interaction is the PreCompact → SessionStart relay that preserves context across compaction:
|
|
547
|
+
|
|
548
|
+
```
|
|
549
|
+
PreCompact fires (context-compression-hook.mjs)
|
|
550
|
+
│
|
|
551
|
+
├── Reads transcript JSONL from stdin { transcript_path }
|
|
552
|
+
├── Extracts: recent user messages, work summaries, files modified, captures
|
|
553
|
+
├── Writes checkpoint to session note (persistent)
|
|
554
|
+
├── Writes injection payload to /tmp/pai-compact-state-{session_id}.txt
|
|
555
|
+
└── Sends notification (WhatsApp or ntfy.sh)
|
|
556
|
+
|
|
557
|
+
⬇ Claude Code runs compaction (conversation is summarized)
|
|
558
|
+
|
|
559
|
+
SessionStart(compact) fires (post-compact-inject.mjs)
|
|
560
|
+
│
|
|
561
|
+
├── Reads /tmp/pai-compact-state-{session_id}.txt
|
|
562
|
+
├── Outputs content to stdout → injected into post-compaction context
|
|
563
|
+
└── Deletes temp file (one-shot relay)
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
**Why the relay?** PreCompact hooks cannot inject into the conversation (stdout is ignored by Claude Code for this event). SessionStart hooks can. The temp file bridges the gap.
|
|
567
|
+
|
|
568
|
+
### Building Hooks
|
|
569
|
+
|
|
570
|
+
```bash
|
|
571
|
+
bun run build # Builds everything including hooks
|
|
572
|
+
node scripts/build-hooks.mjs # Build hooks only
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
The build script compiles each `.ts` hook to a self-contained `.mjs` module using `tsx` bundling, then copies them to the configured hooks directory (`~/.claude/Hooks/` by default).
|
|
576
|
+
|
|
577
|
+
### Adding a New Hook
|
|
578
|
+
|
|
579
|
+
1. Create the TypeScript source in the appropriate `src/hooks/ts/<event>/` directory
|
|
580
|
+
2. Read stdin for `HookInput` JSON (session_id, transcript_path, cwd, hook_event_name)
|
|
581
|
+
3. Use stderr for diagnostics, stdout only if the event supports injection
|
|
582
|
+
4. Register the hook in `~/.claude/settings.json` under the appropriate event with the correct matcher
|
|
583
|
+
5. Run `bun run build` to compile and deploy
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
503
587
|
## Templates
|
|
504
588
|
|
|
505
589
|
PAI ships three templates used during setup and customizable for your workflow.
|
package/README.md
CHANGED
|
@@ -60,6 +60,44 @@ Claude finds the setup skill, checks your system, runs the interactive wizard, a
|
|
|
60
60
|
|
|
61
61
|
---
|
|
62
62
|
|
|
63
|
+
## Context Preservation
|
|
64
|
+
|
|
65
|
+
When Claude's context window fills up, it compresses the conversation. Without PAI, everything from before that point is lost — Claude forgets what it was working on, what files it changed, and what you asked for.
|
|
66
|
+
|
|
67
|
+
PAI intercepts this compression with a two-stage relay:
|
|
68
|
+
|
|
69
|
+
1. **Before compression** — PAI extracts session state from the conversation transcript: your recent requests, work summaries, files modified, and current task context. This gets saved to a checkpoint.
|
|
70
|
+
|
|
71
|
+
2. **After compression** — PAI reads that checkpoint and injects it back into Claude's fresh context. Claude picks up exactly where it left off.
|
|
72
|
+
|
|
73
|
+
This happens automatically. You don't need to do anything — just keep working, and PAI handles the continuity.
|
|
74
|
+
|
|
75
|
+
### What Gets Preserved
|
|
76
|
+
|
|
77
|
+
- Your last 3 requests (so Claude knows what you were asking)
|
|
78
|
+
- Work summaries and captured context
|
|
79
|
+
- Files modified during the session
|
|
80
|
+
- Current working directory and task state
|
|
81
|
+
- Session note checkpoints (persistent — survive even full restarts)
|
|
82
|
+
|
|
83
|
+
### Session Lifecycle Hooks
|
|
84
|
+
|
|
85
|
+
PAI runs hooks at every stage of a Claude Code session:
|
|
86
|
+
|
|
87
|
+
| Event | What PAI Does |
|
|
88
|
+
|-------|--------------|
|
|
89
|
+
| **Session Start** | Loads project context, detects which project you're in, creates a session note |
|
|
90
|
+
| **User Prompt** | Cleans up temp files, updates terminal tab titles |
|
|
91
|
+
| **Pre-Compact** | Saves session state checkpoint, sends notification |
|
|
92
|
+
| **Post-Compact** | Injects preserved state back into Claude's context |
|
|
93
|
+
| **Tool Use** | Captures tool outputs for observability |
|
|
94
|
+
| **Session End** | Summarizes work done, finalizes session note |
|
|
95
|
+
| **Stop** | Writes work items to session note, sends notification |
|
|
96
|
+
|
|
97
|
+
All hooks are TypeScript compiled to `.mjs` modules. They run as separate processes, communicate via stdin (JSON input from Claude Code) and stdout (context injection back into the conversation).
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
63
101
|
## Auto-Compact Context Window
|
|
64
102
|
|
|
65
103
|
Claude Code can automatically compact your context window when it fills up, preventing session interruptions mid-task. PAI's statusline shows you at a glance whether auto-compact is active.
|
package/dist/cli/index.mjs
CHANGED
|
@@ -4142,9 +4142,18 @@ function mergeEnv(settings, incoming, report) {
|
|
|
4142
4142
|
return changed;
|
|
4143
4143
|
}
|
|
4144
4144
|
/**
|
|
4145
|
+
* Strip file extension from a command basename for extension-agnostic dedup.
|
|
4146
|
+
* This ensures that e.g. "context-compression-hook.ts" and
|
|
4147
|
+
* "context-compression-hook.mjs" are treated as the same hook.
|
|
4148
|
+
*/
|
|
4149
|
+
function commandStem(cmd) {
|
|
4150
|
+
return (cmd.split("/").pop() ?? cmd).replace(/\.(mjs|ts|js|sh)$/, "");
|
|
4151
|
+
}
|
|
4152
|
+
/**
|
|
4145
4153
|
* Collect every command string already registered for a given hookType.
|
|
4146
|
-
* Stores
|
|
4147
|
-
* (handles ${PAI_DIR}/Hooks/foo.sh vs /Users/.../Hooks/foo.sh
|
|
4154
|
+
* Stores full command, basename, AND extension-stripped stem for flexible
|
|
4155
|
+
* matching (handles ${PAI_DIR}/Hooks/foo.sh vs /Users/.../Hooks/foo.sh,
|
|
4156
|
+
* and .ts → .mjs migrations).
|
|
4148
4157
|
*/
|
|
4149
4158
|
function existingCommandsForHookType(rules) {
|
|
4150
4159
|
const cmds = /* @__PURE__ */ new Set();
|
|
@@ -4152,11 +4161,30 @@ function existingCommandsForHookType(rules) {
|
|
|
4152
4161
|
cmds.add(entry.command);
|
|
4153
4162
|
const base = entry.command.split("/").pop();
|
|
4154
4163
|
if (base) cmds.add(base);
|
|
4164
|
+
cmds.add(commandStem(entry.command));
|
|
4155
4165
|
}
|
|
4156
4166
|
return cmds;
|
|
4157
4167
|
}
|
|
4158
4168
|
/**
|
|
4169
|
+
* Find and remove an existing rule whose command has the same stem
|
|
4170
|
+
* (extension-agnostic) as the incoming command. Returns true if a
|
|
4171
|
+
* replacement was made. This handles .ts → .mjs migrations cleanly.
|
|
4172
|
+
*/
|
|
4173
|
+
function replaceStaleHook(existingRules, incomingStem, incomingCommand, incomingMatcher) {
|
|
4174
|
+
for (let i = 0; i < existingRules.length; i++) {
|
|
4175
|
+
const rule = existingRules[i];
|
|
4176
|
+
for (let j = 0; j < rule.hooks.length; j++) if (commandStem(rule.hooks[j].command) === incomingStem && rule.hooks[j].command !== incomingCommand) {
|
|
4177
|
+
rule.hooks[j].command = incomingCommand;
|
|
4178
|
+
if (incomingMatcher !== void 0) rule.matcher = incomingMatcher;
|
|
4179
|
+
return true;
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
return false;
|
|
4183
|
+
}
|
|
4184
|
+
/**
|
|
4159
4185
|
* Merge hooks — append entries, deduplicating by command string.
|
|
4186
|
+
* Extension-agnostic: a .mjs hook replaces an existing .ts hook with the
|
|
4187
|
+
* same stem, ensuring clean .ts → .mjs migrations without duplicates.
|
|
4160
4188
|
*/
|
|
4161
4189
|
function mergeHooks(settings, incoming, report) {
|
|
4162
4190
|
let changed = false;
|
|
@@ -4164,12 +4192,21 @@ function mergeHooks(settings, incoming, report) {
|
|
|
4164
4192
|
for (const entry of incoming) {
|
|
4165
4193
|
const { hookType, matcher, command } = entry;
|
|
4166
4194
|
const existingRules = Array.isArray(hooksSection[hookType]) ? hooksSection[hookType] : [];
|
|
4167
|
-
const existingCmds = existingCommandsForHookType(existingRules);
|
|
4168
4195
|
const basename = command.split("/").pop() ?? command;
|
|
4196
|
+
const stem = commandStem(command);
|
|
4197
|
+
const existingCmds = existingCommandsForHookType(existingRules);
|
|
4169
4198
|
if (existingCmds.has(command) || existingCmds.has(basename)) {
|
|
4170
4199
|
report.push(chalk.dim(` Skipped: hook ${hookType} → ${basename} already registered`));
|
|
4171
4200
|
continue;
|
|
4172
4201
|
}
|
|
4202
|
+
if (existingCmds.has(stem)) {
|
|
4203
|
+
if (replaceStaleHook(existingRules, stem, command, matcher)) {
|
|
4204
|
+
hooksSection[hookType] = existingRules;
|
|
4205
|
+
report.push(chalk.yellow(` Upgraded: hook ${hookType} → ${basename} (replaced stale extension)`));
|
|
4206
|
+
changed = true;
|
|
4207
|
+
continue;
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
4173
4210
|
const newRule = { hooks: [{
|
|
4174
4211
|
type: "command",
|
|
4175
4212
|
command
|
|
@@ -4874,6 +4911,7 @@ async function stepTsHooks(rl) {
|
|
|
4874
4911
|
line$1();
|
|
4875
4912
|
let copiedCount = 0;
|
|
4876
4913
|
let skippedCount = 0;
|
|
4914
|
+
let cleanedCount = 0;
|
|
4877
4915
|
for (const filename of allFiles) {
|
|
4878
4916
|
const src = join(distHooksDir, filename);
|
|
4879
4917
|
const dest = join(hooksTarget, filename);
|
|
@@ -4882,6 +4920,12 @@ async function stepTsHooks(rl) {
|
|
|
4882
4920
|
if (srcContent === readFileSync(dest, "utf-8")) {
|
|
4883
4921
|
console.log(c.dim(` Unchanged: ${filename}`));
|
|
4884
4922
|
skippedCount++;
|
|
4923
|
+
const staleTsPath = join(hooksTarget, filename.replace(/\.mjs$/, ".ts"));
|
|
4924
|
+
if (existsSync(staleTsPath)) {
|
|
4925
|
+
unlinkSync(staleTsPath);
|
|
4926
|
+
console.log(c.ok(`Cleaned up stale: ${filename.replace(/\.mjs$/, ".ts")}`));
|
|
4927
|
+
cleanedCount++;
|
|
4928
|
+
}
|
|
4885
4929
|
continue;
|
|
4886
4930
|
}
|
|
4887
4931
|
}
|
|
@@ -4889,11 +4933,22 @@ async function stepTsHooks(rl) {
|
|
|
4889
4933
|
chmodSync(dest, 493);
|
|
4890
4934
|
console.log(c.ok(`Installed: ${filename}`));
|
|
4891
4935
|
copiedCount++;
|
|
4936
|
+
const staleTsPath = join(hooksTarget, filename.replace(/\.mjs$/, ".ts"));
|
|
4937
|
+
if (existsSync(staleTsPath)) {
|
|
4938
|
+
unlinkSync(staleTsPath);
|
|
4939
|
+
console.log(c.ok(`Cleaned up stale: ${filename.replace(/\.mjs$/, ".ts")}`));
|
|
4940
|
+
cleanedCount++;
|
|
4941
|
+
}
|
|
4892
4942
|
}
|
|
4893
4943
|
line$1();
|
|
4894
|
-
if (copiedCount > 0
|
|
4895
|
-
|
|
4896
|
-
|
|
4944
|
+
if (copiedCount > 0 || cleanedCount > 0) {
|
|
4945
|
+
const parts = [];
|
|
4946
|
+
if (copiedCount > 0) parts.push(`${copiedCount} hook(s) installed`);
|
|
4947
|
+
if (skippedCount > 0) parts.push(`${skippedCount} unchanged`);
|
|
4948
|
+
if (cleanedCount > 0) parts.push(`${cleanedCount} stale .ts file(s) cleaned up`);
|
|
4949
|
+
console.log(c.ok(parts.join(", ") + "."));
|
|
4950
|
+
} else console.log(c.dim(` All ${skippedCount} hook(s) already up-to-date.`));
|
|
4951
|
+
return copiedCount > 0 || cleanedCount > 0;
|
|
4897
4952
|
}
|
|
4898
4953
|
/**
|
|
4899
4954
|
* Step 8b: Prompt for the DA (Digital Assistant) name
|
|
@@ -4952,6 +5007,11 @@ async function stepSettings(rl, daName) {
|
|
|
4952
5007
|
hookType: "SessionStart",
|
|
4953
5008
|
command: "${PAI_DIR}/Hooks/capture-all-events.mjs --event-type SessionStart"
|
|
4954
5009
|
},
|
|
5010
|
+
{
|
|
5011
|
+
hookType: "SessionStart",
|
|
5012
|
+
matcher: "compact",
|
|
5013
|
+
command: "${PAI_DIR}/Hooks/post-compact-inject.mjs"
|
|
5014
|
+
},
|
|
4955
5015
|
{
|
|
4956
5016
|
hookType: "UserPromptSubmit",
|
|
4957
5017
|
command: "${PAI_DIR}/Hooks/cleanup-session-files.mjs"
|