pi-posthorse 0.8.0 → 0.8.2

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,29 +1,51 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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
- "keywords": ["pi-package", "pi-extension", "context", "posthorse", "rollover"],
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "context",
10
+ "posthorse",
11
+ "rollover"
12
+ ],
7
13
  "repository": {
8
14
  "type": "git",
9
15
  "url": "git+https://github.com/fitchmultz/pi-posthorse.git"
10
16
  },
11
17
  "license": "MIT",
12
- "files": ["index.ts", "ui.ts"],
18
+ "files": [
19
+ "index.ts",
20
+ "ui.ts",
21
+ "src"
22
+ ],
13
23
  "engines": {
14
24
  "node": ">=24.12.0"
15
25
  },
16
26
  "packageManager": "npm@12.2.0",
17
27
  "pi": {
18
- "extensions": ["./index.ts"],
28
+ "extensions": [
29
+ "./index.ts"
30
+ ],
19
31
  "image": "https://raw.githubusercontent.com/fitchmultz/pi-posthorse/main/diagram.png"
20
32
  },
21
33
  "scripts": {
22
34
  "check": "tsc",
35
+ "quality:prepare": "node scripts/setup-oxlint-engine.ts && node scripts/setup-quality-engine.ts",
36
+ "quality:policy": "node scripts/quality-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 .",
41
+ "format": "oxfmt --write .",
42
+ "format:check": "oxfmt --check .",
43
+ "build": "npm pack --dry-run",
44
+ "verify": "npm run lint && npm run format:check && npm run check:compat && npm run build",
23
45
  "check:compat": "npm run check && npm test && npm run test:native",
24
46
  "test:native": "node --import ./test/pi-loader.ts --test test/host.test.mjs",
25
- "test": "node --import ./test/pi-loader.ts --test test/*.test.ts",
26
- "prepublishOnly": "npm test && npm run check"
47
+ "test": "npm run quality:prepare && node --import ./test/pi-loader.ts --test test/*.test.ts",
48
+ "prepublishOnly": "npm run verify"
27
49
  },
28
50
  "peerDependencies": {
29
51
  "@earendil-works/pi-coding-agent": "*",
@@ -45,7 +67,11 @@
45
67
  "@earendil-works/pi-ai": "1.0.0",
46
68
  "@earendil-works/pi-coding-agent": "1.0.0",
47
69
  "@earendil-works/pi-tui": "1.0.0",
48
- "@types/node": "24.19.0",
70
+ "@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",
49
75
  "typebox": "1.3.27",
50
76
  "typescript": "7.0.2"
51
77
  }
