@tinoy/pi-ext-lib 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tinoy Thomas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # @tinoy/pi-ext-lib
2
+
3
+ Shared helpers for pi extensions. This package registers nothing with pi — it has
4
+ no `pi` manifest and no extension entry — and is consumed as a dependency by
5
+ extension packages.
6
+
7
+ ```bash
8
+ pi install npm:@tinoy/pi-ext-lib # installs it; nothing is registered with pi
9
+ ```
10
+
11
+ An extension package depends on it through npm:
12
+
13
+ ```json
14
+ { "dependencies": { "@tinoy/pi-ext-lib": "^0.1.0" } }
15
+ ```
16
+
17
+ ## API
18
+
19
+ | Export | Module | Purpose |
20
+ | --- | --- | --- |
21
+ | `hookLog(source, kind, detail?)` | `hook-log.ts` | the one diagnostics envelope: `{ts, proc, sid, source, kind, detail}` per line, JSONL in `~/.local/share/pi-hooks/log.jsonl`. Fail-open — a logging failure never breaks the call that emitted it |
22
+ | `HOOK_LOG_PATH` | `hook-log.ts` | that file's path |
23
+ | `clip(text, max?)` | `tool-header.ts` | flatten whitespace and clip to `max` characters with an ellipsis |
24
+ | `argText(args, key)` / `argNumber(args, key)` | `tool-header.ts` | a non-empty trimmed string argument / a finite numeric argument, else `undefined` |
25
+ | `renderToolHeader(theme, name, parts?)` | `tool-header.ts` | the duck-typed one-line header component (`render(width)` + `invalidate()`) a tool's `renderCall` returns |
26
+ | `safeToolHeader(theme, name, build)` | `tool-header.ts` | `renderToolHeader` behind a guard: a throwing `build` degrades to the name alone |
27
+ | `HeaderTheme`, `HeaderComponent`, `HeaderPart` | `tool-header.ts` | the header types |
28
+ | `PROMPT_APPEND_SEP` | `system-prompt.ts` | the separator between a base system prompt and an appended block |
29
+ | `canonicalSystemPrompt(systemPrompt, block)` | `system-prompt.ts` | the one canonical form of the system prompt — base + separator + block, appended exactly once at the end, whatever run-start path built it |
30
+ | `systemPromptSlot(payload)` | `system-prompt.ts` | read/write access to a provider payload's system-prompt slot, or `null` for a payload shape that carries none |
31
+
32
+ ## What is deliberately not here
33
+
34
+ Anything that carries one extension's policy stays in that extension. From the
35
+ prompt seam in `packages/canon`, the tail-section registry (`setTailSection`,
36
+ `registeredSectionIds`) was left out: it owns canon's `canon:section` /
37
+ `canon:sections` event contract, logs under the `canon` source, and its 16 KiB
38
+ section cap is canon's. `canonicalSystemPrompt`, `systemPromptSlot`,
39
+ `PROMPT_APPEND_SEP` are policy-free and live here.
40
+
41
+ ## Dependencies
42
+
43
+ Node builtins only (`node:fs`, `node:os`, `node:path`). No pi package is
44
+ imported, so this package declares no peer dependency.
45
+
46
+ ## Licence
47
+
48
+ MIT — see the repository [LICENSE](../../LICENSE).
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@tinoy/pi-ext-lib",
3
+ "version": "0.1.0",
4
+ "description": "Shared helpers for pi extensions: the hook log envelope, TUI tool headers, and the system-prompt block seam.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/tinoy1336/pi-extensions.git",
9
+ "directory": "packages/ext-lib"
10
+ },
11
+ "homepage": "https://github.com/tinoy1336/pi-extensions/tree/main/packages/ext-lib#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/tinoy1336/pi-extensions/issues"
14
+ },
15
+ "type": "module",
16
+ "exports": {
17
+ ".": "./src/index.ts"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "keywords": [
25
+ "pi-package"
26
+ ],
27
+ "engines": {
28
+ "node": ">=22"
29
+ }
30
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * hook-log: the ONE diagnostics envelope every extension writes.
3
+ *
4
+ * Each extension grew its own log file, its own line shape and its own path,
5
+ * so answering "what did my extensions actually do this session" meant reading
6
+ * four formats in four places. Every diagnostic line now lands here as
7
+ * `{ts, source, kind, detail}`, one JSON object per line.
8
+ *
9
+ * This file is OBSERVABILITY ONLY — nothing reads it back to make a decision.
10
+ * Functional ledgers keep their own files (focus-gate's ledger is read by the
11
+ * gate itself to queue deferred actions; no-subagent-fork's rewrites are its
12
+ * own audit trail). Diagnostics here, state there.
13
+ *
14
+ * Fail-open: a logging failure must never break the tool call that emitted it.
15
+ */
16
+ import { appendFileSync, mkdirSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ const LOG_DIR = join(homedir(), ".local/share/pi-hooks");
21
+
22
+ export const HOOK_LOG_PATH = join(LOG_DIR, "log.jsonl");
23
+
24
+ export function hookLog(source: string, kind: string, detail: Record<string, unknown> = {}): void {
25
+ try {
26
+ mkdirSync(LOG_DIR, { recursive: true });
27
+ // Identity for readers has two parts, because neither alone is enough:
28
+ // `proc` — the pi process that emitted this (unique per running session,
29
+ // immune to environment inheritance). Footer counters filter on it.
30
+ // `sid` — PI_SESSION_ID. Useful for cross-session analytics, but it is an
31
+ // INHERITED env var: a pi started from inside another pi session
32
+ // keeps the parent's id, so it can NOT be trusted as identity.
33
+ appendFileSync(
34
+ HOOK_LOG_PATH,
35
+ JSON.stringify({
36
+ ts: new Date().toISOString(),
37
+ proc: process.pid,
38
+ sid: process.env.PI_SESSION_ID ?? "",
39
+ source,
40
+ kind,
41
+ detail,
42
+ }) + "\n",
43
+ );
44
+ } catch {
45
+ /* never break a tool call over logging */
46
+ }
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @tinoy/pi-ext-lib — the shared helpers pi extensions in this monorepo import.
3
+ *
4
+ * This is the package's whole public API. Every module here is host-agnostic:
5
+ * no extension's policy, no extension's store path, no extension's tool schema.
6
+ *
7
+ * `hook-log.ts` and `tool-header.ts` are verbatim copies of the live extension
8
+ * sources (a single `cp`, never edited in place here) — the second consumer of
9
+ * each was already in the tree, so the copy is the one canonical file for any
10
+ * package that depends on this one.
11
+ */
12
+ export { HOOK_LOG_PATH, hookLog } from "./hook-log.ts";
13
+ export {
14
+ canonicalSystemPrompt,
15
+ PROMPT_APPEND_SEP,
16
+ systemPromptSlot,
17
+ } from "./system-prompt.ts";
18
+ export {
19
+ argNumber,
20
+ argText,
21
+ clip,
22
+ type HeaderComponent,
23
+ type HeaderPart,
24
+ type HeaderTheme,
25
+ renderToolHeader,
26
+ safeToolHeader,
27
+ } from "./tool-header.ts";
@@ -0,0 +1,112 @@
1
+ /**
2
+ * system-prompt — the shared seam for appending a block to the system prompt of
3
+ * every provider request.
4
+ *
5
+ * An extension that appends to the system prompt faces two pi behaviours this
6
+ * module exists to absorb:
7
+ *
8
+ * - `before_agent_start` fires ONLY from the interactive `prompt()` path. A run
9
+ * started by an injected message (`pi.sendMessage(msg, {triggerTurn:true})`
10
+ * while idle) calls `agent.prompt()` directly, so an append made there never
11
+ * lands on that request.
12
+ * - The provider prefix is `[system, tools, messages]`, so a system prompt that
13
+ * differs between two requests of one session re-bills every byte after it as
14
+ * a cache miss.
15
+ *
16
+ * The pair below is the fix for both: `before_agent_start` builds the text, and
17
+ * `before_provider_request` re-normalizes the payload through
18
+ * `canonicalSystemPrompt` so every request leaves with identical bytes whatever
19
+ * path produced it.
20
+ *
21
+ * Nothing here is policy: the block's content, its scope rules and what happens
22
+ * when the payload cannot be rewritten belong to the extension that calls this.
23
+ */
24
+
25
+ /** Separator between the base system prompt and the appended block. */
26
+ export const PROMPT_APPEND_SEP = "\n\n";
27
+
28
+ /** The line that opens the appended block; also the strip marker. Derived from
29
+ * the block, never a second literal, so the strip/append rule cannot drift from
30
+ * the renderer that produced the block. */
31
+ function blockMarker(block: string): string {
32
+ const nl = block.indexOf("\n");
33
+ return `${PROMPT_APPEND_SEP}${nl === -1 ? block : block.slice(0, nl)}`;
34
+ }
35
+
36
+ /** The ONE canonical form of the system prompt: base + separator + block, the
37
+ * block appended exactly once at the end. Whatever arrives is mapped to it, so
38
+ * the bytes cannot depend on which run-start path built the request:
39
+ * - base only (a run that never fired before_agent_start) -> base + block;
40
+ * - base + block (the normal path) -> unchanged;
41
+ * - a fork that inherited a block rendered for another scope, or a request
42
+ * that was appended twice -> stripped back to base, then the ONE current
43
+ * block is appended.
44
+ * Stripping takes the FIRST marker: a duplicated block is delimited there. */
45
+ export function canonicalSystemPrompt(
46
+ systemPrompt: string,
47
+ block: string,
48
+ ): { text: string; hadBlock: boolean } {
49
+ const marker = blockMarker(block);
50
+ const at = systemPrompt.indexOf(marker);
51
+ const base = at === -1 ? systemPrompt : systemPrompt.slice(0, at);
52
+ return { text: `${base}${PROMPT_APPEND_SEP}${block}`, hadBlock: at !== -1 };
53
+ }
54
+
55
+ /** The system-prompt slot of a provider payload, or null when the payload shape
56
+ * carries none we can rewrite. Provider shapes covered: openai-completions /
57
+ * openai-responses / codex (first message, role "system" or "developer"), a
58
+ * top-level `system` string, OpenAI Responses `instructions`, and the
59
+ * anthropic-messages `system` block list when it holds a single text block. */
60
+ export function systemPromptSlot(
61
+ payload: unknown,
62
+ ): { get: () => string; set: (text: string) => void } | null {
63
+ if (!payload || typeof payload !== "object") return null;
64
+ const p = payload as Record<string, unknown>;
65
+ if (Array.isArray(p.messages) && p.messages.length > 0) {
66
+ const first = p.messages[0] as { role?: unknown; content?: unknown };
67
+ if (
68
+ first &&
69
+ typeof first === "object" &&
70
+ (first.role === "system" || first.role === "developer") &&
71
+ typeof first.content === "string"
72
+ ) {
73
+ return {
74
+ get: () => first.content as string,
75
+ set: (text) => {
76
+ first.content = text;
77
+ },
78
+ };
79
+ }
80
+ }
81
+ if (typeof p.system === "string") {
82
+ return {
83
+ get: () => p.system as string,
84
+ set: (text) => {
85
+ p.system = text;
86
+ },
87
+ };
88
+ }
89
+ if (Array.isArray(p.system)) {
90
+ const textBlocks = (p.system as Array<{ text?: unknown }>).filter(
91
+ (b) => b && typeof b === "object" && typeof b.text === "string",
92
+ );
93
+ if (textBlocks.length === 1) {
94
+ const block = textBlocks[0] as { text: string };
95
+ return {
96
+ get: () => block.text,
97
+ set: (text) => {
98
+ block.text = text;
99
+ },
100
+ };
101
+ }
102
+ }
103
+ if (typeof p.instructions === "string") {
104
+ return {
105
+ get: () => p.instructions as string,
106
+ set: (text) => {
107
+ p.instructions = text;
108
+ },
109
+ };
110
+ }
111
+ return null;
112
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * tool-header — the shared `renderCall` header builder for the local extensions.
3
+ *
4
+ * pi bundles its TUI inside the agent and does not expose `@earendil-works/pi-tui`
5
+ * to extensions, so a header cannot be a `Text`; it must be a duck-typed
6
+ * component (`render(width)` + `invalidate()`), the shape
7
+ * extensions/deepseek-cost.ts uses for its entry renderer.
8
+ *
9
+ * A header is ONE line: the tool name plus the few fields a reader needs to
10
+ * follow the transcript. Free text is flattened and clipped before theming, so
11
+ * no ANSI sequence is ever cut; a narrower terminal wraps the line without
12
+ * losing the color in force.
13
+ */
14
+
15
+ export interface HeaderTheme {
16
+ // `any` on the color keeps pi's richer Theme assignable to this narrow shape
17
+ fg?: (color: any, text: string) => string;
18
+ bold?: (text: string) => string;
19
+ }
20
+
21
+ export interface HeaderComponent {
22
+ render(width: number): string[];
23
+ invalidate(): void;
24
+ }
25
+
26
+ /** One colored run of a header line: [theme color, text]. */
27
+ export type HeaderPart = readonly [string, string];
28
+
29
+ const SGR_RE = /^\x1b\[[0-9;]*m/;
30
+
31
+ /** Flatten whitespace and clip to `max` characters with an ellipsis. */
32
+ export function clip(text: string, max = 80): string {
33
+ const flat = text.replace(/\s+/g, " ").trim();
34
+ return flat.length > max ? `${flat.slice(0, Math.max(1, max - 1))}…` : flat;
35
+ }
36
+
37
+ /** A non-empty trimmed string argument, or undefined. */
38
+ export function argText(args: unknown, key: string): string | undefined {
39
+ const value = (args as Record<string, unknown> | null | undefined)?.[key];
40
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
41
+ }
42
+
43
+ /** A finite numeric argument, or undefined. */
44
+ export function argNumber(args: unknown, key: string): number | undefined {
45
+ const value = (args as Record<string, unknown> | null | undefined)?.[key];
46
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
47
+ }
48
+
49
+ /** Visible width (SGR sequences are zero-width). */
50
+ function visibleWidth(text: string): number {
51
+ return text.replace(/\x1b\[[0-9;]*m/g, "").length;
52
+ }
53
+
54
+ /** Break an over-long segment at `width`, re-applying the color in force. */
55
+ function splitSegment(segment: string, width: number, lines: string[]): string {
56
+ let current = "";
57
+ let color = "";
58
+ let visible = 0;
59
+ for (let i = 0; i < segment.length; ) {
60
+ const escape = SGR_RE.exec(segment.slice(i, i + 16));
61
+ if (escape) {
62
+ color = escape[0];
63
+ current += escape[0];
64
+ i += escape[0].length;
65
+ continue;
66
+ }
67
+ if (visible >= width) {
68
+ lines.push(current);
69
+ current = color;
70
+ visible = 0;
71
+ }
72
+ current += segment[i];
73
+ visible += 1;
74
+ i += 1;
75
+ }
76
+ return current;
77
+ }
78
+
79
+ /** Wrap the header's colored segments at `width`, never cutting an escape. */
80
+ function wrapSegments(segments: string[], width: number): string[] {
81
+ const lines: string[] = [];
82
+ let line = "";
83
+ for (const segment of segments) {
84
+ if (!segment) continue;
85
+ if (line && visibleWidth(line) + visibleWidth(segment) > width) {
86
+ lines.push(line.replace(/\s+$/, ""));
87
+ line = "";
88
+ }
89
+ if (!line && visibleWidth(segment) > width) {
90
+ line = splitSegment(segment, width, lines);
91
+ continue;
92
+ }
93
+ line += segment;
94
+ }
95
+ lines.push(line);
96
+ return lines;
97
+ }
98
+
99
+ /** Build the header component: the tool name bold, then one run per part. */
100
+ export function renderToolHeader(theme: HeaderTheme | undefined, name: string, parts: HeaderPart[] = []): HeaderComponent {
101
+ const fg = (color: string, text: string): string => (theme?.fg ? theme.fg(color, text) : text);
102
+ const bold = (text: string): string => (theme?.bold ? theme.bold(text) : text);
103
+ const segments = [fg("toolTitle", bold(name))];
104
+ for (const [color, text] of parts) {
105
+ if (!text) continue;
106
+ segments.push(color ? fg(color, text) : text);
107
+ }
108
+ return {
109
+ render: (width: number) => wrapSegments(segments, Math.max(1, Math.floor(width))),
110
+ invalidate: () => {},
111
+ };
112
+ }
113
+
114
+ /** `renderToolHeader` behind a guard: an odd argument set degrades to the name alone. */
115
+ export function safeToolHeader(theme: HeaderTheme | undefined, name: string, build: () => HeaderPart[]): HeaderComponent {
116
+ try {
117
+ return renderToolHeader(theme, name, build());
118
+ } catch {
119
+ return renderToolHeader(theme, name);
120
+ }
121
+ }