pi-posthorse 0.10.2 → 0.10.4

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/README.md CHANGED
@@ -48,7 +48,12 @@ These tools are available to the model:
48
48
 
49
49
  Expand a tool card with Ctrl+O. In fullscreen mode, click a tool card to expand it. A `new_context` card shows a request. Pi's `Compacted from … tokens` card confirms the rollover.
50
50
 
51
- See [tool examples and paging details](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/reference.md#tools).
51
+ Forks get private copies of the notes used on their selected branch. The parent and fork can
52
+ update the same note name without overwriting each other. Project-wide lists and searches still
53
+ find both, with scope selectors for read-only comparisons.
54
+
55
+ See [tool examples and paging details](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/reference.md#tools)
56
+ and [fork notes](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/reference.md#forks-references-and-discovery).
52
57
 
53
58
  ## Settings and limits
54
59
 
@@ -80,7 +85,7 @@ Removal leaves notes and Pi session history in place. See the [installation refe
80
85
 
81
86
  ## Data and privacy
82
87
 
83
- Posthorse makes no network requests. Notes are plaintext files under `.pi/notes/`; linked Git worktrees share them. Notes for Pi-managed Git packages live outside their checkouts because `pi update` deletes untracked files there: global packages (including their descendants) use Pi's agent-directory `notes/`, and other project-local packages use the containing project's `.pi/package-notes/`, with project-local stores keyed by a full SHA-256 digest of the host and package path. Access existing checkout-local notes with this version before updating the package so they can migrate. Add `.pi/notes/` and `.pi/package-notes/` to the containing project's `.gitignore` if the notes must stay private.
88
+ Posthorse makes no network requests. Notes are plaintext files under `.pi/notes/`. Linked Git worktrees share that store; forks keep private note files inside its `.forks/` directory. Notes for Pi-managed Git packages live outside their checkouts because `pi update` deletes untracked files there: global packages (including their descendants) use Pi's agent-directory `notes/`, and other project-local packages use the containing project's `.pi/package-notes/`, with project-local stores keyed by a full SHA-256 digest of the host and package path. Access existing checkout-local notes with this version before updating the package so they can migrate. Add `.pi/notes/` and `.pi/package-notes/` to the containing project's `.gitignore` if the notes must stay private.
84
89
 
85
90
  History can include user messages, assistant thinking, tool arguments, errors, and images. Content you retrieve enters the context of your selected model and provider.
86
91
 
package/index.ts CHANGED
@@ -15,6 +15,8 @@ import type { CompactionPolicy, PolicyReader } from "./src/contracts.ts";
15
15
  import { registerHistory } from "./src/history.ts";
16
16
  import { safeJsonStringify } from "./src/message-text.ts";
17
17
  import { registerNotes } from "./src/notes.ts";
18
+ import { createNotesStore } from "./src/note-store.ts";
19
+ import { registerNoteGuards } from "./src/note-guards.ts";
18
20
  import { PageReservations } from "./src/paging.ts";
19
21
  import { registerRollover } from "./src/rollover.ts";
20
22
 
@@ -47,10 +49,18 @@ export function createPosthorse(getPolicy?: PolicyReader): (pi: ExtensionAPI) =>
47
49
  const toolTokens = () => activeToolTokens(pi);
48
50
  const pages = new PageReservations(toolTokens);
49
51
  registerPosthorseMessages(pi);
50
- registerRollover(pi, policy, toolTokens, () => {
51
- pages.reset();
52
+ const notes = createNotesStore(pi);
53
+ registerRollover(pi, policy, toolTokens, {
54
+ resetPages: () => {
55
+ pages.reset();
56
+ },
57
+ notes: (ctx) => notes.recovery(ctx),
52
58
  });
53
- registerNotes(pi, policy, pages);
59
+ pi.on("session_start", async (_event, ctx) => {
60
+ await notes.get(ctx);
61
+ });
62
+ registerNoteGuards(pi, notes);
63
+ registerNotes(pi, policy, pages, notes);
54
64
  registerHistory(pi, policy, pages);
55
65
  };
56
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "type": "module",
5
5
  "description": "Posthorse: fresh context, same journey. No-summary context rollover for Pi, with sparse budget reminders, handoffs, notes, and history.",
6
6
  "keywords": [
@@ -31,25 +31,28 @@
31
31
  "image": "https://raw.githubusercontent.com/fitchmultz/pi-posthorse/main/diagram.png"
32
32
  },
33
33
  "scripts": {
34
- "check": "tsc",
35
- "quality:prepare": "node scripts/quality-prepare.ts",
36
- "quality:policy": "node scripts/quality-policy.ts && node scripts/config-policy.ts",
37
- "quality:probes": "npm run quality:prepare && node --test test/quality*.test.ts",
38
- "lint": "npm run quality:prepare && npm run quality:policy && oxlint --deny-warnings .",
39
- "lint:agent": "npm run quality:prepare && npm run quality:policy && oxlint --deny-warnings --format=agent .",
40
- "lint:fix": "npm run quality:prepare && npm run quality:policy && oxlint --fix .",
34
+ "check": "tsc --noEmit",
35
+ "quality:policy": "node scripts/quality-policy.ts",
36
+ "quality:probes": "node --test test/quality*.test.ts",
37
+ "lint": "node scripts/verify.ts quality:policy quality:config lint:oxlint",
38
+ "lint:agent": "node scripts/verify.ts quality:policy quality:config lint:agent:oxlint",
39
+ "lint:fix": "node scripts/verify.ts quality:policy quality:config lint:fix:oxlint",
41
40
  "format": "oxfmt --write .",
42
41
  "format:check": "oxfmt --check .",
43
42
  "build": "npm pack --dry-run",
44
- "verify": "node scripts/toolchain-policy.ts && npm run lint && npm run format:check && npm run check:compat && npm run build",
45
- "check:compat": "npm run check && npm test",
43
+ "verify": "node scripts/toolchain-policy.ts && node scripts/verify.ts quality:policy quality:config lint:oxlint format:check check test build",
44
+ "check:compat": "node scripts/verify.ts check test",
46
45
  "test:native": "node --import ./test/pi-loader.ts --test test/host.test.mjs",
47
- "test": "npm run quality:prepare && node --import ./test/pi-loader.ts --test test/*.test.ts test/host.test.mjs",
48
- "prepublishOnly": "npm run verify"
46
+ "test": "node --import ./test/pi-loader.ts --test test/*.test.ts test/host.test.mjs",
47
+ "prepublishOnly": "npm run verify",
48
+ "quality:config": "node scripts/config-policy.ts",
49
+ "lint:oxlint": "oxlint --deny-warnings .",
50
+ "lint:agent:oxlint": "oxlint --deny-warnings --format=agent .",
51
+ "lint:fix:oxlint": "oxlint --fix ."
49
52
  },
50
53
  "peerDependencies": {
51
- "@earendil-works/pi-coding-agent": ">=1.3.0",
52
- "@earendil-works/pi-tui": ">=1.3.0",
54
+ "@earendil-works/pi-coding-agent": ">=1.4.0",
55
+ "@earendil-works/pi-tui": ">=1.4.0",
53
56
  "typebox": "*"
54
57
  },
55
58
  "peerDependenciesMeta": {
@@ -64,14 +67,14 @@
64
67
  }
65
68
  },
66
69
  "devDependencies": {
67
- "@earendil-works/pi-ai": "1.3.0",
68
- "@earendil-works/pi-coding-agent": "1.3.0",
69
- "@earendil-works/pi-tui": "1.3.0",
70
+ "@earendil-works/pi-ai": "1.4.0",
71
+ "@earendil-works/pi-coding-agent": "1.4.0",
72
+ "@earendil-works/pi-tui": "1.4.0",
70
73
  "@types/node": "24.19.1",
71
- "oxc-parser": "0.153.0",
72
- "oxfmt": "0.72.0",
73
- "oxlint": "1.87.0",
74
- "oxlint-tsgolint": "7.0.2003",
74
+ "oxc-parser": "0.156.0",
75
+ "oxfmt": "0.75.0",
76
+ "oxlint": "1.90.0",
77
+ "oxlint-tsgolint": "7.0.2005",
75
78
  "typebox": "1.3.27",
76
79
  "typescript": "7.0.2",
77
80
  "yaml": "2.9.1"
@@ -71,7 +71,7 @@ export function registerContextTools(
71
71
  ),
72
72
  }),
73
73
  // Pi's fixed tool contract supplies id, params, signal, updates, and context.
74
- // oxlint-disable-next-line max-params
74
+ // oxlint-disable-next-line max-params -- QX-002: Pi supplies five fixed positional tool arguments.
75
75
  async execute(id, { handoff }, _signal, _onUpdate, ctx) {
76
76
  const trimmed = handoff?.trim();
77
77
  const limit = freshPayloadChars(policy(ctx), activeToolTokens());
@@ -99,7 +99,7 @@ export function registerContextTools(
99
99
  "verify remaining context before reporting a token count or changing plans because of context limits",
100
100
  parameters: Type.Object({}),
101
101
  // Pi's fixed tool contract supplies id, params, signal, updates, and context.
102
- // oxlint-disable-next-line max-params
102
+ // oxlint-disable-next-line max-params -- QX-002: Pi supplies five fixed positional tool arguments.
103
103
  async execute(_id, _params, _signal, _onUpdate, ctx) {
104
104
  return contextReport(policy(ctx));
105
105
  },
package/src/contracts.ts CHANGED
@@ -4,7 +4,7 @@ import type { PosthorseDisplay } from "../ui.ts";
4
4
  export type CompactionPolicy = {
5
5
  readonly enabled: boolean;
6
6
  readonly reserveTokens: number;
7
- readonly keepRecentTokens?: number;
7
+ readonly keepRecentTokens?: number | undefined;
8
8
  };
9
9
  export type ContextUsage = {
10
10
  readonly tokens: number | null;
@@ -17,52 +17,59 @@ export type PolicyContext = {
17
17
  readonly getSystemPrompt: () => string;
18
18
  };
19
19
  export type PolicyReader = (ctx: ExtensionContext) => CompactionPolicy;
20
+ export type SnapshotReader = (
21
+ ctx: ExtensionContext,
22
+ livePolicy?: CompactionPolicy,
23
+ ) => PolicyContext;
20
24
  export type ImageLike = {
21
25
  readonly type: "image";
22
26
  readonly data: string;
23
27
  readonly mimeType: string;
24
28
  };
25
29
  export type ToolCallLike = {
26
- readonly type?: string;
27
- readonly id?: string;
28
- readonly name?: string;
29
- readonly namespace?: string;
30
+ readonly type?: string | undefined;
31
+ readonly id?: string | undefined;
32
+ readonly name?: string | undefined;
33
+ readonly namespace?: string | undefined;
30
34
  readonly arguments?: unknown;
31
35
  readonly executionArguments?: unknown;
32
- readonly async?: boolean;
36
+ readonly async?: boolean | undefined;
33
37
  };
34
38
  export type MessageLike = {
35
- readonly role?: string;
36
- readonly stopReason?: string;
37
- readonly errorMessage?: string;
39
+ readonly role?: string | undefined;
40
+ readonly stopReason?: string | undefined;
41
+ readonly errorMessage?: string | undefined;
38
42
  readonly content?: unknown;
39
- readonly summary?: string;
40
- readonly toolName?: string;
41
- readonly customType?: string;
42
- readonly namespace?: string;
43
- readonly toolCallId?: string;
44
- readonly isError?: boolean;
43
+ readonly summary?: string | undefined;
44
+ readonly toolName?: string | undefined;
45
+ readonly customType?: string | undefined;
46
+ readonly namespace?: string | undefined;
47
+ readonly toolCallId?: string | undefined;
48
+ readonly isError?: boolean | undefined;
45
49
  readonly details?: unknown;
46
50
  readonly nestedCalls?: unknown;
47
- readonly command?: string;
48
- readonly output?: string;
49
- readonly excludeFromContext?: boolean;
51
+ readonly command?: string | undefined;
52
+ readonly output?: string | undefined;
53
+ readonly excludeFromContext?: boolean | undefined;
50
54
  };
51
55
  export type EntryLike = {
52
- readonly type?: string;
53
- readonly id?: string;
54
- readonly parentId?: string | null;
55
- readonly timestamp?: string;
56
- readonly message?: MessageLike;
57
- readonly summary?: string;
58
- readonly customType?: string;
56
+ readonly type?: string | undefined;
57
+ readonly id?: string | undefined;
58
+ readonly parentId?: string | null | undefined;
59
+ readonly timestamp?: string | undefined;
60
+ readonly message?: MessageLike | undefined;
61
+ readonly summary?: string | undefined;
62
+ readonly customType?: string | undefined;
59
63
  readonly content?: unknown;
60
64
  readonly details?: unknown;
61
- readonly display?: boolean;
62
- readonly handoff?: string;
63
- readonly firstKeptEntryId?: string | null;
64
- readonly targetId?: string;
65
- readonly replacement?: { readonly content: unknown } | null;
65
+ readonly display?: boolean | undefined;
66
+ readonly data?: unknown;
67
+ readonly cwd?: string | undefined;
68
+ readonly parentSession?: string | undefined;
69
+ readonly handoff?: string | undefined;
70
+ readonly firstKeptEntryId?: string | null | undefined;
71
+ readonly targetId?: string | undefined;
72
+ readonly replacement?: { readonly content: unknown } | null | undefined;
66
73
  };
67
74
  export type WindowedEntry = {
68
75
  readonly entry: EntryLike;
@@ -82,8 +89,8 @@ export type HistoryHit = {
82
89
  };
83
90
  export type ReminderFingerprint = {
84
91
  readonly windowId: string;
85
- readonly contextWindow?: number;
86
- readonly reserveTokens?: number;
92
+ readonly contextWindow?: number | undefined;
93
+ readonly reserveTokens?: number | undefined;
87
94
  };
88
95
  export type TextResult = {
89
96
  content: Array<
package/src/files.ts CHANGED
@@ -45,22 +45,18 @@ export async function resolveFileTarget(absolutePath: string): Promise<string> {
45
45
  if (target.endsWith(sep) || target.endsWith("/")) {
46
46
  // Trailing separators require an existing directory, never a new regular file.
47
47
  // Resolution follows dependent links in filesystem order.
48
- // oxlint-disable-next-line no-await-in-loop
49
48
  await stat(target);
50
49
  return realpath(target);
51
50
  }
52
51
  const parentInput = dirname(target);
53
52
  // stat enforces regular-file/.. traversal that macOS realpath alone accepts.
54
- // oxlint-disable-next-line no-await-in-loop
55
53
  if (!(await stat(parentInput)).isDirectory()) {
56
54
  throw Object.assign(new Error(`Not a directory: ${parentInput}`), { code: "ENOTDIR" });
57
55
  }
58
56
  // Each parent must resolve before its child link can be examined.
59
- // oxlint-disable-next-line no-await-in-loop
60
57
  const parent = await realpath(parentInput);
61
58
  target = join(parent, basename(target));
62
59
  // Link targets cannot be known before examining the current link.
63
- // oxlint-disable-next-line no-await-in-loop
64
60
  const info = await lstatOrMissing(target);
65
61
  if (info === undefined) {
66
62
  return target;
@@ -73,7 +69,6 @@ export async function resolveFileTarget(absolutePath: string): Promise<string> {
73
69
  }
74
70
  links.add(target);
75
71
  // Preserve .. until the preceding directory symlink has been followed.
76
- // oxlint-disable-next-line no-await-in-loop
77
72
  const link = await readlink(target);
78
73
  target = isAbsolute(link) ? link : `${parent}${sep}${link}`;
79
74
  }
@@ -0,0 +1,41 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { buildGuidance } from "./budget.ts";
3
+ import type { SnapshotReader } from "./contracts.ts";
4
+
5
+ export function registerGuidance(pi: ExtensionAPI, policy: SnapshotReader): void {
6
+ let promptOptions: { readonly forceSystemPrompt?: string } | undefined;
7
+ pi.on("before_agent_start", (event) => {
8
+ promptOptions = event.systemPromptOptions;
9
+ });
10
+ pi.on("agent_settled", () => {
11
+ // Prompt options belong to one run, never to later idle context evaluation.
12
+ promptOptions = undefined;
13
+ });
14
+ pi.on("context_with_system", (event, ctx) => {
15
+ const guidance = buildGuidance(policy(ctx));
16
+ const system =
17
+ event.messages.findLast(
18
+ (message) =>
19
+ message.role === "system" && typeof message.sections?.posthorse === "string",
20
+ ) ?? event.messages.find((message) => message.role === "system");
21
+ if (system === undefined || system.role !== "system") {
22
+ return;
23
+ }
24
+ if (promptOptions?.forceSystemPrompt !== undefined) {
25
+ // ponytail: Pi applies forced prompts after this hook; keep request-local guidance
26
+ // until the host projects forced text before context_with_system.
27
+ event.messages.splice(1, 0, {
28
+ role: "custom",
29
+ customType: "posthorse-guidance",
30
+ content: guidance,
31
+ display: false,
32
+ timestamp: 0,
33
+ });
34
+ } else {
35
+ system.sections = {
36
+ ...system.sections,
37
+ posthorse: `<posthorse>\n${guidance}\n</posthorse>`,
38
+ };
39
+ }
40
+ });
41
+ }
@@ -36,10 +36,10 @@ export function contextStart(entries: readonly EntryLike[]): {
36
36
  } {
37
37
  for (let i = entries.length - 1; i >= 0; i--) {
38
38
  const entry = entries[i];
39
- if (entry.type === "context_window") {
39
+ if (entry?.type === "context_window") {
40
40
  return { start: i + 1, boundary: entry };
41
41
  }
42
- if (entry.type === "compaction") {
42
+ if (entry?.type === "compaction") {
43
43
  const kept = entries.findIndex((candidate) => candidate.id === entry.firstKeptEntryId);
44
44
  return { start: kept >= 0 ? kept : i + 1, boundary: entry };
45
45
  }
@@ -101,27 +101,26 @@ function contentText(content: unknown): string {
101
101
  .join("\n");
102
102
  }
103
103
  function ownAssistantText(message: MessageLike, images: WindowedEntry["images"]): string[] {
104
- const originals = [""];
104
+ const originals: string[] = [];
105
+ let current = "";
105
106
  for (const part of unknownArray(message.content) ?? []) {
106
107
  if (isRecoveryCall(part)) {
107
- originals.push("");
108
+ originals.push(current);
109
+ current = "";
108
110
  } else {
109
111
  const partText = textOf({ content: [part] });
110
- const index = originals.length - 1;
111
112
  if (partText !== "") {
112
- originals[index] += `${originals[index] === "" ? "" : "\n"}${partText}`;
113
+ current = current === "" ? partText : `${current}\n${partText}`;
113
114
  }
114
115
  }
115
116
  }
116
- const metadata = [assistantFailure(message), imageSummary(images)]
117
+ const last = [current, assistantFailure(message), imageSummary(images)]
117
118
  .filter((part) => part !== "")
118
119
  .join("\n");
119
- const last = originals.length - 1;
120
- if (metadata !== "") {
121
- originals[last] += `${originals[last] === "" ? "" : "\n"}${metadata}`;
122
- }
123
- if (originals[0] !== "") {
124
- originals[0] = `[assistant] ${originals[0]}`;
120
+ originals.push(last);
121
+ const first = originals[0] ?? "";
122
+ if (first !== "") {
123
+ originals[0] = `[assistant] ${first}`;
125
124
  }
126
125
  return originals;
127
126
  }
@@ -228,9 +227,10 @@ export function windowProjection(): (entry: EntryLike) => string {
228
227
  export function* reverseWindowEntries(entries: readonly EntryLike[]): Generator<WindowedEntry> {
229
228
  // Preserve parent-relative window labels without normalizing the whole branch.
230
229
  // ponytail: ancestry still scans metadata; a native window-aware iterator could remove that cost.
231
- const windows = entries.map(windowProjection());
232
- for (let i = entries.length - 1; i >= 0; i--) {
233
- const item = toWindowedEntry(entries[i], windows[i]);
230
+ const project = windowProjection();
231
+ const windows = entries.map((entry) => ({ entry, windowId: project(entry) }));
232
+ for (const { entry, windowId } of windows.reverse()) {
233
+ const item = toWindowedEntry(entry, windowId);
234
234
  if (item !== undefined) {
235
235
  yield item;
236
236
  }
@@ -13,7 +13,7 @@ import type { PageAccess } from "./paging.ts";
13
13
  type SearchPage = {
14
14
  readonly parts: readonly string[];
15
15
  readonly spans: readonly { readonly headerLength: number; readonly length: number }[];
16
- readonly next?: string;
16
+ readonly next?: string | undefined;
17
17
  };
18
18
  function cursorFor(matches: HistoryMatches, hit: HistoryHit, offset: number): string {
19
19
  return Buffer.from(JSON.stringify([hit.id, hit.priority, offset, matches.key])).toString(
@@ -30,7 +30,7 @@ export type HistoryParams = {
30
30
  export type HistorySource = {
31
31
  readonly dir: string;
32
32
  readonly cwd: string;
33
- readonly currentFile?: string;
33
+ readonly currentFile?: string | undefined;
34
34
  readonly branch: () => readonly EntryLike[];
35
35
  readonly projection: () => readonly ProjectedEntry[];
36
36
  };
@@ -38,7 +38,7 @@ export type HistoryCursor = readonly [id: string, priority: 0 | 1, offset: numbe
38
38
  export type HistoryMatches = {
39
39
  readonly hits: readonly HistoryHit[];
40
40
  readonly skipped: number;
41
- readonly cursor?: HistoryCursor;
41
+ readonly cursor?: HistoryCursor | undefined;
42
42
  readonly key: string;
43
43
  readonly limit: number;
44
44
  };
@@ -109,7 +109,7 @@ function contextVisibility(project: HistorySource["projection"]): (entry: EntryL
109
109
  };
110
110
  }
111
111
  type SearchProgress = {
112
- readonly cursor?: HistoryCursor;
112
+ readonly cursor?: HistoryCursor | undefined;
113
113
  readonly limit: number;
114
114
  readonly add: (hit: HistoryHit) => void;
115
115
  readonly seeking: (priority: 0 | 1) => boolean;
@@ -201,8 +201,8 @@ async function scanArchive(
201
201
  options: {
202
202
  readonly source: string;
203
203
  readonly query: string;
204
- readonly signal?: AbortSignal;
205
- readonly wanted?: LineMatcher;
204
+ readonly signal?: AbortSignal | undefined;
205
+ readonly wanted?: LineMatcher | undefined;
206
206
  readonly inContext: (entry: EntryLike) => boolean;
207
207
  },
208
208
  accumulator: SearchProgress,
@@ -231,7 +231,7 @@ async function archivedMatches(
231
231
  source: HistorySource,
232
232
  search: {
233
233
  readonly query: string;
234
- readonly signal?: AbortSignal;
234
+ readonly signal?: AbortSignal | undefined;
235
235
  readonly inContext: (entry: EntryLike) => boolean;
236
236
  },
237
237
  accumulator: SearchProgress,
package/src/history.ts CHANGED
@@ -10,7 +10,10 @@ import type { PageAccess } from "./paging.ts";
10
10
  import { scopedSessionFiles, sessionWindowEntries } from "./session-files.ts";
11
11
 
12
12
  type LocatedEntry = { readonly item: WindowedEntry; readonly source: string };
13
- function entryReference(id: string): { readonly entryId: string; readonly fileKey?: string } {
13
+ function entryReference(id: string): {
14
+ readonly entryId: string;
15
+ readonly fileKey?: string | undefined;
16
+ } {
14
17
  const separator = id.indexOf("@");
15
18
  return {
16
19
  entryId: separator < 0 ? id : id.slice(0, separator),
@@ -125,7 +128,7 @@ export function registerHistory(
125
128
  ),
126
129
  }),
127
130
  // Pi's fixed tool contract supplies id, params, signal, updates, and context.
128
- // oxlint-disable-next-line max-params
131
+ // oxlint-disable-next-line max-params -- QX-002: Pi supplies five fixed positional tool arguments.
129
132
  async execute(_id, params, signal, _onUpdate, ctx) {
130
133
  const source = historySource(ctx);
131
134
  if (params.op === "search") {