@tinoy/pi-ext-lib 0.1.0 → 0.3.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/CONTRACT.md ADDED
@@ -0,0 +1,226 @@
1
+ # Degradation contract
2
+
3
+ What every package in this monorepo must do so that a consumer can install ANY SUBSET
4
+ and get no fatal error from what they did NOT install. Working extensions are documented
5
+ as caveats (see "Declaring a caveat"); a missing extension is never a hard dependency, a
6
+ thrown error, or a silent no-op.
7
+
8
+ This file is normative. It refines the planning document's contract section in two
9
+ places, both recorded here so the lanes implement one shape:
10
+
11
+ - the neighbour cache is keyed by `(source, neighbour)` — a resolution per reporting
12
+ package and neighbour, not one slot per module, so one package's absent neighbour can
13
+ never mask another's present one;
14
+ - a caveat is a **table row with four fixed columns** ("Declaring a caveat"), not free
15
+ prose, so the checker can enforce it.
16
+
17
+ Check your package against it with:
18
+
19
+ ```bash
20
+ node --experimental-strip-types packages/ext-lib/src/neighbour.probe.ts # the helper's own probe
21
+ node scripts/check-caveats.mjs # caveat declarations
22
+ ```
23
+
24
+ ## R1 — A module body does no work and cannot throw
25
+
26
+ No I/O, no spawning, no network, no environment mutation at module scope. Constants,
27
+ types and function declarations only; every side effect happens inside the extension
28
+ factory, in `try/catch`, once.
29
+
30
+ ```ts
31
+ // module scope: declarations only
32
+ const TOOL_NAME = "example";
33
+ const REFUSAL = "example: unavailable"; // a string, not a computation
34
+
35
+ // the factory does the work
36
+ export default function (pi: ExtensionAPI): void {
37
+ try {
38
+ register(pi);
39
+ } catch (error) {
40
+ hookLog("example", "register-failed", { reason: messageOf(error) });
41
+ }
42
+ }
43
+ ```
44
+
45
+ A read at load is permitted only when it cannot be deferred behind an accessor, and even then
46
+ always behind a `try` that produces a refusal value, never a throw. Prefer the accessor: the
47
+ read runs on its first call and is memoized to one read per process, so importing the package
48
+ performs no I/O and a consumer that never uses the value never touches the store —
49
+ `loadTariff()` in `@tinoy/pi-tariff` reads the configured table on its first call and answers
50
+ `{ ok: false, reason }` when the file is missing or shaped wrong, while the module body stays
51
+ declarations only.
52
+
53
+ ## R2 — Static imports only for what is guaranteed
54
+
55
+ Guaranteed, and therefore imported statically: node builtins, the package's own modules,
56
+ `@tinoy/pi-ext-lib`, `@tinoy/pi-focus-state` / `@tinoy/pi-tariff` where used, and the
57
+ pi-supplied packages declared as `"*"` peers.
58
+
59
+ Optional, and therefore resolved with `optionalNeighbour`: any third-party pi package,
60
+ any external binary, any store that may not exist.
61
+
62
+ ```ts
63
+ import { optionalNeighbour } from "@tinoy/pi-ext-lib";
64
+
65
+ const rpiv = await optionalNeighbour(
66
+ "@juicesharp/rpiv-todo",
67
+ () => import("@juicesharp/rpiv-todo/state/state-reducer.js"),
68
+ {
69
+ source: "todo-parent",
70
+ effect: "the child-to-parent todo relay cannot apply a mutation",
71
+ hint: "pi install npm:@juicesharp/rpiv-todo",
72
+ },
73
+ );
74
+ if (!rpiv) return refusal("todo_parent: the rpiv-todo state reducer is not installed");
75
+ ```
76
+
77
+ `optionalNeighbour(neighbour, load, report)` never throws, resolves each
78
+ `(source, neighbour)` pair once per process, and emits exactly one `neighbour-absent`
79
+ line per absent pair. `report.source` is the reporting package as it appears in the
80
+ diagnostics log; `report.effect` is what is lost, in one clause; `report.hint` is the
81
+ install command or setting that restores it.
82
+
83
+ Declare the neighbour in `package.json` as an optional peer:
84
+
85
+ ```json
86
+ {
87
+ "peerDependencies": { "@juicesharp/rpiv-todo": "*" },
88
+ "peerDependenciesMeta": { "@juicesharp/rpiv-todo": { "optional": true } }
89
+ }
90
+ ```
91
+
92
+ Do not bundle it: a bundled copy would ship a second extension registration, and the
93
+ subset rule needs absence to be survivable rather than impossible.
94
+
95
+ ## R3 — A missing neighbour is reported by name; it is never thrown
96
+
97
+ One line per distinct condition per process, on the shared diagnostics envelope:
98
+
99
+ ```ts
100
+ hookLog("fleet", "neighbour-absent", {
101
+ neighbour: "@tinoy/pi-canon",
102
+ effect: "the foreman discipline section is not appended to the system prompt",
103
+ hint: "pi install npm:@tinoy/pi-canon",
104
+ });
105
+ ```
106
+
107
+ `optionalNeighbour` emits that line for you; call `hookLog` directly only for a condition
108
+ the helper cannot see (a capability probed at call time, an unavailable service).
109
+
110
+ A surface the model can call says the same thing in prose and marks failure explicitly —
111
+ it never returns an empty success and never a bare `null`:
112
+
113
+ ```ts
114
+ return {
115
+ content: [{ type: "text", text: `${TOOL_NAME}: unavailable — ${reason}` }],
116
+ details: { ok: false },
117
+ };
118
+ ```
119
+
120
+ Silent-by-design cases are only these: an extension that is inert without its marker
121
+ (a child-only extension in a parent session), and a pure enrichment listener that
122
+ subscribes to an event a package may never emit.
123
+
124
+ ## R4 — Register only tool names you own
125
+
126
+ A duplicate tool name is a loader error row for the later registrant, and the first owner
127
+ keeps the name. Therefore:
128
+
129
+ - register no name another package could own (audit the suite's names before adding one);
130
+ - when a feature cannot work without an absent neighbour, register **nothing** for it and
131
+ answer its refusal at call time — preferred — or register the tool and refuse; never
132
+ register a name that shadows a neighbour's;
133
+ - if two packages must legitimately offer the same name, the pairing is documented as a
134
+ conflict caveat in both READMEs, because installing both is a user error the loader
135
+ reports.
136
+
137
+ ## R5 — Touch the active tool set only through your own name
138
+
139
+ ```ts
140
+ const active = pi.getActiveTools();
141
+ if (!active.includes(TOOL_NAME)) return; // nothing to do
142
+ pi.setActiveTools(active.filter((name) => name !== TOOL_NAME));
143
+ ```
144
+
145
+ Never `setActiveTools([...fixedList])`: a foreign list reaches the provider, activates
146
+ names no extension registered, and changes the cached prompt prefix. A session-level tool
147
+ set that genuinely must be narrowed is filtered against the registered set first, and
148
+ every dropped name is reported once by name.
149
+
150
+ ## R6 — Shared state, locks and claims have ONE owner that exports the paths
151
+
152
+ Paths under a shared store (claims, locks, rosters, ledgers) are computed only inside the
153
+ package that owns the store, and exported as a helper. Consumers import the helper, never
154
+ a path literal, so relocating the store is one edit.
155
+
156
+ Absence of the store means "this process is not a member": report it and skip the write.
157
+ Never create the tree, never fall back to a second location, never take a lock you cannot
158
+ name. A declared degraded location chosen by the owner and logged is acceptable; an
159
+ implicit one is not.
160
+
161
+ ## R7 — Event and hook seams are one-way and order-free
162
+
163
+ - Publishers never require a subscriber: emitting an event no package listens to changes
164
+ nothing.
165
+ - Subscribers are idempotent and tolerate a publisher that never appears.
166
+ - Where ordering could change bytes, mutate the payload in place and return `undefined`:
167
+ two handlers that each strip and re-append produce order-dependent prompt bytes.
168
+ - Cross-extension handoff crosses the event bus, never a module import. Extensions are
169
+ evaluated in separate module instances, so an imported registry would be a second,
170
+ empty instance with no error and no log.
171
+
172
+ ## R8 — Machine-bound capability is a call-time refusal, always by name
173
+
174
+ A capability that depends on this machine (a desktop bus, a vault, a notification daemon,
175
+ a systemd unit, an external binary, a dataset) is probed when it is used, never at load,
176
+ and its absence answers a refusal carrying the reason and the fix.
177
+
178
+ ```ts
179
+ const capability = probeCapability();
180
+ if (!capability.ok) return refusal(`${TOOL_NAME}: ${capability.reason}`);
181
+ ```
182
+
183
+ Nothing refuses at load time: a missing capability costs the one action, not the
184
+ extension.
185
+
186
+ ## R9 — Declaring a caveat
187
+
188
+ A caveat is a neighbour that makes a package better without being required. Two carriers,
189
+ one source of truth.
190
+
191
+ **README, mandatory when the package has any caveat.** A `## Works better with` section
192
+ holding exactly one table with these four columns, in this order and spelling:
193
+
194
+ ```markdown
195
+ ## Works better with
196
+
197
+ | Neighbour | You gain | You lose without it | Install |
198
+ | --- | --- | --- | --- |
199
+ | `@tinoy/pi-command-guard` | the blocked-call counter in the footer | the counter stays at zero and no row is rendered | `pi install npm:@tinoy/pi-command-guard` |
200
+ ```
201
+
202
+ One row per caveat; the neighbour cell names exactly one package; the gain and loss cells
203
+ are non-empty; the install cell is a `pi install npm:<name>` command or the literal
204
+ `not a package` when the neighbour is a setting rather than a package. A package with no
205
+ caveat states none — no empty table, no section.
206
+
207
+ **Machine-readable mirror, gated.** A `caveats` array inside the `pi` object
208
+ (`[{ "neighbour": "@tinoy/pi-canon", "gains": "the discipline section reaches the system
209
+ prompt" }]`) may be declared only once an unknown key inside `pi` is known to be ignored
210
+ by the loader rather than rejected (matrix case M10). Until then `scripts/check-caveats.mjs`
211
+ validates the README half only and reports the gated half as pending.
212
+
213
+ ## Checking a package before it lands
214
+
215
+ ```bash
216
+ npm run typecheck # the workspace program
217
+ npm run lint # biome
218
+ node scripts/check-caveats.mjs # the caveat declarations
219
+ node --experimental-strip-types packages/ext-lib/src/neighbour.probe.ts
220
+ ```
221
+
222
+ Load the package through pi's own loader in a scratch prefix (no install, no session):
223
+ point `PI_CODING_AGENT_DIR` at a scratch directory and pass the package's `pi.extensions`
224
+ entry to `DefaultResourceLoader` — the shape `docker/smoke.mjs` uses. A clean package
225
+ loads with zero loader errors, and a missing neighbour appears only as a
226
+ `neighbour-absent` line.
package/README.md CHANGED
@@ -28,6 +28,19 @@ An extension package depends on it through npm:
28
28
  | `PROMPT_APPEND_SEP` | `system-prompt.ts` | the separator between a base system prompt and an appended block |
29
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
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
+ | `optionalNeighbour(neighbour, load, report)` | `neighbour.ts` | resolve an optional neighbour with a guarded dynamic import: never throws, resolves each `(source, neighbour)` pair once per process, and emits one `neighbour-absent` line per absent pair |
32
+ | `NeighbourReport` | `neighbour.ts` | what that line carries: the reporting `source`, the lost `effect`, and an install `hint` |
33
+
34
+ ## Degradation contract
35
+
36
+ [CONTRACT.md](CONTRACT.md) is the normative text every package in this repository
37
+ implements: no work and no throw at module scope, optional neighbours resolved through
38
+ `optionalNeighbour`, a named `neighbour-absent` line instead of an error, tool names owned
39
+ outright, the active tool set touched only through a package's own name, one owner per
40
+ shared path, order-free event seams, machine-bound capability refused at call time, and
41
+ caveats declared as a README table row (machine-readable mirror gated on the loader
42
+ ignoring an unknown `pi` key). Check a package with `node scripts/check-caveats.mjs` and
43
+ `node --experimental-strip-types packages/ext-lib/src/neighbour.probe.ts`.
31
44
 
32
45
  ## What is deliberately not here
33
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinoy/pi-ext-lib",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Shared helpers for pi extensions: the hook log envelope, TUI tool headers, and the system-prompt block seam.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,13 +17,22 @@
17
17
  ".": "./src/index.ts"
18
18
  },