package/src/budget.ts ADDED
@@ -0,0 +1,168 @@
1
+ import {
2
+ estimateTokens,
3
+ getAgentDir,
4
+ SettingsManager,
5
+ type ExtensionContext,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ ESTIMATED_IMAGE_CHARS,
9
+ MAX_HANDOFF_CHARS,
10
+ MAX_PAGE_CHARS,
11
+ PAGE_MARGIN_TOKENS,
12
+ type CompactionPolicy,
13
+ type ContextUsage,
14
+ type MessageLike,
15
+ type PolicyContext,
16
+ type PolicyReader,
17
+ } from "./contracts.ts";
18
+ import { imagesOf, textOf } from "./message-text.ts";
19
+
20
+ const MIN_USABLE_TOKENS = Math.ceil(MAX_HANDOFF_CHARS / 4) * 2;
21
+ export const SNAPSHOT_NOTE =
22
+ "Reminder and budget policy uses available settings (a persisted CLI snapshot by default); Pi's live settings control automatic compaction.";
23
+ export const CHECKPOINT_STEPS =
24
+ "update the task's current-state note (goal, progress, decisions, next steps) with available file-editing tools at its absolute path; use notes write only for creation, substantial restructuring, or when editing tools are unavailable. If the note is already current, leave it unchanged. Then call new_context";
25
+ export type Budget = {
26
+ readonly contextWindow: number;
27
+ readonly reserveTokens: number;
28
+ readonly enabled: boolean;
29
+ readonly usable: number;
30
+ readonly rolloverAt: number;
31
+ readonly supported: boolean;
32
+ };
33
+ export function persistedPolicy(ctx: ExtensionContext): CompactionPolicy {
34
+ const settings = SettingsManager.create(ctx.cwd, getAgentDir(), {
35
+ projectTrusted: ctx.isProjectTrusted(),
36
+ });
37
+ const errors = settings.drainErrors();
38
+ if (errors.length > 0) {
39
+ throw new Error(
40
+ `Posthorse could not read persisted Pi settings: ${errors.map((error) => error.error.message).join("; ")}`,
41
+ );
42
+ }
43
+ return settings.getCompactionSettings(ctx.model);
44
+ }
45
+ /** A synchronous evaluation owns one snapshot; never carry it across an await. */
46
+ export function policySnapshot(
47
+ ctx: ExtensionContext,
48
+ getPolicy: PolicyReader,
49
+ livePolicy?: CompactionPolicy,
50
+ ): PolicyContext {
51
+ let usageRead = false;
52
+ let usage: ContextUsage | undefined;
53
+ let settings: CompactionPolicy | undefined;
54
+ return {
55
+ getCompactionSettings: () => {
56
+ settings ??= { ...(livePolicy ?? getPolicy(ctx)) };
57
+ return settings;
58
+ },
59
+ getContextUsage: () => {
60
+ if (!usageRead) {
61
+ usage = ctx.getContextUsage();
62
+ usageRead = true;
63
+ }
64
+ return usage;
65
+ },
66
+ getSystemPrompt: () => ctx.getSystemPrompt(),
67
+ };
68
+ }
69
+ export function budgetFor(
70
+ ctx: PolicyContext,
71
+ contextWindow = ctx.getContextUsage()?.contextWindow,
72
+ ): Budget | undefined {
73
+ if (contextWindow === undefined || contextWindow <= 0) {
74
+ return undefined;
75
+ }
76
+ const { enabled, reserveTokens } = ctx.getCompactionSettings();
77
+ const usable = contextWindow - reserveTokens;
78
+ return {
79
+ contextWindow,
80
+ reserveTokens,
81
+ enabled,
82
+ usable,
83
+ rolloverAt: usable + 1,
84
+ supported: !enabled || usable >= MIN_USABLE_TOKENS,
85
+ };
86
+ }
87
+ export function unsupportedMessage(budget: Budget): string {
88
+ const n = (value: number) => value.toLocaleString("en-US");
89
+ return `Posthorse: unsupported configuration. The model's context window (${n(budget.contextWindow)} tokens) minus Pi's compaction.reserveTokens (${n(budget.reserveTokens)}) leaves ${n(budget.usable)} usable tokens; Posthorse needs at least ${n(MIN_USABLE_TOKENS)}. Automatic Posthorse rollover and checkpoint reminders are off for this model, so Pi's own compaction applies. Lower compaction.reserveTokens in Pi settings or use a larger-context model. new_context remains available with a model-aware handoff limit.`;
90
+ }
91
+ function automaticGuidance(budget: Budget | undefined, enabled: boolean): string {
92
+ if (!enabled) {
93
+ return "Pi compaction is disabled in the available settings, so Posthorse sends no checkpoint reminder. new_context remains available.";
94
+ }
95
+ if (budget !== undefined && !budget.supported) {
96
+ return unsupportedMessage(budget);
97
+ }
98
+ const deadline =
99
+ budget === undefined
100
+ ? "the configured Pi context limit"
101
+ : `${Math.max(1, Math.round((budget.rolloverAt / budget.contextWindow) * 100))}% used`;
102
+ const line =
103
+ budget === undefined
104
+ ? ""
105
+ : ` The rollover line is ${budget.rolloverAt.toLocaleString("en-US")} tokens used under the available Pi settings.`;
106
+ return `Automatic rollover follows Pi's compaction setting.${line} At most one checkpoint reminder may arrive before the rollover line (${deadline}); when it does, stop normal work, ${CHECKPOINT_STEPS}.`;
107
+ }
108
+ export function buildGuidance(ctx: PolicyContext): string {
109
+ const budget = budgetFor(ctx);
110
+ const enabled = budget?.enabled ?? ctx.getCompactionSettings().enabled;
111
+ const capacity =
112
+ budget === undefined
113
+ ? "Configured context capacity is unknown."
114
+ : `Configured context capacity: ${budget.contextWindow.toLocaleString("en-US")} tokens. This is Pi's best available native limit, not remaining space or a measured provider boundary. Fresh windows use the active configuration; system instructions, tools, the handoff, and new messages consume part of it.`;
115
+ return `## Context self-management (Posthorse)
116
+ ${capacity}
117
+ Do not guess context capacity or remaining space from response-token limits or reasoning budgets. Before reporting a remaining-token count or changing your work plan because of context limits, call get_context_remaining. If usage is unknown, report it as unknown. No routine budget checks are needed.
118
+ ${automaticGuidance(budget, enabled)}
119
+ After a rollover, earlier conversation stays in history. Restore notes and todos, then verify live state before stateful or external work; automatic handoffs record inputs, not progress.
120
+ Keep one concise current-state note per task. Preserve decisions and safety constraints; link fuller evidence and history instead of copying them. Edit changed sections rather than resending unchanged content.
121
+ Write handoffs and notes as normal readable prose; notes and history search match literal text.`;
122
+ }
123
+ /** ponytail: native estimates omit request-local guidance; reserve it until post-transform usage is exposed. */
124
+ export function guidanceTokens(ctx: PolicyContext): number {
125
+ return estimateTokens({
126
+ role: "system",
127
+ content: `<posthorse>\n${buildGuidance(ctx)}\n</posthorse>`,
128
+ timestamp: 0,
129
+ });
130
+ }
131
+ /** Half the fresh capacity after prompt/tool/input overhead remains for continued work. */
132
+ export function freshPayloadChars(
133
+ ctx: PolicyContext,
134
+ toolTokens: number,
135
+ pendingMessages: readonly MessageLike[] = [],
136
+ cap = MAX_HANDOFF_CHARS,
137
+ ): number {
138
+ const budget = budgetFor(ctx);
139
+ if (budget === undefined) {
140
+ return cap;
141
+ }
142
+ const line = budget.enabled && budget.supported ? budget.rolloverAt : budget.contextWindow;
143
+ const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4) + guidanceTokens(ctx);
144
+ const pendingTokens = pendingMessages.reduce(
145
+ (total, message) =>
146
+ total +
147
+ Math.ceil(textOf(message).length / 4) +
148
+ imagesOf(message.content).length * (ESTIMATED_IMAGE_CHARS / 4),
149
+ 0,
150
+ );
151
+ return Math.min(
152
+ cap,
153
+ Math.max(
154
+ 0,
155
+ Math.floor((line - PAGE_MARGIN_TOKENS - promptTokens - toolTokens - pendingTokens) / 2),
156
+ ) * 4,
157
+ );
158
+ }
159
+ export function pageCapacity(ctx: PolicyContext, toolTokens: number): number {
160
+ const usage = ctx.getContextUsage();
161
+ if (usage === undefined || usage.tokens === null) {
162
+ return freshPayloadChars(ctx, toolTokens, [], MAX_PAGE_CHARS) / 4 + PAGE_MARGIN_TOKENS;
163
+ }
164
+ const budget = budgetFor(ctx, usage.contextWindow);
165
+ const line =
166
+ budget?.enabled === true && budget.supported ? budget.rolloverAt : usage.contextWindow;
167
+ return line - usage.tokens - guidanceTokens(ctx);
168
+ }
@@ -0,0 +1,113 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { toolCards, type PosthorseDisplay } from "../ui.ts";
4
+ import {
5
+ budgetFor,
6
+ freshPayloadChars,
7
+ SNAPSHOT_NOTE,
8
+ unsupportedMessage,
9
+ type Budget,
10
+ } from "./budget.ts";
11
+ import {
12
+ EMPTY_HANDOFF,
13
+ MAX_HANDOFF_CHARS,
14
+ textResult,
15
+ type PolicyContext,
16
+ type TextResult,
17
+ } from "./contracts.ts";
18
+
19
+ function rolloverStatus(budget: Budget | undefined): "disabled" | "enabled" | "unsupported" {
20
+ if (budget?.enabled !== true) {
21
+ return "disabled";
22
+ }
23
+ return budget.supported ? "enabled" : "unsupported";
24
+ }
25
+ function contextReport(host: PolicyContext): TextResult {
26
+ const usage = host.getContextUsage();
27
+ if (usage === undefined || usage.tokens === null) {
28
+ return textResult("Context usage is not known until the next model response.", [], {
29
+ kind: "context",
30
+ });
31
+ }
32
+ const n = (value: number) => value.toLocaleString("en-US");
33
+ const budget = budgetFor(host, usage.contextWindow);
34
+ const rollover = rolloverStatus(budget);
35
+ const display: PosthorseDisplay = {
36
+ kind: "context",
37
+ usage,
38
+ rollover,
39
+ rolloverAt: budget?.rolloverAt,
40
+ };
41
+ const configured = `≈${n(Math.max(0, usage.contextWindow - usage.tokens))} tokens until the configured context limit (${n(usage.tokens)}/${n(usage.contextWindow)} used, ${Math.round(usage.percent ?? 0)}%). Best available native estimate. ${SNAPSHOT_NOTE}`;
42
+ if (budget?.enabled !== true) {
43
+ return textResult(
44
+ `Automatic rollover is disabled in the available settings (Pi compaction.enabled=false). ${configured}`,
45
+ [],
46
+ display,
47
+ );
48
+ }
49
+ if (!budget.supported) {
50
+ return textResult(`${unsupportedMessage(budget)} ${configured}`, [], display);
51
+ }
52
+ return textResult(
53
+ `≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${configured}`,
54
+ [],
55
+ display,
56
+ );
57
+ }
58
+ export function registerContextTools(
59
+ pi: ExtensionAPI,
60
+ policy: (ctx: ExtensionContext) => PolicyContext,
61
+ activeToolTokens: () => number,
62
+ requestReset: (id: string, handoff: string) => void,
63
+ ): void {
64
+ pi.registerTool({
65
+ name: "new_context",
66
+ label: "New Context",
67
+ ...toolCards("new_context"),
68
+ description:
69
+ "Start a fresh context window once the current foreground tools succeed; background work continues across the reset. Earlier conversation stays recoverable through history. Put continuation state in handoff, or save fuller state in notes first.",
70
+ promptSnippet: "request a fresh context window with an optional handoff",
71
+ parameters: Type.Object({
72
+ handoff: Type.Optional(
73
+ Type.String({
74
+ description: "What the fresh window needs to continue, as readable prose",
75
+ maxLength: MAX_HANDOFF_CHARS,
76
+ }),
77
+ ),
78
+ }),
79
+ // Pi's fixed tool contract supplies id, params, signal, updates, and context.
80
+ // oxlint-disable-next-line max-params
81
+ async execute(id, { handoff }, _signal, _onUpdate, ctx) {
82
+ const trimmed = handoff?.trim();
83
+ const limit = freshPayloadChars(policy(ctx), activeToolTokens());
84
+ if (trimmed !== undefined && trimmed !== "" && trimmed.length > limit) {
85
+ throw new Error(
86
+ `Handoff is too large for the active model (${trimmed.length.toLocaleString("en-US")} characters; limit ${limit.toLocaleString("en-US")}). Save fuller state in notes, then retry with a shorter handoff or no handoff.`,
87
+ );
88
+ }
89
+ const result = textResult(
90
+ "Requested a fresh Pi context after the current foreground tools succeed. Background work continues across the reset. Earlier conversation stays in session history.",
91
+ [],
92
+ { kind: "new-context" },
93
+ );
94
+ requestReset(id, trimmed === undefined || trimmed === "" ? EMPTY_HANDOFF : trimmed);
95
+ return result;
96
+ },
97
+ });
98
+ pi.registerTool({
99
+ name: "get_context_remaining",
100
+ label: "Context Remaining",
101
+ ...toolCards("get_context_remaining"),
102
+ description:
103
+ "Estimate tokens left before Pi's automatic rollover and the configured context limit. Check before reporting remaining space or changing your work plan because of context limits. A checkpoint reminder already arrives before rollover; no routine checks are needed.",
104
+ promptSnippet:
105
+ "verify remaining context before reporting a token count or changing plans because of context limits",
106
+ parameters: Type.Object({}),
107
+ // Pi's fixed tool contract supplies id, params, signal, updates, and context.
108
+ // oxlint-disable-next-line max-params
109
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
110
+ return contextReport(policy(ctx));
111
+ },
112
+ });
113
+ }
@@ -0,0 +1,124 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { PosthorseDisplay } from "../ui.ts";
3
+
4
+ export type CompactionPolicy = {
5
+ readonly enabled: boolean;
6
+ readonly reserveTokens: number;
7
+ };
8
+ export type ContextUsage = {
9
+ readonly tokens: number | null;
10
+ readonly contextWindow: number;
11
+ readonly percent: number | null;
12
+ };
13
+ export type PolicyContext = {
14
+ readonly getCompactionSettings: () => CompactionPolicy;
15
+ readonly getContextUsage: () => ContextUsage | undefined;
16
+ readonly getSystemPrompt: () => string;
17
+ };
18
+ export type PolicyReader = (ctx: ExtensionContext) => CompactionPolicy;
19
+ export type ImageLike = {
20
+ readonly type: "image";
21
+ readonly data: string;
22
+ readonly mimeType: string;
23
+ };
24
+ export type ToolCallLike = {
25
+ readonly type?: string;
26
+ readonly id?: string;
27
+ readonly name?: string;
28
+ readonly namespace?: string;
29
+ readonly arguments?: unknown;
30
+ readonly executionArguments?: unknown;
31
+ readonly async?: boolean;
32
+ };
33
+ export type MessageLike = {
34
+ readonly role?: string;
35
+ readonly stopReason?: string;
36
+ readonly errorMessage?: string;
37
+ readonly content?: unknown;
38
+ readonly summary?: string;
39
+ readonly toolName?: string;
40
+ readonly namespace?: string;
41
+ readonly toolCallId?: string;
42
+ readonly isError?: boolean;
43
+ readonly command?: string;
44
+ readonly output?: string;
45
+ readonly excludeFromContext?: boolean;
46
+ };
47
+ export type EntryLike = {
48
+ readonly type?: string;
49
+ readonly id?: string;
50
+ readonly parentId?: string | null;
51
+ readonly timestamp?: string;
52
+ readonly message?: MessageLike;
53
+ readonly summary?: string;
54
+ readonly customType?: string;
55
+ readonly content?: unknown;
56
+ readonly details?: unknown;
57
+ readonly display?: boolean;
58
+ readonly handoff?: string;
59
+ readonly firstKeptEntryId?: string | null;
60
+ readonly targetId?: string;
61
+ readonly replacement?: { readonly content: unknown } | null;
62
+ };
63
+ export type WindowedEntry = {
64
+ readonly entry: EntryLike;
65
+ readonly windowId: string;
66
+ readonly text: string;
67
+ readonly images: readonly ImageLike[];
68
+ };
69
+ export type ProjectedEntry = {
70
+ readonly sourceEntry: EntryLike;
71
+ readonly messages: readonly MessageLike[];
72
+ };
73
+ export type HistoryHit = {
74
+ readonly id: string;
75
+ readonly text: string;
76
+ readonly headerLength: number;
77
+ readonly priority: 0 | 1;
78
+ };
79
+ export type ReminderFingerprint = {
80
+ readonly windowId: string;
81
+ readonly contextWindow?: number;
82
+ readonly reserveTokens?: number;
83
+ };
84
+ export type TextResult = {
85
+ content: Array<
86
+ { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }
87
+ >;
88
+ details: PosthorseDisplay | undefined;
89
+ };
90
+
91
+ export const MAX_HANDOFF_CHARS = 20_000;
92
+ export const MAX_PAGE_CHARS = 40_000;
93
+ export const MIN_PAGE_CHARS = 1_000;
94
+ export const PAGE_MARGIN_TOKENS = 1_000;
95
+ export const ESTIMATED_IMAGE_CHARS = 4_800;
96
+ export const REMINDER_TYPE = "posthorse-reminder";
97
+ export const AUTO_HANDOFF_PREFIX = "Automatic context rollover recovery record.";
98
+ export const EMPTY_HANDOFF = "Fresh context. Restore relevant notes and history before continuing.";
99
+
100
+ export function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
101
+ return typeof value === "object" && value !== null && !Array.isArray(value);
102
+ }
103
+ export function unknownArray(value: unknown): readonly unknown[] | undefined {
104
+ return Array.isArray(value) ? value : undefined;
105
+ }
106
+ export function optionalString(value: unknown): string | undefined {
107
+ return typeof value === "string" ? value : undefined;
108
+ }
109
+ export function requireValue(value: string | undefined, name: string, op: string): string {
110
+ if (value === undefined || value === "") {
111
+ throw new Error(`"${name}" is required for op "${op}".`);
112
+ }
113
+ return value;
114
+ }
115
+ export function isReminderType(customType: unknown): boolean {
116
+ return customType === REMINDER_TYPE;
117
+ }
118
+ export function textResult(
119
+ text: string,
120
+ images: readonly ImageLike[] = [],
121
+ display?: PosthorseDisplay,
122
+ ): TextResult {
123
+ return { content: [{ type: "text", text }, ...images], details: display };
124
+ }
package/src/files.ts ADDED
@@ -0,0 +1,182 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createReadStream, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
3
+ import {
4
+ access,
5
+ constants,
6
+ lstat,
7
+ open,
8
+ readFile,
9
+ readlink,
10
+ realpath,
11
+ rename,
12
+ stat,
13
+ unlink,
14
+ } from "node:fs/promises";
15
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
16
+ import { isRecord } from "./contracts.ts";
17
+
18
+ async function lstatOrMissing(path: string) {
19
+ try {
20
+ return await lstat(path);
21
+ } catch (error) {
22
+ if (isRecord(error) && error.code === "ENOENT") {
23
+ return;
24
+ }
25
+ throw error;
26
+ }
27
+ }
28
+ /** Resolve every link before normalization, preserving path traversal constraints. */
29
+ export async function resolveFileTarget(absolutePath: string): Promise<string> {
30
+ let target = absolutePath;
31
+ const links = new Set<string>();
32
+ for (;;) {
33
+ if (target.endsWith(sep) || target.endsWith("/")) {
34
+ // Trailing separators require an existing directory, never a new regular file.
35
+ // Resolution follows dependent links in filesystem order.
36
+ // oxlint-disable-next-line no-await-in-loop
37
+ await stat(target);
38
+ return realpath(target);
39
+ }
40
+ const parentInput = dirname(target);
41
+ // stat enforces regular-file/.. traversal that macOS realpath alone accepts.
42
+ // oxlint-disable-next-line no-await-in-loop
43
+ if (!(await stat(parentInput)).isDirectory()) {
44
+ throw Object.assign(new Error(`Not a directory: ${parentInput}`), { code: "ENOTDIR" });
45
+ }
46
+ // Each parent must resolve before its child link can be examined.
47
+ // oxlint-disable-next-line no-await-in-loop
48
+ const parent = await realpath(parentInput);
49
+ target = join(parent, basename(target));
50
+ // Link targets cannot be known before examining the current link.
51
+ // oxlint-disable-next-line no-await-in-loop
52
+ const info = await lstatOrMissing(target);
53
+ if (info === undefined) {
54
+ return target;
55
+ }
56
+ if (!info.isSymbolicLink()) {
57
+ return realpath(target);
58
+ }
59
+ if (links.has(target)) {
60
+ throw Object.assign(new Error(`Symlink cycle: ${absolutePath}`), { code: "ELOOP" });
61
+ }
62
+ links.add(target);
63
+ // Preserve .. until the preceding directory symlink has been followed.
64
+ // oxlint-disable-next-line no-await-in-loop
65
+ const link = await readlink(target);
66
+ target = isAbsolute(link) ? link : `${parent}${sep}${link}`;
67
+ }
68
+ }
69
+ async function existingNote(target: string) {
70
+ const previous = await lstatOrMissing(target);
71
+ if (previous !== undefined && !previous.isFile()) {
72
+ throw Object.assign(new Error(`Cannot publish to a non-regular file: ${target}`), {
73
+ code: previous.isDirectory() ? "EISDIR" : "EINVAL",
74
+ path: target,
75
+ });
76
+ }
77
+ if (previous !== undefined) {
78
+ await access(target, constants.W_OK);
79
+ }
80
+ return previous;
81
+ }
82
+ /** Same-directory replacement; pre-rename failures preserve the old note and its metadata. */
83
+ export async function publishFile(
84
+ path: string,
85
+ content: string,
86
+ signal?: AbortSignal,
87
+ ): Promise<void> {
88
+ signal?.throwIfAborted();
89
+ const target = await resolveFileTarget(path);
90
+ const previous = await existingNote(target);
91
+ const temporary = join(dirname(target), `.posthorse-${randomUUID()}.tmp`);
92
+ const file = await open(temporary, "wx", previous === undefined ? 0o666 : 0o600);
93
+ try {
94
+ await file.writeFile(content, { signal });
95
+ if (previous !== undefined) {
96
+ const staged = await file.stat();
97
+ if (staged.uid !== previous.uid || staged.gid !== previous.gid) {
98
+ await file.chown(previous.uid, previous.gid);
99
+ }
100
+ await file.chmod(previous.mode & 0o777);
101
+ }
102
+ await file.close();
103
+ signal?.throwIfAborted();
104
+ await rename(temporary, target);
105
+ } catch (error) {
106
+ await file.close().catch(() => {
107
+ /* Preserve the publication failure if closing also fails. */
108
+ });
109
+ await unlink(temporary).catch(() => {
110
+ /* Cleanup must not replace the original publication failure. */
111
+ });
112
+ throw error;
113
+ }
114
+ }
115
+ /** Split on LF only; Unicode line separators are valid inside JSONL strings and notes. */
116
+ export async function* lfLines(file: string, signal?: AbortSignal): AsyncGenerator<string> {
117
+ const stream = createReadStream(file, { encoding: "utf8", signal });
118
+ let pending: string[] = [];
119
+ for await (const chunk of stream) {
120
+ if (typeof chunk !== "string") {
121
+ throw new Error("Expected a UTF-8 text stream.");
122
+ }
123
+ const lines = chunk.split("\n");
124
+ pending.push(lines[0] ?? "");
125
+ if (lines.length > 1) {
126
+ yield pending.join("");
127
+ yield* lines.slice(1, -1);
128
+ pending = [lines.at(-1) ?? ""];
129
+ }
130
+ }
131
+ const last = pending.join("");
132
+ if (last !== "") {
133
+ yield last;
134
+ }
135
+ }
136
+ /** Git's heuristic: a NUL in the first 8,000 bytes marks binary content. */
137
+ export async function isBinaryFile(path: string): Promise<boolean> {
138
+ const file = await open(path, "r");
139
+ try {
140
+ const { buffer, bytesRead } = await file.read(Buffer.alloc(8_000), 0, 8_000, 0);
141
+ return buffer.subarray(0, bytesRead).includes(0);
142
+ } finally {
143
+ await file.close();
144
+ }
145
+ }
146
+ /** A whole file as UTF-8 text, or undefined when `isBinaryFile` would call it binary. */
147
+ export async function readText(path: string, signal?: AbortSignal): Promise<string | undefined> {
148
+ const bytes = await readFile(path, { signal });
149
+ return bytes.subarray(0, 8_000).includes(0) ? undefined : bytes.toString("utf8");
150
+ }
151
+ function gitRoot(dir: string, marker: string): string {
152
+ let target = marker;
153
+ if (!statSync(marker).isDirectory()) {
154
+ const gitdir = readFileSync(marker, "utf8").match(/^gitdir:\s*(.+?)\s*$/m)?.[1];
155
+ if (gitdir === undefined || gitdir === "") {
156
+ return dir;
157
+ }
158
+ target = resolve(dir, gitdir);
159
+ }
160
+ target = realpathSync(target);
161
+ const commonFile = join(target, "commondir");
162
+ const common = realpathSync(
163
+ existsSync(commonFile) ? resolve(target, readFileSync(commonFile, "utf8").trim()) : target,
164
+ );
165
+ return basename(common) === ".git" ? dirname(common) : common;
166
+ }
167
+ /** Conventional repos share the primary checkout; separate Git directories own their shared root. */
168
+ export function notesRoot(cwd: string): string {
169
+ for (let dir = cwd; ; dir = dirname(dir)) {
170
+ const marker = join(dir, ".git");
171
+ if (existsSync(marker)) {
172
+ try {
173
+ return gitRoot(dir, marker);
174
+ } catch {
175
+ return dir;
176
+ } // Orphaned worktrees retain local notes.
177
+ }
178
+ if (dirname(dir) === dir) {
179
+ return cwd;
180
+ }
181
+ }
182
+ }