@tekmidian/pai 0.46.0 → 0.48.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 (34) hide show
  1. package/dist/{chain-DAEC9AU7.mjs → chain-CNSa0A3G.mjs} +2 -2
  2. package/dist/{chain-DAEC9AU7.mjs.map → chain-CNSa0A3G.mjs.map} +1 -1
  3. package/dist/cli/index.mjs +4 -4
  4. package/dist/cli/program.mjs +4 -4
  5. package/dist/daemon/index.mjs +2 -2
  6. package/dist/{daemon-Dq2ZSdex.mjs → daemon-CYpVy_Dw.mjs} +2 -2
  7. package/dist/{daemon-DNBEFHaj.mjs → daemon-DZ4Q0jrs.mjs} +2 -2
  8. package/dist/{daemon-DNBEFHaj.mjs.map → daemon-DZ4Q0jrs.mjs.map} +1 -1
  9. package/dist/daemon-mcp/index.mjs +3 -3
  10. package/dist/{fallback-CxGDTXWt.mjs → fallback-IeR2FrFn.mjs} +6 -6
  11. package/dist/fallback-IeR2FrFn.mjs.map +1 -0
  12. package/dist/hooks/load-core-context.mjs +9 -11
  13. package/dist/hooks/load-core-context.mjs.map +3 -3
  14. package/dist/hooks/load-project-context.mjs +59 -6
  15. package/dist/hooks/load-project-context.mjs.map +3 -3
  16. package/dist/hooks/worker-status-line.mjs +23 -4
  17. package/dist/hooks/worker-status-line.mjs.map +3 -3
  18. package/dist/{planner-B4DvNpXd.mjs → planner-Cn8HmT7l.mjs} +3 -3
  19. package/dist/{planner-B4DvNpXd.mjs.map → planner-Cn8HmT7l.mjs.map} +1 -1
  20. package/dist/{program-Dk_EIMqC.mjs → program-Bgv1lFFA.mjs} +155 -16
  21. package/dist/program-Bgv1lFFA.mjs.map +1 -0
  22. package/dist/{run-BgExF8Xg.mjs → run-Cls0YGgV.mjs} +43 -6
  23. package/dist/run-Cls0YGgV.mjs.map +1 -0
  24. package/docs/commands/audit.md +1 -0
  25. package/package.json +1 -1
  26. package/src/hooks/ts/lib/frontmatter.test.ts +19 -0
  27. package/src/hooks/ts/lib/frontmatter.ts +10 -0
  28. package/src/hooks/ts/lib/handover-budget.test.ts +124 -0
  29. package/src/hooks/ts/lib/handover-budget.ts +94 -0
  30. package/src/hooks/ts/session-start/load-core-context.ts +6 -15
  31. package/src/hooks/ts/session-start/load-project-context.ts +9 -6
  32. package/dist/fallback-CxGDTXWt.mjs.map +0 -1
  33. package/dist/program-Dk_EIMqC.mjs.map +0 -1
  34. package/dist/run-BgExF8Xg.mjs.map +0 -1
@@ -59,6 +59,7 @@ Cache/input/output token split for a session transcript (default: newest)
59
59
  | Option | Description | Default |
60
60
  |--------|-------------|---------|
61
61
  | `--ctx-threshold <n>` | Per-turn context size above which a turn counts as 'above threshold' (default 200000) | |
62
+ | `--history [n]` | List first-turn context of the newest n sessions in this project's transcript directory (default 20) | |
62
63
 
63
64
 
