@tekmidian/pai 0.42.6 → 0.44.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 (50) hide show
  1. package/dist/{chain-BbcfP77K.mjs → chain-DAEC9AU7.mjs} +2 -2
  2. package/dist/{chain-BbcfP77K.mjs.map → chain-DAEC9AU7.mjs.map} +1 -1
  3. package/dist/cli/index.mjs +5 -5
  4. package/dist/cli/program.mjs +5 -5
  5. package/dist/daemon/index.mjs +4 -4
  6. package/dist/{daemon-BNjbVsQU.mjs → daemon-BVbvL8IA.mjs} +4 -4
  7. package/dist/{daemon-BppziPi6.mjs → daemon-ebLDoKM3.mjs} +6 -6
  8. package/dist/{daemon-BppziPi6.mjs.map → daemon-ebLDoKM3.mjs.map} +1 -1
  9. package/dist/daemon-mcp/index.mjs +3 -3
  10. package/dist/{fallback-Cwt4hmh5.mjs → fallback-CxGDTXWt.mjs} +6 -4
  11. package/dist/fallback-CxGDTXWt.mjs.map +1 -0
  12. package/dist/hooks/load-project-context.mjs +51 -48
  13. package/dist/hooks/load-project-context.mjs.map +4 -4
  14. package/dist/hooks/mcp-deferred-reminder.mjs.map +1 -1
  15. package/dist/hooks/whisper-reinject.mjs +8 -0
  16. package/dist/hooks/whisper-reinject.mjs.map +3 -3
  17. package/dist/hooks/worker-status-line.mjs +108 -41
  18. package/dist/hooks/worker-status-line.mjs.map +3 -3
  19. package/dist/{planner-pXRc-8yt.mjs → planner-B4DvNpXd.mjs} +3 -3
  20. package/dist/{planner-pXRc-8yt.mjs.map → planner-B4DvNpXd.mjs.map} +1 -1
  21. package/dist/{program-LLNPpxlj.mjs → program-cr5U1-DK.mjs} +1347 -65
  22. package/dist/program-cr5U1-DK.mjs.map +1 -0
  23. package/dist/{run-DDdT9teG.mjs → run-BgExF8Xg.mjs} +485 -60
  24. package/dist/run-BgExF8Xg.mjs.map +1 -0
  25. package/dist/{tools-BradfQk7.mjs → tools-CAG2sUjf.mjs} +2 -2
  26. package/dist/{tools-UrSHIe29.mjs → tools-DiRC5glA.mjs} +2 -2
  27. package/dist/{tools-UrSHIe29.mjs.map → tools-DiRC5glA.mjs.map} +1 -1
  28. package/dist/{wakeup-TnGjJY3I.mjs → wakeup-BfElduEU.mjs} +21 -8
  29. package/dist/wakeup-BfElduEU.mjs.map +1 -0
  30. package/docs/commands/README.md +5 -1
  31. package/docs/commands/audit.md +50 -2
  32. package/docs/commands/worker.md +2 -0
  33. package/package.json +1 -1
  34. package/scripts/build-hooks.mjs +2 -0
  35. package/src/hooks/ts/lib/context-fill.test.ts +40 -0
  36. package/src/hooks/ts/post-tool-use/whisper-reinject.test.ts +55 -0
  37. package/src/hooks/ts/post-tool-use/whisper-reinject.ts +3 -0
  38. package/src/hooks/ts/session-start/load-project-context.ts +3 -3
  39. package/src/hooks/ts/session-start/mcp-deferred-reminder.ts +2 -2
  40. package/src/hooks/ts/session-start/session-start-worker-guard.test.ts +0 -1
  41. package/src/hooks/ztk-auto.sh +28 -0
  42. package/src/hooks/ztk-auto.test.ts +72 -0
  43. package/src/hooks/ztk-passthrough.mjs +71 -0
  44. package/dist/fallback-Cwt4hmh5.mjs.map +0 -1
  45. package/dist/hooks/session-commands.mjs +0 -26
  46. package/dist/hooks/session-commands.mjs.map +0 -7
  47. package/dist/program-LLNPpxlj.mjs.map +0 -1
  48. package/dist/run-DDdT9teG.mjs.map +0 -1
  49. package/dist/wakeup-TnGjJY3I.mjs.map +0 -1
  50. package/src/hooks/ts/session-start/session-commands.ts +0 -30
