pi-posthorse 0.10.1 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
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,21 +31,24 @@
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
54
  "@earendil-works/pi-coding-agent": ">=1.3.0",
@@ -68,10 +71,10 @@
68
71
  "@earendil-works/pi-coding-agent": "1.3.0",
69
72
  "@earendil-works/pi-tui": "1.3.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,49 +17,56 @@ 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 namespace?: string;
42
- readonly toolCallId?: string;
43
- readonly isError?: boolean;
44
- readonly command?: string;
45
- readonly output?: string;
46
- readonly excludeFromContext?: 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;
49
+ readonly details?: unknown;
50
+ readonly nestedCalls?: unknown;
51
+ readonly command?: string | undefined;
52
+ readonly output?: string | undefined;
53
+ readonly excludeFromContext?: boolean | undefined;
47
54
  };
48
55
  export type EntryLike = {
49
- readonly type?: string;
50
- readonly id?: string;
51
- readonly parentId?: string | null;
52
- readonly timestamp?: string;
53
- readonly message?: MessageLike;
54
- readonly summary?: string;
55
- 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;
56
63
  readonly content?: unknown;
57
64
  readonly details?: unknown;
58
- readonly display?: boolean;
59
- readonly handoff?: string;
60
- readonly firstKeptEntryId?: string | null;
61
- readonly targetId?: string;
62
- readonly replacement?: { readonly content: unknown } | null;
65
+ readonly display?: boolean | undefined;
66
+ readonly handoff?: string | undefined;
67
+ readonly firstKeptEntryId?: string | null | undefined;
68
+ readonly targetId?: string | undefined;
69
+ readonly replacement?: { readonly content: unknown } | null | undefined;
63
70
  };
64
71
  export type WindowedEntry = {
65
72
  readonly entry: EntryLike;
@@ -79,8 +86,8 @@ export type HistoryHit = {
79
86
  };
80
87
  export type ReminderFingerprint = {
81
88
  readonly windowId: string;
82
- readonly contextWindow?: number;
83
- readonly reserveTokens?: number;
89
+ readonly contextWindow?: number | undefined;
90
+ readonly reserveTokens?: number | undefined;
84
91
  };
85
92
  export type TextResult = {
86
93
  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") {
package/src/notes.ts CHANGED
@@ -84,7 +84,6 @@ async function* folderEntries(directory: string, signal: AbortSignal | undefined
84
84
  .slice(0, firstFolder === -1 ? batch.length : firstFolder + 1)
85
85
  .map((entry) => join(directory, entry.name));
86
86
  // Stats overlap only up to the next entry that may be a folder.
87
- // oxlint-disable-next-line no-await-in-loop
88
87
  const infos = await Promise.all(paths.map(statIfPresent));
89
88
  for (const [index, path] of paths.entries()) {
90
89
  start += 1;
@@ -148,7 +147,6 @@ async function listNotes(
148
147
  const rows: string[] = [];
149
148
  for (let start = 0; start < files.length; start += FORMAT_BATCH) {
150
149
  // Pi gets an event-loop turn before each batch of rows.
151
- // oxlint-disable-next-line no-await-in-loop
152
150
  await setImmediate();
153
151
  signal?.throwIfAborted();
154
152
  for (const file of files.slice(start, start + FORMAT_BATCH)) {
@@ -274,7 +272,6 @@ async function noteHits(
274
272
  // A batch of notes over 1 MiB reads nothing that would notice cancellation.
275
273
  signal?.throwIfAborted();
276
274
  // Batches keep note order, so a search stops within one batch of the page limit.
277
- // oxlint-disable-next-line no-await-in-loop
278
275
  const batch = await Promise.all(
279
276
  notes
280
277
  .slice(start, start + SEARCH_BATCH)
@@ -364,7 +361,7 @@ export function registerNotes(
364
361
  ),
365
362
  }),
366
363
  // Pi's fixed tool contract supplies id, params, signal, updates, and context.
367
- // oxlint-disable-next-line max-params
364
+ // oxlint-disable-next-line max-params -- QX-002: Pi supplies five fixed positional tool arguments.
368
365
  async execute(_id, params, signal, _onUpdate, ctx) {
369
366
  const dir = notesDir(ctx.cwd);
370
367
  const access: NotesAccess = { host: () => policy(ctx), pages, signal };
package/src/paging.ts CHANGED
@@ -14,8 +14,8 @@ import {
14
14
  export type PageRequest = {
15
15
  readonly offset: number;
16
16
  readonly imageCount?: number;
17
- readonly cursor?: string;
18
- readonly imageOffset?: number;
17
+ readonly cursor?: string | undefined;
18
+ readonly imageOffset?: number | undefined;
19
19
  };
20
20
  export type NotesPage = {
21
21
  readonly rows: readonly string[];