@tinoy/pi-ext-lib 0.1.0 → 0.2.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 +226 -0
- package/README.md +13 -0
- package/package.json +10 -2
- package/src/index.ts +1 -0
- package/src/neighbour.ts +91 -0
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.
|
|
3
|
+
"version": "0.2.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,21 @@
|
|
|
17
17
|
".": "./src/index.ts"
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
|
-
"src",
|
|
20
|
+
"src/hook-log.ts",
|
|
21
|
+
"src/index.ts",
|
|
22
|
+
"src/neighbour.ts",
|
|
23
|
+
"src/system-prompt.ts",
|
|
24
|
+
"src/tool-header.ts",
|
|
25
|
+
"CONTRACT.md",
|
|
21
26
|
"README.md",
|
|
22
27
|
"LICENSE"
|
|
23
28
|
],
|
|
24
29
|
"keywords": [
|
|
25
30
|
"pi-package"
|
|
26
31
|
],
|
|
32
|
+
"pi": {
|
|
33
|
+
"extensions": []
|
|
34
|
+
},
|
|
27
35
|
"engines": {
|
|
28
36
|
"node": ">=22"
|
|
29
37
|
}
|
package/src/index.ts
CHANGED
package/src/neighbour.ts
ADDED
|
@@ -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
|
+
}
|