@@ -1,3 +1,4 @@
1
+ import { a as paiHomePath, r as migratePaiFile } from "./pai-home-ChAOupZc.mjs";
1
2
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { basename, join } from "node:path";
@@ -29,19 +30,31 @@ const EXTRACT_SECTIONS = [
29
30
  /** Identity file location. */
30
31
  const IDENTITY_FILE = join(homedir(), ".pai", "identity.txt");
31
32
  /**
32
- * Load L0 identity from ~/.pai/identity.txt.
33
- * Returns the file content, or an empty string if the file does not exist.
34
- * Never throws.
33
+ * Load L0 identity. Prefers the PAI_HOME location (identity.txt migrated by
34
+ * `pai config migrate`), falling back to the legacy ~/.pai/identity.txt so a
35
+ * not-yet-migrated file keeps working. Returns "" if neither exists. Never
36
+ * throws.
35
37
  */
36
- function loadL0Identity() {
37
- if (!existsSync(IDENTITY_FILE)) return "";
38
+ function loadL0Identity(opts = {}) {
39
+ const newPath = opts.newPath ?? paiHomePath("identity.txt");
40
+ const legacyPath = opts.legacyPath ?? IDENTITY_FILE;
41
+ const path = existsSync(newPath) ? newPath : legacyPath;
42
+ if (!existsSync(path)) return "";
38
43
  try {
39
- return readFileSync(IDENTITY_FILE, "utf-8").trim();
44
+ return readFileSync(path, "utf-8").trim();
40
45
  } catch {
41
46
  return "";
42
47
  }
43
48
  }
44
49
  /**
50
+ * Move ~/.pai/identity.txt into PAI_HOME, following the same copy /
51
+ * verify-byte-identical / rename-aside semantics as every other per-user
52
+ * file (see migratePaiFile). `from`/`to` are overridable for tests.
53
+ */
54
+ function migrateIdentityFile(opts = {}) {
55
+ return migratePaiFile(opts.to ?? paiHomePath("identity.txt"), [opts.from ?? IDENTITY_FILE], { dryRun: opts.dryRun });
56
+ }
57
+ /**
45
58
  * Find the Notes directory for a project given its root_path from the registry.
46
59
  * Checks local Notes/ first, then central ~/.claude/projects/... path.
47
60
  */
@@ -214,5 +227,5 @@ function buildWakeupContext(rootPath, tokenBudget = L1_TOKEN_BUDGET) {
214
227
  }
215
228
 
216
229
  //#endregion
217
- export { buildWakeupContext as t };
218
- //# sourceMappingURL=wakeup-TnGjJY3I.mjs.map
230
+ export { migrateIdentityFile as n, buildWakeupContext as t };
231
+ //# sourceMappingURL=wakeup-BfElduEU.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wakeup-BfElduEU.mjs","names":[],"sources":["../src/memory/wakeup.ts"],"sourcesContent":["/**\n * Wake-up context system — progressive context loading inspired by mempalace.\n *\n * Layers:\n * L0 Identity (~100 tokens) — user identity from ~/.pai/identity.txt. Always loaded.\n * L1 Essential Story (~500-800t) — top session notes for the project, key lines extracted.\n * L2 On-Demand — triggered by topic queries (handled by memory_search).\n * L3 Deep Search — unlimited federated memory search (memory_search tool).\n */\n\nimport { existsSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { join, basename } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { paiHomePath, migratePaiFile, type MigrateFileResult } from \"../config/pai-home.js\";\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Maximum tokens for the L1 essential story block. Approx 4 chars/token.\n * Measured 2026-09-20: 800 tokens was mostly stale bullets. Reduced to 300.\n */\nconst L1_TOKEN_BUDGET = 300;\nconst L1_CHAR_BUDGET = L1_TOKEN_BUDGET * 4; // ~1600 chars\n\n/** Maximum session notes to scan when building L1. Reduced from 10 to 3 (2026-09-20). */\nconst L1_MAX_NOTES = 3;\n\n/** Sections to extract from session notes (in priority order). */\nconst EXTRACT_SECTIONS = [\n \"Work Done\",\n \"Key Decisions\",\n \"Next Steps\",\n \"Checkpoint\",\n];\n\n/** Identity file location. */\nconst IDENTITY_FILE = join(homedir(), \".pai\", \"identity.txt\");\n\n// ---------------------------------------------------------------------------\n// L0: Identity\n// ---------------------------------------------------------------------------\n\n/**\n * Load L0 identity. Prefers the PAI_HOME location (identity.txt migrated by\n * `pai config migrate`), falling back to the legacy ~/.pai/identity.txt so a\n * not-yet-migrated file keeps working. Returns \"\" if neither exists. Never\n * throws.\n */\nexport function loadL0Identity(\n opts: { newPath?: string; legacyPath?: string } = {}\n): string {\n const newPath = opts.newPath ?? paiHomePath(\"identity.txt\");\n const legacyPath = opts.legacyPath ?? IDENTITY_FILE;\n const path = existsSync(newPath) ? newPath : legacyPath;\n if (!existsSync(path)) return \"\";\n try {\n return readFileSync(path, \"utf-8\").trim();\n } catch {\n return \"\";\n }\n}\n\n/**\n * Move ~/.pai/identity.txt into PAI_HOME, following the same copy /\n * verify-byte-identical / rename-aside semantics as every other per-user\n * file (see migratePaiFile). `from`/`to` are overridable for tests.\n */\nexport function migrateIdentityFile(\n opts: { dryRun?: boolean; from?: string; to?: string } = {}\n): MigrateFileResult {\n const toPath = opts.to ?? paiHomePath(\"identity.txt\");\n const fromPath = opts.from ?? IDENTITY_FILE;\n return migratePaiFile(toPath, [fromPath], { dryRun: opts.dryRun });\n}\n\n// ---------------------------------------------------------------------------\n// L1: Essential Story\n// ---------------------------------------------------------------------------\n\n/**\n * Find the Notes directory for a project given its root_path from the registry.\n * Checks local Notes/ first, then central ~/.claude/projects/... path.\n */\nfunction findNotesDirForProject(rootPath: string): string | null {\n // Check local Notes directories first\n const localCandidates = [\n join(rootPath, \"Notes\"),\n join(rootPath, \"notes\"),\n join(rootPath, \".claude\", \"Notes\"),\n ];\n for (const p of localCandidates) {\n if (existsSync(p)) return p;\n }\n\n // Fall back to central ~/.claude/projects/{encoded}/Notes\n const encoded = rootPath\n .replace(/\\//g, \"-\")\n .replace(/\\./g, \"-\")\n .replace(/ /g, \"-\");\n const centralNotes = join(\n homedir(),\n \".claude\",\n \"projects\",\n encoded,\n \"Notes\"\n );\n if (existsSync(centralNotes)) return centralNotes;\n\n return null;\n}\n\n/**\n * Recursively find all .md session note files in a Notes directory.\n * Handles both flat layout (Notes/*.md) and month-subdirectory layout\n * (Notes/YYYY/MM/*.md). Returns files sorted newest-first by filename\n * (note numbers are monotonically increasing, so lexicographic = newest-last,\n * so we reverse).\n */\nfunction findSessionNotes(notesDir: string): string[] {\n const result: string[] = [];\n\n const scanDir = (dir: string) => {\n if (!existsSync(dir)) return;\n let entries: string[];\n try {\n entries = readdirSync(dir, { withFileTypes: true } as Parameters<typeof readdirSync>[1] as any)\n .map((e: any) => ({ name: e.name, isDir: e.isDirectory() }));\n } catch {\n return;\n }\n\n for (const entry of entries as Array<{ name: string; isDir: boolean }>) {\n const fullPath = join(dir, entry.name);\n if (entry.isDir) {\n // Recurse into YYYY/MM subdirectories\n scanDir(fullPath);\n } else if (entry.name.match(/^\\d{3,4}[\\s_-].*\\.md$/)) {\n result.push(fullPath);\n }\n }\n };\n\n scanDir(notesDir);\n\n // Sort newest-first by the DATE in the filename, not the note number.\n //\n // The note number is only monotonic within one uninterrupted numbering run.\n // It restarts — per month directory, and after a registry merge renumbers a\n // project — so \"highest number\" and \"most recent\" diverge. Observed live:\n // `0184 - 2026-02-22` outranked `0008 - 2026-08-01`, and the wake-up context\n // handed a resumed session five-month-old material as its recent history.\n //\n // `NNNN - YYYY-MM-DD - Title.md` is the enforced layout, so the date is the\n // reliable key. Number breaks ties within a day; mtime is the last resort\n // for notes that predate the naming convention.\n const dateOf = (p: string): string =>\n basename(p).match(/(\\d{4}-\\d{2}-\\d{2})/)?.[1] ?? \"\";\n const numberOf = (p: string): number =>\n parseInt(basename(p).match(/^(\\d+)/)?.[1] ?? \"0\", 10);\n const mtimeOf = (p: string): number => {\n try {\n return statSync(p).mtimeMs;\n } catch {\n return 0;\n }\n };\n\n result.sort((a, b) => {\n const dateA = dateOf(a);\n const dateB = dateOf(b);\n // Undated notes sort last rather than winning on an empty string.\n if (dateA !== dateB) {\n if (!dateA) return 1;\n if (!dateB) return -1;\n return dateB.localeCompare(dateA);\n }\n const byNumber = numberOf(b) - numberOf(a);\n if (byNumber !== 0) return byNumber;\n return mtimeOf(b) - mtimeOf(a);\n });\n\n return result;\n}\n\n/**\n * Extract the most important lines from a session note.\n * Prioritises: Work Done items, Key Decisions, Next Steps, Checkpoint headings.\n * Returns a condensed string under maxChars.\n */\nfunction normalizeLine(line: string): string {\n return line\n .replace(/^(- \\[[ x]\\] |- |\\* |\\d+\\.\\s)/, \"\")\n .trim()\n .replace(/\\s+/g, \" \");\n}\n\nfunction extractKeyLines(content: string, maxChars: number, seen: Set<string> = new Set()): string {\n const lines = content.split(\"\\n\");\n const selected: string[] = [];\n let inTargetSection = false;\n let currentSection = \"\";\n let charCount = 0;\n\n // First pass: collect lines from priority sections\n for (const line of lines) {\n // Detect section headers\n const h2Match = line.match(/^## (.+)$/);\n const h3Match = line.match(/^### (.+)$/);\n if (h2Match) {\n currentSection = h2Match[1];\n inTargetSection = EXTRACT_SECTIONS.some((s) =>\n currentSection.toLowerCase().includes(s.toLowerCase())\n );\n continue;\n }\n if (h3Match) {\n // Checkpoints / sub-sections — include heading as label\n if (inTargetSection) {\n const label = `[${h3Match[1]}]`;\n if (charCount + label.length < maxChars) {\n selected.push(label);\n charCount += label.length + 1;\n }\n }\n continue;\n }\n\n if (!inTargetSection) continue;\n\n // Skip blank lines and HTML comments\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(\"<!--\") || trimmed === \"---\") continue;\n\n // Include checkbox items, bold text, and plain text lines\n if (\n trimmed.startsWith(\"- \") ||\n trimmed.startsWith(\"* \") ||\n trimmed.match(/^\\d+\\./) ||\n trimmed.startsWith(\"**\")\n ) {\n const normalized = normalizeLine(trimmed);\n if (seen.has(normalized)) continue;\n if (charCount + trimmed.length + 1 > maxChars) break;\n seen.add(normalized);\n selected.push(trimmed);\n charCount += trimmed.length + 1;\n }\n }\n\n return selected.join(\"\\n\");\n}\n\n/**\n * Build the L1 essential story block.\n *\n * Reads the most recent session notes for the project and extracts the key\n * lines (Work Done, Key Decisions, Next Steps) within the token budget.\n *\n * @param rootPath The project root path (from the registry).\n * @param tokenBudget Max tokens to consume. Default 800 (~3200 chars).\n * @returns Formatted L1 block, or empty string if no notes found.\n */\nexport function buildL1EssentialStory(\n rootPath: string,\n tokenBudget = L1_TOKEN_BUDGET\n): string {\n const charBudget = tokenBudget * 4;\n const notesDir = findNotesDirForProject(rootPath);\n if (!notesDir) return \"\";\n\n const noteFiles = findSessionNotes(notesDir).slice(0, L1_MAX_NOTES);\n if (noteFiles.length === 0) return \"\";\n\n const sections: string[] = [];\n const seen = new Set<string>();\n let remaining = charBudget;\n\n for (const noteFile of noteFiles) {\n if (remaining <= 50) break;\n\n let content: string;\n try {\n content = readFileSync(noteFile, \"utf-8\");\n } catch {\n continue;\n }\n\n // Extract the note date and title from the filename\n const name = basename(noteFile);\n const titleMatch = name.match(/^\\d+ - (\\d{4}-\\d{2}-\\d{2}) - (.+)\\.md$/);\n const dateLabel = titleMatch ? titleMatch[1] : \"\";\n const titleLabel = titleMatch\n ? titleMatch[2]\n : name.replace(/^\\d+ - /, \"\").replace(/\\.md$/, \"\");\n\n // Skip if nothing useful extracted from this note\n const perNoteChars = Math.min(remaining, Math.floor(charBudget / noteFiles.length) + 200);\n const extracted = extractKeyLines(content, perNoteChars, seen);\n if (!extracted) continue;\n\n const noteBlock = `[${dateLabel} - ${titleLabel}]\\n${extracted}`;\n sections.push(noteBlock);\n remaining -= noteBlock.length + 1;\n }\n\n if (sections.length === 0) return \"\";\n\n return sections.join(\"\\n\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Combined: buildWakeupContext\n// ---------------------------------------------------------------------------\n\n/**\n * Build the combined wake-up context block (L0 + L1).\n *\n * Returns a formatted string suitable for injection as a system-reminder,\n * or an empty string if both layers are empty.\n *\n * @param rootPath Project root path for L1 note lookup. Optional.\n * @param tokenBudget L1 token budget. Default 800.\n */\nexport function buildWakeupContext(\n rootPath?: string,\n tokenBudget = L1_TOKEN_BUDGET\n): string {\n const identity = loadL0Identity();\n const essentialStory = rootPath\n ? buildL1EssentialStory(rootPath, tokenBudget)\n : \"\";\n\n if (!identity && !essentialStory) return \"\";\n\n const parts: string[] = [];\n\n if (identity) {\n parts.push(`## L0 Identity\\n\\n${identity}`);\n }\n\n if (essentialStory) {\n parts.push(`## L1 Essential Story\\n\\n${essentialStory}`);\n }\n\n return parts.join(\"\\n\\n\");\n}\n"],"mappings":"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"}
@@ -45,14 +45,18 @@ Generated man pages for every `pai` command area. Read any page in the terminal
45
45
  | Command | Description |
46
46
  |---------|-------------|
47
47
  | `pai audit` | Diagnostic audits: token-waste report and friends |
48
- | `pai audit tokens` | Token-waste audit: memory files, hooks, session usage, spawn overhead, daemon, env, schedule |
48
+ | `pai audit tokens` | Token-waste audit: memory files, hooks, session usage, spawn overhead, daemon, env, schedule, skill catalogue, subagents, MCP |
49
49
  | `pai audit tokens daemon` | LLM spawns, KG-extraction parse failures, work-queue counts from the daemon log |
50
50
  | `pai audit tokens env` | ANTHROPIC_BASE_URL / model-override env on every live claude process |
51
51
  | `pai audit tokens files [paths...]` | Token count per memory file (CLAUDE.md chain, CORE skill, whisper rules, auto-memory) |
52
52
  | `pai audit tokens hooks` | Token cost of every SessionStart / UserPromptSubmit hook |
53
+ | `pai audit tokens ladder` | LIVE: first-turn context at increasing headless-worker configuration (spawns real claude -p calls) |
54
+ | `pai audit tokens mcp` | MCP servers: configured vs. pinned vs. loaded-live vs. used in the last 30 days |
53
55
  | `pai audit tokens schedule` | Launchd agents / crontab entries that outpace the measured prompt-cache TTL |
54
56
  | `pai audit tokens session [path]` | Cache/input/output token split for a session transcript (default: newest) |
57
+ | `pai audit tokens skills` | Token cost of the SKILL.md / command / plugin catalogue, top entries, duplicate names |
55
58
  | `pai audit tokens spawn` | Spawn-overhead comparison: Agent-tool subagents vs. pai workers |
59
+ | `pai audit tokens subagents` | Token cost of Claude Code subagent definitions (~/.claude/agents, <cwd>/.claude/agents) and their model pinning |
56
60
  | `pai backup` | Backup registry, config, and Postgres database to PAI_HOME/backups/ |
57
61
  | `pai clear-names` | Recovery: wipe corrupted iTerm2 session name state. |
58
62
  | `pai config` | PAI_HOME namespace dir: path, migrate |
@@ -14,17 +14,18 @@ pai audit <subcommand> [options]
14
14
 
15
15
  | Command | Description |
16
16
  |---------|-------------|
17
- | [`pai audit tokens`](#pai-audit-tokens) | Token-waste audit: memory files, hooks, session usage, spawn overhead, daemon, env, schedule |
17
+ | [`pai audit tokens`](#pai-audit-tokens) | Token-waste audit: memory files, hooks, session usage, spawn overhead, daemon, env, schedule, skill catalogue, subagents, MCP |
18
18
 
19
19
  ### pai audit tokens
20
20
 
21
- Token-waste audit: memory files, hooks, session usage, spawn overhead, daemon, env, schedule
21
+ Token-waste audit: memory files, hooks, session usage, spawn overhead, daemon, env, schedule, skill catalogue, subagents, MCP
22
22
 
23
23
  **Options**
24
24
 
25
25
  | Option | Description | Default |
26
26
  |--------|-------------|---------|
27
27
  | `--json` | Print JSON instead of a table | |
28
+ | `--record <dir>` | Write a dated, numbered markdown+JSON snapshot of the combined report to <dir> | |
28
29
 
29
30
 
30
31
  ### pai audit tokens files [paths...]
@@ -53,6 +54,12 @@ Cache/input/output token split for a session transcript (default: newest)
53
54
  |----------|------|-------------|
54
55
  | `[path]` | optional | Session JSONL path (default: newest under ~/.claude/projects) |
55
56
 
57
+ **Options**
58
+
59
+ | Option | Description | Default |
60
+ |--------|-------------|---------|
61
+ | `--ctx-threshold <n>` | Per-turn context size above which a turn counts as 'above threshold' (default 200000) | |
62
+
56
63
 
57
64
  ### pai audit tokens spawn
58
65
 
@@ -63,6 +70,7 @@ Spawn-overhead comparison: Agent-tool subagents vs. pai workers
63
70
  | Option | Description | Default |
64
71
  |--------|-------------|---------|
65
72
  | `-n, --n <count>` | How many of the newest logs per group to read (default 10) | |
73
+ | `--detail` | Print one row per log (first-turn ctx, prompt tokens, overhead, model, path) | |
66
74
 
67
75
 
68
76
  ### pai audit tokens daemon
@@ -80,6 +88,46 @@ ANTHROPIC_BASE_URL / model-override env on every live claude process
80
88
  Launchd agents / crontab entries that outpace the measured prompt-cache TTL
81
89
 
82
90
 
91
+ ### pai audit tokens skills
92
+
93
+ Token cost of the SKILL.md / command / plugin catalogue, top entries, duplicate names
94
+
95
+ **Options**
96
+
97
+ | Option | Description | Default |
98
+ |--------|-------------|---------|
99
+ | `--top <n>` | How many top-by-tokens entries to show (default 15) | |
100
+
101
+
102
+ ### pai audit tokens subagents
103
+
104
+ Token cost of Claude Code subagent definitions (~/.claude/agents, <cwd>/.claude/agents) and their model pinning
105
+
106
+
107
+ ### pai audit tokens mcp
108
+
109
+ MCP servers: configured vs. pinned vs. loaded-live vs. used in the last 30 days
110
+
111
+ **Options**
112
+
113
+ | Option | Description | Default |
114
+ |--------|-------------|---------|
115
+ | `--connect` | Actually connect to every stdio MCP server to count exposed tools (side-effecting; never on by default) | |
116
+
117
+
118
+ ### pai audit tokens ladder
119
+
120
+ LIVE: first-turn context at increasing headless-worker configuration (spawns real claude -p calls)
121
+
122
+ **Options**
123
+
124
+ | Option | Description | Default |
125
+ |--------|-------------|---------|
126
+ | `--live` | Actually spawn claude -p (billed API traffic); refuses without this flag | |
127
+ | `--model <model>` | Model to probe with (default haiku) | |
128
+ | `--mcp-config <path>` | MCP config for the L2 rung (default: newest *.mcp.json in the workers log dir) | |
129
+
130
+
83
131
  ## See also
84
132
 
85
133
  [`pai backup`](backup.md) · [`pai clear-names`](clear-names.md) · [`pai config`](config.md) · [`pai daemon`](daemon.md) · [`pai db`](db.md) · [`pai end`](end.md) · [`pai help`](help.md) · [`pai identity`](identity.md) · [`pai kg`](kg.md) · [`pai launch`](launch.md) · [`pai mcp`](mcp.md) · [`pai memory`](memory.md) · [`pai notify`](notify.md) · [`pai observation`](observation.md) · [`pai obsidian`](obsidian.md) · [`pai pause`](pause.md) · [`pai project`](project.md) · [`pai projects`](projects.md) · [`pai registry`](registry.md) · [`pai restore`](restore.md) · [`pai session`](session.md) · [`pai sessions`](sessions.md) · [`pai setup`](setup.md) · [`pai shell-init`](shell-init.md) · [`pai skill`](skill.md) · [`pai task`](task.md) · [`pai topic`](topic.md) · [`pai update`](update.md) · [`pai worker`](worker.md) · [`pai zettel`](zettel.md)
@@ -86,6 +86,8 @@ Grant MCP tools by naming mcp__server__tool in --allowedTools (the server loads
86
86
  | `--worktree` | Run in a git worktree on branch worker/<id> (default for implement/complex/plan in a git repo) | |
87
87
  | `--no-worktree` | Run in place, no worktree | |
88
88
  | `--print-cmd` | Print the assembled claude argv as JSON and exit, without spawning (audit tool) | |
89
+ | `--report <format>` | Final-report contract/parser: json or ag2 (default: ag2, or PAI_WORKER_REPORT) | |
90
+ | `--no-report-retry` | Skip the one bounded re-ask when the final AG2 message fails validation | |
89
91
 
90
92
 
91
93
  ### pai worker ps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekmidian/pai",
3
- "version": "0.42.6",
3
+ "version": "0.44.0",
4
4
  "description": "PAI Knowledge OS — Personal AI Infrastructure with federated memory and project management",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -238,6 +238,8 @@ if (doSync && isInsideWorkerWorktree(process.cwd())) {
238
238
  ["src/hooks/pre-compact.sh", "pai-pre-compact.sh"],
239
239
  ["src/hooks/session-stop.sh", "pai-session-stop.sh"],
240
240
  ["src/hooks/session-autosave.sh", "pai-session-autosave.sh"],
241
+ ["src/hooks/ztk-auto.sh", "ztk-auto.sh"],
242
+ ["src/hooks/ztk-passthrough.mjs", "ztk-passthrough.mjs"],
241
243
  ];
242
244
  for (const [src, destName] of shellHooks) {
243
245
  if (existsSync(src)) {
@@ -632,6 +632,46 @@ describe("contextFillThresholds — trigger source", () => {
632
632
  });
633
633
  });
634
634
 
635
+ // ---------------------------------------------------------------------------
636
+ // contextFillThresholds — the 2026-09-20 regime change (override 80 -> 20 on
637
+ // a 1M window). Same min(measured, configured) formula, no code change
638
+ // needed, but the specific numbers of that day are worth pinning down: a
639
+ // project's measured history (784k, from the prior 78-79% regime) is now
640
+ // HIGHER than the newly configured 20%-of-1M trigger (200k), so the clamp
641
+ // must take the configured value, not the stale measured one.
642
+ // ---------------------------------------------------------------------------
643
+
644
+ describe("contextFillThresholds — 2026-09-20 regime change (override 80 -> 20)", () => {
645
+ it("measured 784k + configured 200k (override=20, 1M window) -> clamps to 200k", () => {
646
+ const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "20" }, { measuredTrigger: 784_000 });
647
+ expect(t.configuredTriggerTokens).toBe(200_000);
648
+ expect(t.triggerSource).toBe("measured-clamped");
649
+ expect(t.effectiveTriggerTokens).toBe(200_000);
650
+ expect(t.warmupTokens).toBe(100_000); // 200,000 - 100,000
651
+ });
652
+
653
+ it("measured 784k + configured 800k (override=80, 1M window) -> measured wins at 784k", () => {
654
+ const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" }, { measuredTrigger: 784_000 });
655
+ expect(t.configuredTriggerTokens).toBe(800_000);
656
+ expect(t.triggerSource).toBe("measured");
657
+ expect(t.effectiveTriggerTokens).toBe(784_000);
658
+ });
659
+
660
+ it("measured null + configured 200k (override=20, no compaction history yet) -> 200k", () => {
661
+ const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "20" }, { measuredTrigger: null });
662
+ expect(t.triggerSource).toBe("configured");
663
+ expect(t.effectiveTriggerTokens).toBe(200_000);
664
+ });
665
+
666
+ it("measured 784k + override unset (defaults to 80, configured 800k) -> measured wins at 784k", () => {
667
+ const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 784_000 });
668
+ expect(t.autocompactPct).toBe(80);
669
+ expect(t.configuredTriggerTokens).toBe(800_000);
670
+ expect(t.triggerSource).toBe("measured");
671
+ expect(t.effectiveTriggerTokens).toBe(784_000);
672
+ });
673
+ });
674
+
635
675
  // ---------------------------------------------------------------------------