19
19
  "files": [
20
- "src",
20
+ "src/glob.ts",
21
+ "src/hook-log.ts",
22
+ "src/index.ts",
23
+ "src/neighbour.ts",
24
+ "src/system-prompt.ts",
25
+ "src/tool-header.ts",
26
+ "CONTRACT.md",
21
27
  "README.md",
22
28
  "LICENSE"
23
29
  ],
24
30
  "keywords": [
25
31
  "pi-package"
26
32
  ],
33
+ "pi": {
34
+ "extensions": []
35
+ },
27
36
  "engines": {
28
37
  "node": ">=22"
29
38
  }
package/src/glob.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * glob.ts — path/glob overlap, the predicate the fleet and its claim guard must agree on.
3
+ *
4
+ * `globOverlap` answers "do these two path patterns address the same tree": an exact
5
+ * match, either side a directory prefix of the other, or a `*` wildcard anywhere in
6
+ * either side. It lives here because both extension packages that ask the question
7
+ * need the same answer, and a shared library is a cheaper owner than a dependency
8
+ * between the two packages.
9
+ */
10
+
11
+ /** Glob/path overlap: exact, directory prefix, or '*' anywhere in either side. */
12
+ export function globOverlap(a: string, b: string): boolean {
13
+ const x = a.replace(/\/+$/, "");
14
+ const y = b.replace(/\/+$/, "");
15
+ if (x === y) return true;
16
+ if (x.includes("*")) return new RegExp(`^${x.split("*").map(escapeRe).join(".*")}$`).test(y);
17
+ if (y.includes("*")) return new RegExp(`^${y.split("*").map(escapeRe).join(".*")}$`).test(x);
18
+ return x.startsWith(`${y}/`) || y.startsWith(`${x}/`);
19
+ }
20
+
21
+ /** Escape every RegExp metacharacter in `s`, so it can be embedded in a pattern. */
22
+ export function escapeRe(s: string): string {
23
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
24
+ }
package/src/index.ts CHANGED
@@ -9,7 +9,9 @@
9
9
  * each was already in the tree, so the copy is the one canonical file for any