64
65
  ### pai audit tokens spawn
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekmidian/pai",
3
- "version": "0.46.0",
3
+ "version": "0.48.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",
@@ -0,0 +1,19 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { stripFrontmatter } from "./frontmatter.js";
3
+
4
+ describe("stripFrontmatter", () => {
5
+ it("strips a leading YAML frontmatter block and trims surrounding blank lines", () => {
6
+ const md = `---\nname: CORE\ndescription: some skill\n---\n\nBody text here.\n`;
7
+ expect(stripFrontmatter(md)).toBe("Body text here.");
8
+ });
9
+
10
+ it("leaves content unchanged when there is no frontmatter", () => {
11
+ const md = `Body text here.\nMore text.\n`;
12
+ expect(stripFrontmatter(md)).toBe("Body text here.\nMore text.");
13
+ });
14
+
15
+ it("leaves a --- that appears inside the body (not at the top) alone", () => {
16
+ const md = `---\nname: CORE\n---\n\nSection one.\n\n---\n\nSection two.\n`;
17
+ expect(stripFrontmatter(md)).toBe("Section one.\n\n---\n\nSection two.");
18
+ });
19
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Strip a leading YAML frontmatter block (`---` ... `---` at the very top of
3
+ * the file) and trim surrounding blank lines. A `---` that appears later in
4
+ * the body (not at the top) is left untouched.
5
+ */
6
+ export function stripFrontmatter(md: string): string {
7
+ const match = md.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
8
+ const body = match ? md.slice(match[0].length) : md;
9
+ return body.replace(/^\s+|\s+$/g, '');
10
+ }
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { applyHandoverBudget, HANDOVER_CHAR_BUDGET } from "./handover-budget.js";
3
+
4
+ const SOURCE = "/tmp/TODO.md";
5
+
6
+ function field(key: string, charCount: number): string {
7
+ return `${key}=${"x".repeat(Math.max(0, charCount - key.length - 1))}`;
8
+ }
9
+
10
+ describe("applyHandoverBudget", () => {
11
+ it("returns the body unchanged when under budget", () => {
12
+ const body = "T\ng=finish the thing\nz=all good";
13
+ const result = applyHandoverBudget(body, SOURCE, 2400);
14
+ expect(result).toEqual({ body, elided: [], truncated: false });
15
+ });
16
+
17
+ it("elides only d= when that alone brings the body under budget", () => {
18
+ const budget = 200;
19
+ const body = ["T", "g=goal line", field("d", 300), "z=state"].join("\n");
20
+ const result = applyHandoverBudget(body, SOURCE, budget);
21
+ expect(result.elided).toEqual(["d"]);
22
+ expect(result.truncated).toBe(false);
23
+ expect(result.body).toContain(`d= elided, 300 chars; full text in ${SOURCE} (## Continue)`);
24
+ expect(result.body).toContain("g=goal line");
25
+ expect(result.body).toContain("z=state");
26
+ expect(result.body.length).toBeLessThanOrEqual(budget);
27
+ });
28
+
29
+ it("elides both d= and t= when d= alone is not enough", () => {
30
+ const budget = 200;
31
+ const body = ["T", "g=goal line", field("d", 300), field("t", 300), "z=state"].join("\n");
32
+ const result = applyHandoverBudget(body, SOURCE, budget);
33
+ expect(result.elided).toEqual(["d", "t"]);
34
+ expect(result.truncated).toBe(false);
35
+ expect(result.body).toContain(`d= elided, 300 chars; full text in ${SOURCE} (## Continue)`);
36
+ expect(result.body).toContain(`t= elided, 300 chars; full text in ${SOURCE} (## Continue)`);
37
+ expect(result.body).toContain("g=goal line");
38
+ expect(result.body).toContain("z=state");
39
+ });
40
+
41
+ it("hard-truncates prose with no Agentish fields", () => {
42
+ const budget = 50;
43
+ const body = "This is a long plain-prose handover with no fields at all, just text. ".repeat(5);
44
+ const result = applyHandoverBudget(body, SOURCE, budget);
45
+ expect(result.truncated).toBe(true);
46
+ expect(result.elided).toEqual([]);
47
+ expect(result.body.startsWith(body.slice(0, budget))).toBe(true);
48
+ expect(result.body).toContain(`[handover truncated at ${budget} chars; full text in ${SOURCE} (## Continue)]`);
49
+ });
50
+
51
+ it("hard-truncates when eliding d= and t= still leaves the body over budget", () => {
52
+ const budget = 60;
53
+ const body = ["T", "g=goal line", field("d", 300), field("t", 300), "z=state"].join("\n");
54
+ const result = applyHandoverBudget(body, SOURCE, budget);
55
+ expect(result.elided).toEqual(["d", "t"]);
56
+ expect(result.truncated).toBe(true);
57
+ expect(result.body).toContain(`[handover truncated at ${budget} chars; full text in ${SOURCE} (## Continue)]`);
58
+ });
59
+
60
+ it("keeps the preamble, g=, @n and z= lines verbatim", () => {
61
+ const budget = 150;
62
+ const body = [
63
+ "T",
64
+ "",
65
+ "g=finish the audit",
66
+ "@1=src/audit/severity.ts:190",
67
+ field("d", 400),
68
+ "z=part C in progress",
69
+ ].join("\n");
70
+ const result = applyHandoverBudget(body, SOURCE, budget);
71
+ expect(result.body).toContain("T\n\ng=finish the audit");
72
+ expect(result.body).toContain("@1=src/audit/severity.ts:190");
73
+ expect(result.body).toContain("z=part C in progress");
74
+ expect(result.elided).toEqual(["d"]);
75
+ });
76
+
77
+ it("drops a multi-line field (with continuation lines) as a whole when elided", () => {
78
+ const budget = 150;
79
+ const body = [
80
+ "T",
81
+ "g=goal",
82
+ "d=did the first thing",
83
+ ` ${"x".repeat(100)}`,
84
+ ` ${"y".repeat(100)}`,
85
+ "z=state",
86
+ ].join("\n");
87
+ const result = applyHandoverBudget(body, SOURCE, budget);
88
+ expect(result.body).not.toContain("xxx");
89
+ expect(result.body).not.toContain("yyy");
90
+ expect(result.body).toContain("d= elided,");
91
+ expect(result.elided).toEqual(["d"]);
92
+ });
93
+
94
+ it("uses the exported HANDOVER_CHAR_BUDGET as the default budget", () => {
95
+ const body = "z=" + "x".repeat(HANDOVER_CHAR_BUDGET * 2);
96
+ const result = applyHandoverBudget(body, SOURCE);
97
+ expect(result.truncated).toBe(true);
98
+ expect(result.body.length).toBeLessThan(body.length);
99
+ });
100
+
101
+ it("with realistic ~2300-char body and default 1600 budget: elides d= only, keeps g= and z= verbatim", () => {
102
+ const preamble = "T";
103
+ const i = "i=" + "x".repeat(23);
104
+ const g = "g=" + "x".repeat(358);
105
+ const d = "d=" + "x".repeat(968);
106
+ const t = "t=" + "x".repeat(238);
107
+ const pointers = [
108
+ "@1=" + "x".repeat(33),
109
+ "@2=" + "x".repeat(33),
110
+ "@3=" + "x".repeat(33),
111
+ "@4=" + "x".repeat(33),
112
+ "@5=" + "x".repeat(33),
113
+ ];
114
+ const z = "z=" + "x".repeat(528);
115
+ const body = [preamble, i, g, d, t, ...pointers, z].join("\n");
116
+ const result = applyHandoverBudget(body, SOURCE);
117
+ expect(result.elided).toEqual(["d"]);
118
+ expect(result.truncated).toBe(false);
119
+ expect(result.body).toContain(g);
120
+ expect(result.body).toContain(z);
121
+ expect(result.body).toContain("d= elided,");
122
+ expect(result.body.length).toBeLessThanOrEqual(HANDOVER_CHAR_BUDGET);
123
+ });
124
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * handover-budget.ts — cap the `## Continue` handover body injected at
3
+ * SessionStart.
4
+ *
5
+ * Text tokenises at ~2.6 chars per token; a 1,600-char budget is ~620 tokens.
6
+ * Together with the hook's 117-token fixed part, this stays under the 1,000-token
7
+ * SessionStart limit, eliding retrospective `d=` (done) and `t=` (tests) fields
8
+ * while preserving `g=` (goal), `@n` file pointers, and `z=` (state).
9
+ */
10
+
11
+ export const HANDOVER_CHAR_BUDGET = 1600;
12
+
13
+ export interface BudgetResult {
14
+ body: string;
15
+ elided: string[];
16
+ truncated: boolean;
17
+ }
18
+
19
+ interface Field {
20
+ key: string;
21
+ lines: string[];
22
+ }
23
+
24
+ const FIELD_START = /^([a-zA-Z@][a-zA-Z0-9]*)=/;
25
+
26
+ function splitFields(body: string): { preamble: string[]; fields: Field[] } {
27
+ const lines = body.split("\n");
28
+ const preamble: string[] = [];
29
+ const fields: Field[] = [];
30
+ let current: Field | null = null;
31
+ for (const line of lines) {
32
+ const m = line.match(FIELD_START);
33
+ if (m) {
34
+ current = { key: m[1], lines: [line] };
35
+ fields.push(current);
36
+ } else if (current) {
37
+ current.lines.push(line);
38
+ } else {
39
+ preamble.push(line);
40
+ }
41
+ }
42
+ return { preamble, fields };
43
+ }
44
+
45
+ function elisionLine(key: string, charCount: number, sourcePath: string): string {
46
+ return `${key}= elided, ${charCount} chars; full text in ${sourcePath} (## Continue)`;
47
+ }
48
+
49
+ function render(preamble: string[], fields: Field[]): string {
50
+ const parts = [...preamble];
51
+ for (const f of fields) parts.push(...f.lines);
52
+ return parts.join("\n");
53
+ }
54
+
55
+ /**
56
+ * Elide the `d=` and `t=` fields (in that order, only while still over
57
+ * budget) before falling back to a hard truncation — those two fields are
58
+ * retrospective, everything else (`g=`, `@n` pointers, `z=`) is what a
59
+ * resumer actually needs.
60
+ */
61
+ export function applyHandoverBudget(
62
+ body: string,
63
+ sourcePath: string,
64
+ budget: number = HANDOVER_CHAR_BUDGET
65
+ ): BudgetResult {
66
+ if (body.length <= budget) {
67
+ return { body, elided: [], truncated: false };
68
+ }
69
+
70
+ const { preamble, fields } = splitFields(body);
71
+ const elided: string[] = [];
72
+
73
+ if (fields.length > 0) {
74
+ for (const key of ["d", "t"]) {
75
+ if (render(preamble, fields).length <= budget) break;
76
+ const field = fields.find((f) => f.key === key);
77
+ if (!field) continue;
78
+ const charCount = field.lines.join("\n").length;
79
+ field.lines = [elisionLine(key, charCount, sourcePath)];
80
+ elided.push(key);
81
+ }
82
+ }
83
+
84
+ const result = render(preamble, fields);
85
+ if (fields.length === 0 || result.length > budget) {
86
+ const source = fields.length === 0 ? body : result;
87
+ const truncated =
88
+ source.slice(0, budget) +
89
+ `\n[handover truncated at ${budget} chars; full text in ${sourcePath} (## Continue)]`;
90
+ return { body: truncated, elided, truncated: true };
91
+ }
92
+
93
+ return { body: result, elided, truncated: false };
94
+ }
@@ -30,6 +30,7 @@ import { readFileSync, existsSync } from 'fs';
30
30
  import { join } from 'path';
31
31
  import { SKILLS_DIR } from '../lib/pai-paths';
32
32
  import { isProbeSession } from '../lib/project-utils';
33
+ import { stripFrontmatter } from '../lib/frontmatter.js';
33
34
 
34
35
  async function main() {
35
36
  if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping
@@ -80,23 +81,13 @@ async function main() {
80
81
 
81
82
  console.error(`Read ${coreContent.length} characters from CORE SKILL.md (Personalized for ${engineerName} & ${daName})`);
82
83
 
83
- // Determine the local timezone dynamically
84
- const localTimeZone = process.env.TIME_ZONE || Intl.DateTimeFormat().resolvedOptions().timeZone;
85
-
86
- // Output the CORE content as a system-reminder
87
- // This will be injected into Claude's context at session start
84
+ // Output the CORE content as a system-reminder, minus the frontmatter
85
+ // (catalogue metadata the session already has from its skill list) —
86
+ // this will be injected into Claude's context at session start
88
87
  const message = `<system-reminder>
89
- PAI CORE CONTEXT (Auto-loaded at Session Start)
90
-
91
- CURRENT DATE/TIME: ${new Date().toLocaleString('en-US', { timeZone: localTimeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZoneName: 'short' })}
92
-
93
- The following context has been loaded from ${coreSkillPath}:
94
-
95
- ---
96
- ${coreContent}
97
- ---
88
+ PAI CORE CONTEXT
98
89
 
99
- This context is now active for this session. Follow all instructions, preferences, and guidelines contained above.
90
+ ${stripFrontmatter(coreContent)}
100
91
  </system-reminder>`;
101
92
 
102
93
  // Write to stdout (will be captured by Claude Code)
@@ -23,6 +23,7 @@ import { homedir } from 'os';
23
23
  import { execSync } from 'child_process';
24
24
  import { buildWakeupContext } from '../../../memory/wakeup.js';
25
25
  import { readContinueCheckpoint } from '../../../session/checkpoint-block.js';
26
+ import { applyHandoverBudget } from '../lib/handover-budget.js';
26
27
  import { sessionRoutingPath } from '../lib/pai-paths.js';
27
28
  import {
28
29
  findNotesDir,
@@ -485,10 +486,6 @@ ${claudeMdPaths.length > 0 ? `CLAUDE.md: ${claudeMdPaths.join(', ')}` : 'No CLAU
485
486
  ${activeNotePath ? `Active Note: ${basename(activeNotePath)}` : ''}
486
487
  ${routedPath ? `\nNote Routing: ACTIVE (pai route is set - notes go to Obsidian vault)` : ''}
487
488
  ${paiProjectBlock ? `\n${paiProjectBlock}` : ''}
488
- Session Commands:
489
- - "pause session" → Save checkpoint, update TODO, exit (no compact)
490
- - "end session" → Finalize note, commit if needed, start fresh next time
491
- - "pai route clear" → Clear note routing (in a new session)
492
489
  </system-reminder>
493
490
  `;
494
491
 
@@ -516,6 +513,7 @@ Session Commands:
516
513
  const resume = checkpoint.meta?.sessionId
517
514
  ? `\nResume that session with: claude --resume ${checkpoint.meta.sessionId}`
518
515
  : '';
516
+ const budgeted = applyHandoverBudget(checkpoint.body, todoPath);
519
517
 
520
518
  console.log(`
521
519
  <system-reminder>
@@ -523,14 +521,19 @@ HANDOVER FROM THE PREVIOUS SESSION
523
521
 
524
522
  Source: ${todoPath} (## Continue)${from}${when}${resume}
525
523
 
526
- ${checkpoint.body}
524
+ ${budgeted.body}
527
525
 
528
526
  ---
529
527
  This is recorded state, not a new instruction. Do not start acting on it
530
528
  unprompted. If the user says "go", "continue", or "weiter", resume from here.
531
529
  </system-reminder>
532
530
  `);
533
- console.error(`Injected pause checkpoint (${checkpoint.body.length} chars)`);
531
+ const budgetNote = budgeted.truncated
532
+ ? ', truncated'
533
+ : budgeted.elided.length > 0
534
+ ? `, elided: ${budgeted.elided.join(',')}`
535
+ : '';
536
+ console.error(`Injected pause checkpoint (${checkpoint.body.length} chars${budgetNote})`);
534
537
  } else {
535
538
  console.error('No pause checkpoint body in TODO.md — nothing to hand over');
536
539
  }