636
676
  // Foreign-model transcripts — a headless worker on another provider (a
637
677
  // different context window) shares the project folder; its compactions must
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync, existsSync, readdirSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+
7
+ // whisper-reinject.ts calls main() unconditionally at module scope, including
8
+ // a read of fd 0 — same reason as session-start-worker-guard.test.ts and
9
+ // user-prompt/whisper-rules.test.ts, run it as a real subprocess with its own
10
+ // piped stdin and an isolated TMPDIR so its counter file never collides with
11
+ // a real session's.
12
+ describe("post-tool-use/whisper-reinject entrypoint", () => {
13
+ const ENTRYPOINT = "src/hooks/ts/post-tool-use/whisper-reinject.ts";
14
+ let isolatedTmp: string;
15
+
16
+ beforeEach(() => {
17
+ isolatedTmp = mkdtempSync(join(tmpdir(), "whisper-reinject-test-"));
18
+ });
19
+
20
+ afterEach(() => {
21
+ rmSync(isolatedTmp, { recursive: true, force: true });
22
+ });
23
+
24
+ function runHook(sessionId: string, env: NodeJS.ProcessEnv = {}): string {
25
+ return execFileSync("bun", [ENTRYPOINT], {
26
+ input: JSON.stringify({ session_id: sessionId, hook_event_name: "PostToolUse" }),
27
+ encoding: "utf8",
28
+ timeout: 15_000,
29
+ // This test suite itself may run inside a worker (PAI_WORKER=1 in
30
+ // process.env) — explicitly clear it so "without the guard" cases are
31
+ // not silently exercising the guarded path instead.
32
+ env: { ...process.env, PAI_WORKER: "", TMPDIR: isolatedTmp, ...env },
33
+ });
34
+ }
35
+
36
+ it("prints nothing and does not touch the counter file when PAI_WORKER=1", () => {
37
+ const stdout = runHook("t", { PAI_WORKER: "1" });
38
+ expect(stdout).toBe("");
39
+
40
+ const counterDir = join(isolatedTmp, "pai-whisper-reinject");
41
+ expect(existsSync(counterDir)).toBe(false);
42
+ });
43
+
44
+ it("stays quiet below the threshold and emits the reminder once enough calls have passed", () => {
45
+ let stdout = "";
46
+ for (let i = 0; i < 8; i++) {
47
+ stdout = runHook("t");
48
+ }
49
+ expect(stdout).toContain("<system-reminder>");
50
+ expect(stdout).toContain("tool calls into this turn");
51
+
52
+ const counterDir = join(isolatedTmp, "pai-whisper-reinject");
53
+ expect(readdirSync(counterDir).length).toBe(1);
54
+ });
55
+ });
@@ -24,6 +24,7 @@
24
24
  import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