10
10
  * package that depends on this one.
11
11
  */
12
+ export { escapeRe, globOverlap } from "./glob.ts";
12
13
  export { HOOK_LOG_PATH, hookLog } from "./hook-log.ts";
14
+ export { type NeighbourReport, optionalNeighbour } from "./neighbour.ts";
13
15
  export {
14
16
  canonicalSystemPrompt,
15
17
  PROMPT_APPEND_SEP,
@@ -0,0 +1,91 @@
1
+ /**
2
+ * neighbour — the guarded, cached dynamic import for anything a user may not
3
+ * have installed.
4
+ *
5
+ * An extension that needs a package the user did not install must not fail to
6
+ * load: a static import of an absent module is a loader error row, the whole
7
+ * extension is dropped, and every feature it carried goes with it. Resolving the
8
+ * neighbour here keeps the extension loaded, reports the absence once by name,
9
+ * and lets the caller refuse only the capability that needed it.
10
+ *
11
+ * Guarantees, in the order they matter:
12
+ * - it never throws: a rejected import and a throw inside `load` both answer
13
+ * null;
14
+ * - each `(source, neighbour)` pair resolves ONCE per process, so a hot path
15
+ * cannot re-attempt an absent import on every call;
16
+ * - exactly one `neighbour-absent` diagnostics line is emitted per absent pair
17
+ * per process, carrying the neighbour, the lost capability and the fix.
18
+ *
19
+ * The cache is keyed by reporting package AND neighbour. One extension's absent
20
+ * neighbour therefore never masks another's present one, two packages reaching
21
+ * the same absent neighbour each report it under their own source (their effects
22
+ * differ), and a second call site in one package reuses the first resolution.
23
+ */
24
+ import { hookLog } from "./hook-log.ts";
25
+
26
+ /** What a call site states so an absence is actionable rather than merely visible. */
27
+ export interface NeighbourReport {
28
+ /** The reporting package, as it appears in the diagnostics log. */
29
+ source: string;
30
+ /** What is lost while the neighbour is absent, in one clause. */
31
+ effect: string;
32
+ /** The install command or setting that restores the capability. */
33
+ hint?: string;
34
+ }
35
+
36
+ /** One resolution per reporting package and neighbour, per process. */
37
+ const resolutions = new Map<string, Promise<unknown>>();
38
+
39
+ /** One report per absent pair, per process: repeated calls never repeat the line. */
40
+ const reported = new Set<string>();
41
+
42
+ function keyOf(source: string, neighbour: string): string {
43
+ return `${source}\u0000${neighbour}`;
44
+ }
45
+
46
+ function messageOf(error: unknown): string {
47
+ return error instanceof Error ? error.message : String(error);
48
+ }
49
+
50
+ /**
51
+ * Resolve an optional neighbour, or answer null when it cannot be loaded.
52
+ *
53
+ * ```ts
54
+ * const rpiv = await optionalNeighbour(
55
+ * "@juicesharp/rpiv-todo",
56
+ * () => import("@juicesharp/rpiv-todo/state/state-reducer.js"),
57
+ * {
58
+ * source: "todo-parent",
59
+ * effect: "the child-to-parent todo relay cannot apply a mutation",
60
+ * hint: "pi install npm:@juicesharp/rpiv-todo",
61
+ * },
62
+ * );
63
+ * if (!rpiv) return refusal("todo_parent: the rpiv-todo state reducer is not installed");
64
+ * ```
65
+ */
66
+ export function optionalNeighbour<T>(
67
+ neighbour: string,
68
+ load: () => Promise<T>,
69
+ report: NeighbourReport,
70
+ ): Promise<T | null> {
71
+ const key = keyOf(report.source, neighbour);
72
+ const cached = resolutions.get(key);
73
+ if (cached) return cached as Promise<T | null>;
74
+ // Promise.resolve().then(load) also catches a `load` that throws synchronously.
75
+ const pending: Promise<T | null> = Promise.resolve()
76
+ .then(load)
77
+ .catch((error: unknown) => {
78
+ if (!reported.has(key)) {
79
+ reported.add(key);
80
+ hookLog(report.source, "neighbour-absent", {
81
+ neighbour,
82
+ effect: report.effect,
83
+ ...(report.hint ? { hint: report.hint } : {}),
84
+ reason: messageOf(error),
85
+ });
86
+ }
87
+ return null;
88
+ });
89
+ resolutions.set(key, pending);
90
+ return pending;
91
+ }
@@ -4,7 +4,7 @@
4
4
  * pi bundles its TUI inside the agent and does not expose `@earendil-works/pi-tui`
5
5
  * to extensions, so a header cannot be a `Text`; it must be a duck-typed
6
6
  * component (`render(width)` + `invalidate()`), the shape
7
- * extensions/deepseek-cost.ts uses for its entry renderer.
7
+ * the deepseek-cost entry uses for its entry renderer.
8
8
  *
9
9
  * A header is ONE line: the tool name plus the few fields a reader needs to
10
10
  * follow the transcript. Free text is flattened and clipped before theming, so