25
  import { dirname, join } from "node:path";
26
26
  import { tmpdir } from "node:os";
27
+ import { isWorkerSession } from "../lib/worker-session.js";
27
28
 
28
29
  /** Stay silent below this many tool calls — a short turn has not drifted yet. */
29
30
  const QUIET_BELOW = 6;
@@ -69,6 +70,8 @@ function main(): void {
69
70
  if (raw.trim()) sessionId = (JSON.parse(raw) as { session_id?: string }).session_id ?? "";
70
71
  } catch { /* no session id — fall back to a shared counter */ }
71
72
 
73
+ if (isWorkerSession()) return;
74
+
72
75
  const n = bump(counterFile(sessionId));
73
76
  if (n < QUIET_BELOW || n % EVERY !== 0) return;
74
77
 
@@ -498,9 +498,9 @@ Session Commands:
498
498
  // 8.5. INJECT THE PAUSE CHECKPOINT
499
499
  //
500
500
  // `pai pause` writes the handover to TODO.md under `## Continue`. Until now
501
- // nothing read it back: the session-commands hook only told the model to look
502
- // there *if the user typed "go"*, so a resumed session started blind unless
503
- // the user knew the magic word. Writing a handover nobody delivers is not a
501
+ // nothing read it back: CORE SKILL.md only told the model to look there *if
502
+ // the user typed "go"*, so a resumed session started blind unless the user
503
+ // knew the magic word. Writing a handover nobody delivers is not a
504
504
  // handover. Inject it, and say plainly that it is the previous session's
505
505
  // state rather than an instruction to act on.
506
506
  if (existsSync(todoPath)) {
@@ -11,8 +11,8 @@
11
11
  * call: see ../lib/mcp-deferred-reminder.ts for the rationale and text.
12
12
  *
13
13
  * stdout is captured by Claude Code and injected into the session context,
14
- * same channel as session-commands/inject-observations. Never blocks session
15
- * start: always exits 0.
14
+ * same channel as inject-observations. Never blocks session start: always
15
+ * exits 0.
16
16
  */
17
17
 
18
18
  import { decideMcpDeferredReminder, type ReminderHookInput } from "../lib/mcp-deferred-reminder.js";
@@ -15,7 +15,6 @@ describe("session-start hooks: worker sessions get no injected context", () => {
15
15
  "src/hooks/ts/session-start/load-project-context.ts",
16
16
  "src/hooks/ts/session-start/mcp-deferred-reminder.ts",
17
17
  "src/hooks/ts/session-start/post-compact-inject.ts",
18
- "src/hooks/ts/session-start/session-commands.ts",
19
18
  ];
20
19
 
21
20
  function runHook(entrypoint: string, env: NodeJS.ProcessEnv): string {
@@ -0,0 +1,28 @@
1
+ #!/bin/bash
2
+ # Wrapper around ztk rewrite that auto-approves and passes through commands
3
+ # whose output ztk would compact lossily (plain-text tools, interpreters,
4
+ # pipelines already tailed/headed/counted, or anything requesting JSON output).
5
+ # Changes permissionDecision from "ask" to "allow" for everything else.
6
+ # Also handles empty responses from ztk (passthrough).
7
+
8
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ stdin="$(cat)"
10
+
11
+ command="$(printf '%s' "$stdin" | jq -r '.tool_input.command // empty' 2>/dev/null)"
12
+
13
+ if [ -n "$command" ]; then
14
+ decision="$(node "$script_dir/ztk-passthrough.mjs" "$command" 2>/dev/null)"
15
+ if [ "$decision" = "yes" ]; then
16
+ exit 0
17
+ fi
18
+ fi
19
+
20
+ result=$(printf '%s' "$stdin" | ztk rewrite 2>/dev/null)
21
+
22
+ if [ -z "$result" ]; then
23
+ # ztk returned nothing - allow the command as-is
24
+ echo '{"permissionDecision":"allow"}'
25
+ else
26
+ # ztk returned something - change ask to allow
27
+ echo "$result" | sed 's/"permissionDecision":"ask"/"permissionDecision":"allow"/'
28
+ fi
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+
7
+ // ztk-auto.sh reads the PreToolUse payload from stdin, decides whether the
8
+ // command should bypass ztk's (lossy, for short/structured output) rewrite
9
+ // step via ztk-passthrough.mjs, and otherwise shells out to the real `ztk`
10
+ // binary. A pass-through decision prints nothing and exits 0 — Claude Code
11
+ // treats a silent PreToolUse hook as "no opinion", i.e. the command runs
12
+ // untouched. A non-pass-through decision always produces JSON output.
13
+ describe("ztk-auto.sh", () => {
14
+ const SCRIPT = "src/hooks/ztk-auto.sh";
15
+ let fakeBinDir: string;
16
+
17
+ // Stub `ztk` so the test doesn't depend on the real binary being installed
18
+ // or on its exact output shape — only that ztk-auto.sh calls it and
19
+ // forwards its (sed-patched) output for non-pass-through commands.
20
+ beforeAll(() => {
21
+ fakeBinDir = mkdtempSync(join(tmpdir(), "ztk-auto-test-bin-"));
22
+ const fakeZtk = join(fakeBinDir, "ztk");
23
+ writeFileSync(
24
+ fakeZtk,
25
+ [
26
+ "#!/bin/bash",
27
+ "cat > /dev/null",
28
+ 'echo \'{"permissionDecision":"ask","hookSpecificOutput":{"updatedInput":{"command":"ztk run npm test"}}}\'',
29
+ ].join("\n"),
30
+ );
31
+ chmodSync(fakeZtk, 0o755);
32
+ });
33
+
34
+ afterAll(() => {
35
+ rmSync(fakeBinDir, { recursive: true, force: true });
36
+ });
37
+
38
+ function runHook(command: string): string {
39
+ return execFileSync("bash", [SCRIPT], {
40
+ input: JSON.stringify({
41
+ hook_event_name: "PreToolUse",
42
+ tool_name: "Bash",
43
+ tool_input: { command },
44
+ }),
45
+ encoding: "utf8",
46
+ timeout: 15_000,
47
+ env: { ...process.env, PATH: `${fakeBinDir}:${process.env.PATH}` },
48
+ });
49
+ }
50
+
51
+ it("passes through a plain ls (empty stdout, command runs untouched)", () => {
52
+ expect(runHook("ls -la src")).toBe("");
53
+ });
54
+
55
+ it("sends an unrecognised command to ztk and rewrites it", () => {
56
+ const stdout = runHook("npm test");
57
+ expect(stdout).toContain("ztk run");
58
+ expect(stdout).toContain('"permissionDecision":"allow"');
59
+ });
60
+
61
+ it("passes through a cd-prefixed wc command", () => {
62
+ expect(runHook("cd /x && wc -l a")).toBe("");
63
+ });
64
+
65
+ it("passes through a pipeline whose last stage is tail", () => {
66
+ expect(runHook("cargo build 2>&1 | tail -20")).toBe("");
67
+ });
68
+
69
+ it("passes through a command requesting --json output", () => {
70
+ expect(runHook("pai worker ps --json")).toBe("");
71
+ });
72
+ });
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ztk-passthrough — decide whether a Bash command's output should skip
4
+ * ztk's rewrite/compaction step.
5
+ *
6
+ * ztk compacts aggressively and is lossy for short, already-structured
7
+ * output: a plain `ls` came back with a file misreported as a directory,
8
+ * and `wc -l ... | tail -1` came back summarised instead of verbatim.
9
+ * Interpreters, text-processing tools, anything already piped through
10
+ * head/tail/wc, and anything asking for machine-readable JSON output must
11
+ * keep their exact stdout, so they bypass ztk entirely.
12
+ *
13
+ * Prints "yes" (pass through, skip ztk) or "no" (send to ztk) to stdout.
14
+ */
15
+
16
+ const PASSTHROUGH_COMMANDS = new Set([
17
+ "ls", "wc", "echo", "printf", "pwd", "which", "whoami", "date",
18
+ "basename", "dirname", "readlink", "stat", "du", "df",
19
+ "head", "tail", "cat", "jq", "sed", "awk", "sort", "uniq", "tr", "cut",
20
+ "true", "test", "[",
21
+ "python3", "node", "bun",
22
+ ]);
23
+
24
+ const LAST_STAGE_PASSTHROUGH = new Set(["head", "tail", "wc"]);
25
+
26
+ const VAR_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\S*)\s+/;
27
+ const CD_PREFIX = /^cd\s+(?:'[^']*'|"[^"]*"|\S+)\s*&&\s*/;
28
+ const ENV_PREFIX = /^env\s+/;
29
+ const TIMEOUT_PREFIX = /^timeout\s+\S+\s+/;
30
+
31
+ /** Strip leading `cd … &&`, `env …`, `timeout N`, and variable assignments, in any order. */
32
+ function stripLeaders(command) {
33
+ let cur = command.trimStart();
34
+ for (let i = 0; i < 20; i++) {
35
+ const before = cur;
36
+ cur = cur.replace(CD_PREFIX, "").replace(ENV_PREFIX, "").replace(TIMEOUT_PREFIX, "").replace(VAR_ASSIGN, "");
37
+ cur = cur.trimStart();
38
+ if (cur === before) break;
39
+ }
40
+ return cur;
41
+ }
42
+
43
+ function firstWord(command) {
44
+ return command.trim().split(/\s+/)[0] ?? "";
45
+ }
46
+
47
+ function lastPipelineStage(command) {
48
+ const stages = command.split("|");
49
+ return stages[stages.length - 1] ?? "";
50
+ }
51
+
52
+ export function shouldPassThrough(rawCommand) {
53
+ const command = rawCommand ?? "";
54
+ if (!command.trim()) return false;
55
+
56
+ if (command.includes("--output-format json") || command.includes("--json")) return true;
57
+
58
+ const lastStageFirstWord = firstWord(lastPipelineStage(command));
59
+ if (LAST_STAGE_PASSTHROUGH.has(lastStageFirstWord)) return true;
60
+
61
+ const stripped = stripLeaders(command);
62
+ const head = firstWord(stripped);
63
+ return PASSTHROUGH_COMMANDS.has(head);
64
+ }
65
+
66
+ function main() {
67
+ const command = process.argv[2] ?? "";
68
+ console.log(shouldPassThrough(command) ? "yes" : "no");
69
+ }
70
+
71
+ main();