@arnilo/prism 0.5.5 → 0.6.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/CHANGELOG.md +46 -0
- package/README.md +10 -10
- package/dist/agent-approval.js +7 -6
- package/dist/agent-loops.js +51 -12
- package/dist/agent-session/session.d.ts +1 -0
- package/dist/agent-session/session.js +20 -2
- package/dist/agent-tool-dispatch.js +5 -4
- package/dist/cli-runner.d.ts +8 -1
- package/dist/cli-runner.js +97 -7
- package/dist/content.d.ts +3 -16
- package/dist/content.js +9 -99
- package/dist/context-budget.d.ts +12 -1
- package/dist/context-budget.js +42 -19
- package/dist/contracts-core/agent.d.ts +11 -0
- package/dist/contracts-core/agent.js +4 -1
- package/dist/extensions.d.ts +18 -1
- package/dist/extensions.js +10 -0
- package/dist/index.d.ts +6 -6
- package/dist/index.js +4 -4
- package/dist/input.d.ts +6 -0
- package/dist/input.js +12 -1
- package/dist/media-types.d.ts +34 -0
- package/dist/media-types.js +158 -0
- package/dist/pinned-fetch.d.ts +2 -2
- package/dist/pinned-fetch.js +11 -12
- package/dist/redaction.js +74 -1
- package/dist/session-stores.d.ts +11 -0
- package/dist/session-stores.js +23 -8
- package/docs/acp.md +1 -1
- package/docs/ag-ui.md +4 -2
- package/docs/agent-events.md +2 -0
- package/docs/agent-loops.md +1 -1
- package/docs/agent-session-runtime.md +3 -1
- package/docs/browser-automation.md +5 -2
- package/docs/cli-rpc.md +15 -1
- package/docs/contributing.md +37 -0
- package/docs/core.md +2 -0
- package/docs/document-reader.md +2 -0
- package/docs/documents.md +1 -1
- package/docs/extension-authoring.md +8 -9
- package/docs/extensions.md +13 -1
- package/docs/graft.md +29 -5
- package/docs/history/release-handoffs.md +33 -0
- package/docs/host-security.md +2 -2
- package/docs/index.md +33 -17
- package/docs/input-and-prompt-assembly.md +4 -4
- package/docs/language-intelligence.md +1 -1
- package/docs/migrate-to-0.5.md +7 -2
- package/docs/migrate-to-0.6.md +89 -0
- package/docs/migration.md +30 -0
- package/docs/model-registry.md +1 -1
- package/docs/multimodal-content.md +1 -1
- package/docs/obscura.md +3 -1
- package/docs/options-index.md +286 -0
- package/docs/peer-dependencies.md +94 -0
- package/docs/performance.md +34 -2
- package/docs/ponytail.md +2 -0
- package/docs/postgres-persistence.md +3 -1
- package/docs/provider-conformance.md +1 -1
- package/docs/provider-packages.md +21 -21
- package/docs/provider-primitives.md +2 -1
- package/docs/providers/ai-sdk.md +5 -2
- package/docs/public-contracts.md +2 -2
- package/docs/release-and-install.md +75 -55
- package/docs/server.md +1 -1
- package/docs/session-stores.md +3 -1
- package/docs/sqlite-persistence.md +2 -0
- package/docs/testing.md +38 -0
- package/docs/tools.md +1 -1
- package/docs/wiki.md +47 -3
- package/package.json +5 -5
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF policy, host/address types, `MediaContentError`, and the URL gate shared by the
|
|
3
|
+
* media content pipeline (`content.ts`) and the DNS-pinned fetch primitive
|
|
4
|
+
* (`pinned-fetch.ts`).
|
|
5
|
+
*
|
|
6
|
+
* Leaf module: it imports nothing from either consumer, which is what keeps the two off
|
|
7
|
+
* each other's import graph (plan 070 Task 10, shipped in 0.6.0 — the pair previously formed
|
|
8
|
+
* a deliberate ESM cycle where each module referenced the other's exports only inside
|
|
9
|
+
* function bodies). Declarations moved here verbatim; `assertSsrfAllowedUrl` is
|
|
10
|
+
* re-exported from `content.ts` so every import path and the class identity stay put.
|
|
11
|
+
*/
|
|
12
|
+
import { isIP } from "node:net";
|
|
13
|
+
export class MediaContentError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
constructor(code, message, options) {
|
|
16
|
+
super(message, options);
|
|
17
|
+
this.name = "MediaContentError";
|
|
18
|
+
this.code = code;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function assertSsrfAllowedUrl(url, policy = {}) {
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = new URL(url);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new MediaContentError("ssrf_denied", "Media URL is not a valid absolute URL");
|
|
28
|
+
}
|
|
29
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
30
|
+
throw new MediaContentError("unsupported_url_scheme", `Media URL scheme ${parsed.protocol} is not allowed`);
|
|
31
|
+
}
|
|
32
|
+
if (parsed.username || parsed.password) {
|
|
33
|
+
throw new MediaContentError("ssrf_denied", "Media URL must not embed credentials");
|
|
34
|
+
}
|
|
35
|
+
const hostname = normalizeHostname(parsed.hostname);
|
|
36
|
+
// Parsed up front so a malformed policy entry always fails closed, even when another
|
|
37
|
+
// allow-list short-circuits below. Membership only matters after the denied-name list.
|
|
38
|
+
const cidrAllowed = isAllowedByCidr(hostname, policy.allowedCidrs);
|
|
39
|
+
if (policy.allowedHostnames?.length) {
|
|
40
|
+
if (!policy.allowedHostnames.some((allowed) => hostname === normalizeHostname(allowed))) {
|
|
41
|
+
throw new MediaContentError("ssrf_denied", `Media URL host ${hostname} is not allow-listed`);
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (policy.denyPrivateHosts === false)
|
|
46
|
+
return;
|
|
47
|
+
if (hostname === "localhost" ||
|
|
48
|
+
hostname.endsWith(".localhost") ||
|
|
49
|
+
hostname.endsWith(".local") ||
|
|
50
|
+
hostname === "metadata" ||
|
|
51
|
+
hostname === "metadata.google.internal" ||
|
|
52
|
+
hostname === "instance-data") {
|
|
53
|
+
throw new MediaContentError("ssrf_denied", `Media URL host ${hostname} is not allowed`);
|
|
54
|
+
}
|
|
55
|
+
// Validates the CIDR list even for a denied/absent IP: an unparseable entry fails closed.
|
|
56
|
+
if (cidrAllowed)
|
|
57
|
+
return;
|
|
58
|
+
if (isBlockedIp(hostname)) {
|
|
59
|
+
throw new MediaContentError("ssrf_denied", `Media URL host ${hostname} is not allowed`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function normalizeHostname(value) {
|
|
63
|
+
return value
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/^\[|\]$/g, "")
|
|
66
|
+
.replace(/\.$/, "");
|
|
67
|
+
}
|
|
68
|
+
export function isBlockedIp(hostname) {
|
|
69
|
+
const normalized = normalizeHostname(hostname);
|
|
70
|
+
const family = isIP(normalized);
|
|
71
|
+
if (family === 4)
|
|
72
|
+
return isBlockedIpv4(normalized);
|
|
73
|
+
if (family === 6)
|
|
74
|
+
return isBlockedIpv6(normalized);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Membership test for `SsrfPolicy.allowedCidrs`. Non-IP hostnames can never match; an
|
|
79
|
+
* entry that does not parse as `address/prefix` throws `ssrf_denied` (fail closed,
|
|
80
|
+
* including entries of the other address family than the one being tested).
|
|
81
|
+
*/
|
|
82
|
+
export function isAllowedByCidr(hostname, allowedCidrs) {
|
|
83
|
+
if (!allowedCidrs?.length)
|
|
84
|
+
return false;
|
|
85
|
+
const ranges = allowedCidrs.map((entry) => {
|
|
86
|
+
const range = parseCidr(entry);
|
|
87
|
+
if (!range)
|
|
88
|
+
throw new MediaContentError("ssrf_denied", `SSRF policy CIDR '${entry}' is not a valid range`);
|
|
89
|
+
return range;
|
|
90
|
+
});
|
|
91
|
+
const address = normalizeHostname(hostname);
|
|
92
|
+
const family = isIP(address);
|
|
93
|
+
if (family !== 4 && family !== 6)
|
|
94
|
+
return false;
|
|
95
|
+
const bits = family === 4 ? 32 : 128;
|
|
96
|
+
const target = addressToBigInt(address, family);
|
|
97
|
+
return ranges.some((range) => range.bits === bits && target >> BigInt(bits - range.prefix) === range.base >> BigInt(bits - range.prefix));
|
|
98
|
+
}
|
|
99
|
+
function parseCidr(value) {
|
|
100
|
+
const [address, prefixText, ...rest] = value.split("/");
|
|
101
|
+
if (rest.length > 0 || address === undefined || prefixText === undefined)
|
|
102
|
+
return undefined;
|
|
103
|
+
const family = isIP(normalizeHostname(address));
|
|
104
|
+
if (family !== 4 && family !== 6)
|
|
105
|
+
return undefined;
|
|
106
|
+
const prefix = Number(prefixText);
|
|
107
|
+
const bits = family === 4 ? 32 : 128;
|
|
108
|
+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > bits)
|
|
109
|
+
return undefined;
|
|
110
|
+
return { base: addressToBigInt(normalizeHostname(address), family), bits, prefix };
|
|
111
|
+
}
|
|
112
|
+
function addressToBigInt(address, family) {
|
|
113
|
+
const words = family === 4 ? address.split(".").map(Number) : (parseIpv6Words(address) ?? []);
|
|
114
|
+
return words.reduce((accumulator, word) => (accumulator << BigInt(family === 4 ? 8 : 16)) | BigInt(word), 0n);
|
|
115
|
+
}
|
|
116
|
+
function isBlockedIpv4(address) {
|
|
117
|
+
const [a, b] = address.split(".").map(Number);
|
|
118
|
+
return (a === 0 ||
|
|
119
|
+
a === 10 ||
|
|
120
|
+
a === 127 ||
|
|
121
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
122
|
+
(a === 169 && b === 254) ||
|
|
123
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
124
|
+
(a === 192 && (b === 0 || b === 168)) ||
|
|
125
|
+
(a === 198 && (b === 18 || b === 19 || b === 51)) ||
|
|
126
|
+
(a === 203 && b === 0) ||
|
|
127
|
+
a >= 224);
|
|
128
|
+
}
|
|
129
|
+
function isBlockedIpv6(address) {
|
|
130
|
+
const words = parseIpv6Words(address);
|
|
131
|
+
if (!words)
|
|
132
|
+
return true;
|
|
133
|
+
if (words.every((word) => word === 0) || (words.slice(0, 7).every((word) => word === 0) && words[7] === 1))
|
|
134
|
+
return true;
|
|
135
|
+
if ((words[0] & 0xfe00) === 0xfc00)
|
|
136
|
+
return true;
|
|
137
|
+
if ((words[0] & 0xffc0) === 0xfe80 || (words[0] & 0xffc0) === 0xfec0)
|
|
138
|
+
return true;
|
|
139
|
+
if ((words[0] & 0xff00) === 0xff00)
|
|
140
|
+
return true;
|
|
141
|
+
if (words[0] === 0x2001 && words[1] === 0x0db8)
|
|
142
|
+
return true;
|
|
143
|
+
const mapped = words.slice(0, 5).every((word) => word === 0) && (words[5] === 0 || words[5] === 0xffff);
|
|
144
|
+
return mapped && isBlockedIpv4(`${words[6] >> 8}.${words[6] & 0xff}.${words[7] >> 8}.${words[7] & 0xff}`);
|
|
145
|
+
}
|
|
146
|
+
function parseIpv6Words(address) {
|
|
147
|
+
const parts = address.split("::");
|
|
148
|
+
if (parts.length > 2)
|
|
149
|
+
return undefined;
|
|
150
|
+
const left = parts[0] ? parts[0].split(":") : [];
|
|
151
|
+
const right = parts[1] ? parts[1].split(":") : [];
|
|
152
|
+
const missing = 8 - left.length - right.length;
|
|
153
|
+
if (missing < 0 || (parts.length === 1 && missing !== 0))
|
|
154
|
+
return undefined;
|
|
155
|
+
const words = [...left, ...Array.from({ length: missing }, () => "0"), ...right].map((part) => Number.parseInt(part, 16));
|
|
156
|
+
return words.length === 8 && words.every((word) => Number.isInteger(word) && word >= 0 && word <= 0xffff) ? words : undefined;
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=media-types.js.map
|
package/dist/pinned-fetch.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type MediaHostAddress, type MediaHostnameResolver, type SsrfPolicy } from "./
|
|
1
|
+
import { type MediaHostAddress, type MediaHostnameResolver, normalizeHostname, type SsrfPolicy } from "./media-types.js";
|
|
2
|
+
export { normalizeHostname };
|
|
2
3
|
export interface PinnedFetchOptions {
|
|
3
4
|
/** Prefix for request-level error messages ("redirects are not allowed", "response exceeds", ...). Default "Request". */
|
|
4
5
|
readonly errorPrefix?: string;
|
|
@@ -20,6 +21,5 @@ export declare function defaultResolver(hostname: string): Promise<readonly Medi
|
|
|
20
21
|
export declare function requestPinned(url: URL, address: MediaHostAddress, init: RequestInit | undefined, errorPrefix?: string): Promise<Response>;
|
|
21
22
|
export declare function boundResponse(response: Response, maxBytes: number, errorPrefix?: string): Response;
|
|
22
23
|
export declare function raceAbort<T>(promise: Promise<T>, signal: AbortSignal | null | undefined): Promise<T>;
|
|
23
|
-
export declare function normalizeHostname(value: string): string;
|
|
24
24
|
export declare function isLoopbackHostname(value: string): boolean;
|
|
25
25
|
export declare function isLoopbackAddress(value: string): boolean;
|
package/dist/pinned-fetch.js
CHANGED
|
@@ -11,15 +11,17 @@
|
|
|
11
11
|
* for 3xx). Error messages are parameterized by `errorPrefix` so each caller
|
|
12
12
|
* (MCP, OIDC, OPA, content) keeps its own taxonomy and message text.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
* media
|
|
16
|
-
*
|
|
14
|
+
* The SSRF gate, `MediaContentError`, and the host/address types come from the leaf
|
|
15
|
+
* module `./media-types.js` — shared with `content.ts`, which routes its default media
|
|
16
|
+
* fetch through here. This module transitively imports nothing from `content.ts` (plan
|
|
17
|
+
* 070 Task 10 broke the deliberate ESM cycle that used to sit between the two).
|
|
17
18
|
*/
|
|
18
19
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
19
20
|
import { request as httpRequest } from "node:http";
|
|
20
21
|
import { request as httpsRequest } from "node:https";
|
|
21
22
|
import { isIP } from "node:net";
|
|
22
|
-
import { assertSsrfAllowedUrl, MediaContentError } from "./
|
|
23
|
+
import { assertSsrfAllowedUrl, MediaContentError, normalizeHostname, } from "./media-types.js";
|
|
24
|
+
export { normalizeHostname };
|
|
23
25
|
/** One DNS-pinned, redirect-free, byte-bounded fetch. See module comment. */
|
|
24
26
|
export async function pinnedFetch(url, init, options) {
|
|
25
27
|
const errorPrefix = options?.errorPrefix ?? "Request";
|
|
@@ -72,8 +74,11 @@ export async function resolvePinnedAddress(url, resolver, signal, allowLoopback,
|
|
|
72
74
|
}
|
|
73
75
|
const literal = candidate.family === 6 ? `[${normalized}]` : normalized;
|
|
74
76
|
// Fail closed on resolved candidates: an explicit hostname allow-list is honored
|
|
75
|
-
// for the URL itself, but every resolved address is still private-checked.
|
|
76
|
-
|
|
77
|
+
// for the URL itself, but every resolved address is still private-checked. An
|
|
78
|
+
// allowed CIDR is a range rule, so it does apply to resolved addresses.
|
|
79
|
+
const candidatePolicy = ssrf?.allowedHostnames?.length
|
|
80
|
+
? { denyPrivateHosts: ssrf.denyPrivateHosts, ...(ssrf.allowedCidrs ? { allowedCidrs: ssrf.allowedCidrs } : {}) }
|
|
81
|
+
: ssrf;
|
|
77
82
|
try {
|
|
78
83
|
assertSsrfAllowedUrl(`${url.protocol}//${literal}`, candidatePolicy);
|
|
79
84
|
}
|
|
@@ -250,12 +255,6 @@ export async function raceAbort(promise, signal) {
|
|
|
250
255
|
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
251
256
|
});
|
|
252
257
|
}
|
|
253
|
-
export function normalizeHostname(value) {
|
|
254
|
-
return value
|
|
255
|
-
.toLowerCase()
|
|
256
|
-
.replace(/^\[|\]$/g, "")
|
|
257
|
-
.replace(/\.$/, "");
|
|
258
|
-
}
|
|
259
258
|
export function isLoopbackHostname(value) {
|
|
260
259
|
const hostname = normalizeHostname(value);
|
|
261
260
|
return hostname === "localhost" || hostname.endsWith(".localhost") || isLoopbackAddress(hostname);
|
package/dist/redaction.js
CHANGED
|
@@ -3,6 +3,18 @@ const REDACTED = "[REDACTED]";
|
|
|
3
3
|
// Depth bound matching agent-run-state.ts; hostile deep structures yield a placeholder
|
|
4
4
|
// instead of a stack overflow.
|
|
5
5
|
const MAX_REDACT_DEPTH = 32;
|
|
6
|
+
// Plan 070 Task 9: single-pass fast path. One left-to-right alternation replaces every
|
|
7
|
+
// occurrence of every needle in a single scan, instead of one split/join pass per needle.
|
|
8
|
+
// It is only equivalent to the ordered reduce below when no needle occurrence can overlap
|
|
9
|
+
// another needle's occurrence or a produced placeholder, so `singlePassMatcher` returns
|
|
10
|
+
// null for such sets and the loop stays authoritative. Below this length the set check
|
|
11
|
+
// costs more than the passes it saves (measured crossover ~4 KB, so the fast path only
|
|
12
|
+
// engages with a wide margin).
|
|
13
|
+
const SINGLE_PASS_MIN_CHARS = 16 * 1024;
|
|
14
|
+
// ponytail: bound the fast path to this needle count. The set check is O(k²) and the
|
|
15
|
+
// alternation compile grows with k, so the loop is no slower beyond it. Raise if a host
|
|
16
|
+
// redacts with much larger secret sets.
|
|
17
|
+
const SINGLE_PASS_MAX_NEEDLES = 32;
|
|
6
18
|
export function createSecretRedactor(secrets) {
|
|
7
19
|
return { redact: (value) => redactSecrets(value, secrets) };
|
|
8
20
|
}
|
|
@@ -35,7 +47,18 @@ export function redactSecrets(value, secrets) {
|
|
|
35
47
|
const needles = secrets.filter((secret) => Boolean(secret));
|
|
36
48
|
if (needles.length === 0)
|
|
37
49
|
return value;
|
|
38
|
-
|
|
50
|
+
// Decided lazily on the first large string, and only once per call: a redaction of many
|
|
51
|
+
// small strings never pays the set check and never regresses against the loop.
|
|
52
|
+
let singlePass;
|
|
53
|
+
const redactString = (text) => {
|
|
54
|
+
if (text.length >= SINGLE_PASS_MIN_CHARS) {
|
|
55
|
+
if (singlePass === undefined)
|
|
56
|
+
singlePass = singlePassMatcher(needles);
|
|
57
|
+
if (singlePass)
|
|
58
|
+
return text.replace(singlePass, REDACTED);
|
|
59
|
+
}
|
|
60
|
+
return needles.reduce((current, secret) => current.split(secret).join(REDACTED), text);
|
|
61
|
+
};
|
|
39
62
|
const redactKey = (key) => {
|
|
40
63
|
if (typeof key === "string")
|
|
41
64
|
return redactString(key);
|
|
@@ -90,6 +113,56 @@ export function redactSecrets(value, secrets) {
|
|
|
90
113
|
};
|
|
91
114
|
return redact(value);
|
|
92
115
|
}
|
|
116
|
+
function escapeRegExpLiteral(literal) {
|
|
117
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Compiled single-pass matcher for `needles`, or null when the set is not provably
|
|
121
|
+
* equivalent to the ordered reduce/split/join in `redactSecrets`. Equivalence holds when
|
|
122
|
+
* no needle occurrence can overlap another needle's occurrence (overlap would make the
|
|
123
|
+
* result depend on which needle is mentioned first rather than on position) and no needle
|
|
124
|
+
* can occur inside or across the edges of a produced "[REDACTED]" placeholder (a later
|
|
125
|
+
* pass would then redact text the single scan never sees). Both checks are conservative:
|
|
126
|
+
* a false negative only costs the fast path.
|
|
127
|
+
*/
|
|
128
|
+
function singlePassMatcher(needles) {
|
|
129
|
+
if (needles.length < 2 || needles.length > SINGLE_PASS_MAX_NEEDLES)
|
|
130
|
+
return null;
|
|
131
|
+
for (const needle of needles) {
|
|
132
|
+
if (needleTouchesPlaceholder(needle))
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
for (const left of needles) {
|
|
136
|
+
for (const right of needles) {
|
|
137
|
+
if (left !== right && needlesOverlap(left, right))
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return new RegExp(needles.map(escapeRegExpLiteral).join("|"), "g");
|
|
142
|
+
}
|
|
143
|
+
/** A placeholder-relative occurrence: needle inside "[REDACTED]", or a needle prefix equal
|
|
144
|
+
* to a placeholder suffix / needle suffix equal to a placeholder prefix (a match spanning
|
|
145
|
+
* the placeholder's edge that the ordered passes would create). */
|
|
146
|
+
function needleTouchesPlaceholder(needle) {
|
|
147
|
+
if (REDACTED.includes(needle))
|
|
148
|
+
return true;
|
|
149
|
+
for (let n = 1; n < needle.length; n += 1) {
|
|
150
|
+
if (REDACTED.endsWith(needle.slice(0, n)) || REDACTED.startsWith(needle.slice(n)))
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
/** One occurrence of `left` overlapping one of `right`: containment either way, or a proper
|
|
156
|
+
* suffix of `left` equal to a proper prefix of `right` (callers check both directions). */
|
|
157
|
+
function needlesOverlap(left, right) {
|
|
158
|
+
if (right.includes(left))
|
|
159
|
+
return true;
|
|
160
|
+
for (let n = 1; n < left.length && n < right.length; n += 1) {
|
|
161
|
+
if (right.startsWith(left.slice(left.length - n)))
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
93
166
|
export function errorToErrorInfo(error, secrets = []) {
|
|
94
167
|
const code = readErrorCode(error);
|
|
95
168
|
const retry = readRetryAfterMs(error);
|
package/dist/session-stores.d.ts
CHANGED
|
@@ -28,5 +28,16 @@ export type MemorySessionSearchMode = "linear" | "unsupported";
|
|
|
28
28
|
export interface CreateMemorySessionStoreOptions {
|
|
29
29
|
/** Default `"linear"`: capped in-process scan. `"unsupported"`: typed throw. */
|
|
30
30
|
readonly sessionSearchMode?: MemorySessionSearchMode;
|
|
31
|
+
/**
|
|
32
|
+
* Optional overrides for the capped in-process scan. Hosts with a small session set can
|
|
33
|
+
* raise the caps (bounded by the contract `HARD_MAX_SESSION_SEARCH_LINEAR_*` values);
|
|
34
|
+
* defaults are the contract `DEFAULT_MAX_SESSION_SEARCH_LINEAR_*` caps. Values below 1
|
|
35
|
+
* or above the hard cap fail store construction closed with a `TypeError`.
|
|
36
|
+
*/
|
|
37
|
+
readonly search?: {
|
|
38
|
+
readonly maxLinearSessions?: number;
|
|
39
|
+
readonly maxLinearEntries?: number;
|
|
40
|
+
readonly maxLinearBytes?: number;
|
|
41
|
+
};
|
|
31
42
|
}
|
|
32
43
|
export declare function createMemorySessionStore(initialEntries?: readonly SessionEntry[], options?: CreateMemorySessionStoreOptions): SessionStore;
|
package/dist/session-stores.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_MAX_SESSION_SEARCH_LINEAR_BYTES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_ENTRIES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_SESSIONS, DEFAULT_MAX_SESSION_SEARCH_SNIPPET_BYTES, resolveSessionSearchQuery, SESSION_APPEND_CONFLICT_CODE, SESSION_SEARCH_WORKSPACE_METADATA_KEY, SessionAppendConflictError, SessionSearchUnsupportedError, } from "./contracts.js";
|
|
1
|
+
import { DEFAULT_MAX_SESSION_SEARCH_LINEAR_BYTES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_ENTRIES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_SESSIONS, DEFAULT_MAX_SESSION_SEARCH_SNIPPET_BYTES, HARD_MAX_SESSION_SEARCH_LINEAR_BYTES, HARD_MAX_SESSION_SEARCH_LINEAR_ENTRIES, HARD_MAX_SESSION_SEARCH_LINEAR_SESSIONS, resolveSessionSearchQuery, SESSION_APPEND_CONFLICT_CODE, SESSION_SEARCH_WORKSPACE_METADATA_KEY, SessionAppendConflictError, SessionSearchUnsupportedError, } from "./contracts.js";
|
|
2
2
|
import { createId } from "./ids.js";
|
|
3
3
|
export function createSessionEntry(options) {
|
|
4
4
|
const { createId, now, ...entry } = options;
|
|
@@ -96,12 +96,27 @@ function rebuildSessionContextCore(entries, options = {}) {
|
|
|
96
96
|
}
|
|
97
97
|
return { leafId: branch.at(-1)?.id, entries: branch, messages, summaries };
|
|
98
98
|
}
|
|
99
|
+
function resolveLinearSearchCaps(search) {
|
|
100
|
+
return {
|
|
101
|
+
sessions: assertLinearCap(search?.maxLinearSessions, "maxLinearSessions", DEFAULT_MAX_SESSION_SEARCH_LINEAR_SESSIONS, HARD_MAX_SESSION_SEARCH_LINEAR_SESSIONS),
|
|
102
|
+
entries: assertLinearCap(search?.maxLinearEntries, "maxLinearEntries", DEFAULT_MAX_SESSION_SEARCH_LINEAR_ENTRIES, HARD_MAX_SESSION_SEARCH_LINEAR_ENTRIES),
|
|
103
|
+
bytes: assertLinearCap(search?.maxLinearBytes, "maxLinearBytes", DEFAULT_MAX_SESSION_SEARCH_LINEAR_BYTES, HARD_MAX_SESSION_SEARCH_LINEAR_BYTES),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function assertLinearCap(value, name, fallback, hardMax) {
|
|
107
|
+
const cap = value ?? fallback;
|
|
108
|
+
if (!Number.isSafeInteger(cap) || cap < 1 || cap > hardMax) {
|
|
109
|
+
throw new TypeError(`CreateMemorySessionStoreOptions.search.${name} must be a safe integer from 1 to ${hardMax}`);
|
|
110
|
+
}
|
|
111
|
+
return cap;
|
|
112
|
+
}
|
|
99
113
|
export function createMemorySessionStore(initialEntries = [], options = {}) {
|
|
100
114
|
const byId = new Map();
|
|
101
115
|
const bySession = new Map();
|
|
102
116
|
const leafBySession = new Map();
|
|
103
117
|
const idempotencySeen = new Set();
|
|
104
118
|
const mode = options.sessionSearchMode ?? "linear";
|
|
119
|
+
const searchCaps = resolveLinearSearchCaps(options.search);
|
|
105
120
|
for (const entry of initialEntries)
|
|
106
121
|
add(entry);
|
|
107
122
|
return {
|
|
@@ -118,7 +133,7 @@ export function createMemorySessionStore(initialEntries = [], options = {}) {
|
|
|
118
133
|
async searchSessions(query) {
|
|
119
134
|
if (mode === "unsupported")
|
|
120
135
|
throw new SessionSearchUnsupportedError();
|
|
121
|
-
return searchMemorySessionsLinear(bySession, leafBySession, query);
|
|
136
|
+
return searchMemorySessionsLinear(bySession, leafBySession, query, searchCaps);
|
|
122
137
|
},
|
|
123
138
|
};
|
|
124
139
|
function add(entry, options) {
|
|
@@ -160,7 +175,7 @@ export function createMemorySessionStore(initialEntries = [], options = {}) {
|
|
|
160
175
|
leafBySession.set(entry.sessionId, entry.id);
|
|
161
176
|
}
|
|
162
177
|
}
|
|
163
|
-
function searchMemorySessionsLinear(bySession, leafBySession, query) {
|
|
178
|
+
function searchMemorySessionsLinear(bySession, leafBySession, query, caps) {
|
|
164
179
|
const q = resolveSessionSearchQuery(query);
|
|
165
180
|
q.signal?.throwIfAborted();
|
|
166
181
|
let sessionsScanned = 0;
|
|
@@ -168,11 +183,11 @@ function searchMemorySessionsLinear(bySession, leafBySession, query) {
|
|
|
168
183
|
let bytesScanned = 0;
|
|
169
184
|
const matches = [];
|
|
170
185
|
for (const [sessionId, entries] of bySession) {
|
|
171
|
-
if (sessionsScanned >=
|
|
186
|
+
if (sessionsScanned >= caps.sessions)
|
|
172
187
|
break;
|
|
173
|
-
if (entriesScanned >=
|
|
188
|
+
if (entriesScanned >= caps.entries)
|
|
174
189
|
break;
|
|
175
|
-
if (bytesScanned >=
|
|
190
|
+
if (bytesScanned >= caps.bytes)
|
|
176
191
|
break;
|
|
177
192
|
q.signal?.throwIfAborted();
|
|
178
193
|
sessionsScanned += 1;
|
|
@@ -190,9 +205,9 @@ function searchMemorySessionsLinear(bySession, leafBySession, query) {
|
|
|
190
205
|
let matchedModel = false;
|
|
191
206
|
let snippetSource;
|
|
192
207
|
for (const entry of entries) {
|
|
193
|
-
if (entriesScanned >=
|
|
208
|
+
if (entriesScanned >= caps.entries)
|
|
194
209
|
break;
|
|
195
|
-
if (bytesScanned >=
|
|
210
|
+
if (bytesScanned >= caps.bytes)
|
|
196
211
|
break;
|
|
197
212
|
entriesScanned += 1;
|
|
198
213
|
const text = entrySearchText(entry);
|
package/docs/acp.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## What it does
|
|
4
4
|
|
|
5
|
-
`@arnilo/prism-ag-ui/acp` (stable ACP **v1**, `@agentclientprotocol/sdk@1.
|
|
5
|
+
`@arnilo/prism-ag-ui/acp` (stable ACP **v1**, `@agentclientprotocol/sdk@1.4.0` root exports only) exposes two adapters:
|
|
6
6
|
|
|
7
7
|
- `createPrismAcpAgent(options)` — serves ACP as an **agent**: an editor/AI client connects through the SDK transport and drives host-owned Prism sessions with `session/new`, `session/load`, `session/resume`, `session/prompt`, `session/set_mode`, `session/set_config_option`, `session/list`, `session/delete`, `session/close`, and `session/cancel`. The agent is a thin protocol adapter: every capability, decision, and byte cap is wired from host seams, and there is **no second policy engine** on the agent side.
|
|
8
8
|
- `createAcpEventMapper(options)` — maps a Prism `AgentEvent` stream (or `CoWorkEvent`) to ACP `SessionUpdate`s for hosts that stream through their own transport.
|
package/docs/ag-ui.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# Frontend interoperability (AG-UI and ACP)
|
|
2
2
|
|
|
3
|
+
> **Required peer install:** `zod` (the pinned ACP SDK peers it) — see [Optional peer dependencies](peer-dependencies.md).
|
|
4
|
+
|
|
3
5
|
## What it does
|
|
4
6
|
|
|
5
7
|
`@arnilo/prism-ag-ui` is an optional, framework-free protocol adapter over Prism's existing redacted `AgentEvent`, session, durable-run, and persistence seams.
|
|
6
8
|
|
|
7
9
|
- Root export maps Prism events to AG-UI `@ag-ui/core` **0.0.59** events and offers `createAgUiHandler()` (`Request` → SSE `Response`), compatible `createPersistenceAgUiReplay()` pages, distributed `createAgentEventSourceAgUiReplay()` follow, and explicit `createAgUiMcpAdapter()` / `createAgUiMcpAppHandler()` / `createAgUiA2AAdapter()` protocol handshakes.
|
|
8
|
-
- `@arnilo/prism-ag-ui/acp` is the stable ACP **v1** sibling: `createAcpEventMapper()` and `createPrismAcpAgent()` over `@agentclientprotocol/sdk` **1.
|
|
10
|
+
- `@arnilo/prism-ag-ui/acp` is the stable ACP **v1** sibling: `createAcpEventMapper()` and `createPrismAcpAgent()` over `@agentclientprotocol/sdk` **1.4.0** root exports. ACP is a protocol adapter — sessions, modes, MCP, fs/terminal, lifecycle mapping, and caps live on the host seams. See [ACP coding-host interop](acp.md) for the full reference; this page covers AG-UI only.
|
|
9
11
|
- Core remains protocol-free. `resumeAgentRunStream()` / `AgentRunLifecycle.resumeStream()` are generic durable-resume streams shared by adapters.
|
|
10
12
|
|
|
11
13
|
## When to use it
|
|
@@ -38,7 +40,7 @@ npm install @arnilo/prism @arnilo/prism-ag-ui
|
|
|
38
40
|
| `projection` | Explicit safe tool/state/messages/activity/reasoning/raw/custom/interrupt projection. Omit each callback for default deny. Prefer `composeAgUiProjections(createMessagesFromSessionProjection(...), createStateFromStoreProjection(...), createActivityFromToolProgressProjection(), host)` for standard families. |
|
|
39
41
|
| `a2ui` | Opt-in A2UI painting middleware (`{ catalogId, mode, renderToolName?, allowedCatalogIds?, limits? }`). Detects `a2ui_operations` tool results and/or streams from `render_a2ui` args; paints `a2ui-surface` activity events. Absent = inert. |
|
|
40
42
|
| `capabilities` | Optional host declaration narrowed to implemented SSE/projector/lifecycle features; read `handler.capabilities`. |
|
|
41
|
-
| `redactor`, `limits` | Host redaction and narrowing-only finite caps. |
|
|
43
|
+
| `redactor`, `limits` | Host redaction and narrowing-only finite caps (`AgUiLimitOptions`, with its A2UI variant). |
|
|
42
44
|
|
|
43
45
|
The handler accepts only `POST` JSON validated with official AG-UI `RunAgentInputSchema`. Every aggregate is bounded before a callback runs. With no `input.project`, it preserves compatibility: final text user message only; non-empty state or frontend tools fail before authorization/session lookup. With a projector, all current roles/history, context, state, forwarded props, multimodal parts, parent lineage, and tool-result continuations are available as untrusted input. The projector must apply Prism media URL/SSRF/MIME policy before forwarding media. Start a run with no `resume` and no `?cursor=`; replay supplies `?cursor=`.
|
|
44
46
|
|
package/docs/agent-events.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Agent events
|
|
2
2
|
|
|
3
|
+
> **Optional peer install:** `@nats-io/jetstream` + `@nats-io/transport-node` for the JetStream event source — see [Optional peer dependencies](peer-dependencies.md).
|
|
4
|
+
|
|
3
5
|
## What it does
|
|
4
6
|
|
|
5
7
|
`AgentEvent` is the single observable stream every `AgentSession` run emits. Subscribers receive normalized, redacted, in-order events covering agent lifecycle, assistant message streaming, delegated-agent activity, tool execution, queue updates, subscriber overflow, compaction, retry, artifact validation/refinement, and terminal errors. The stream is in-memory, live-only, and bounded per subscriber by `SubscribeOptions`; there is no durable queue, no background work, and no extra dependency.
|
package/docs/agent-loops.md
CHANGED
|
@@ -233,7 +233,7 @@ await session.run(input, { loop: twoShotLoop });
|
|
|
233
233
|
- `ArtifactValidation.errors[].message` may echo model text — `artifact_*` event payloads flow through the same `redactAgentEvent` path as other `AgentEvent`s (see [Agent events](agent-events.md)).
|
|
234
234
|
- `generateValidateReviseLoop` makes at most `1 + maxRevisions + maxToolRounds` provider turns when bounded tools are enabled (otherwise `maxRevisions + 1`); it cannot loop forever. Each revision costs one provider turn plus one store append.
|
|
235
235
|
- Bounded artifact tool calls run sequentially through `dispatchToolCall` (permission + validation + execute); their assistant call and result are persisted before the next provider request. `singleShotLoop` retains its bounded parallel worker pool and original call-order transcript behavior.
|
|
236
|
-
- In a parallel single-shot batch, the worker pool stops claiming calls after the first dispatch error or abort, waits for every already-claimed worker with `Promise.allSettled`,
|
|
236
|
+
- In a parallel single-shot batch, the worker pool stops claiming calls after the first dispatch error or abort, waits for every already-claimed worker with `Promise.allSettled`, then persists rows in call order before rethrowing the first failure: real results for calls that finished, an error row carrying the failure for the call that threw, and a `tool_call_not_dispatched` row for calls the batch never started. A stopped batch therefore never ends the run with `tool_call` ids that have no `tool_result` (providers reject such a history on the next turn); run-level suspension errors (`AgentRunSuspended`, `ERR_PRISM_DELEGATION_SUSPENDED`, `ERR_PRISM_LOOP_*`) are the exception — their resume machinery appends the real result, so the failed call gets no synthetic row. Already-claimed side effects may finish and are not rolled back; successful batches still append results in original call order. The round-level `chargeToolRound` approval gate runs before workers, so approval suspension starts no worker.
|
|
237
237
|
- The loop is a plain object/factory; no class hierarchy, no background work, no extra dependencies. `LoopContext` is a single object literal of bound arrows built once per run.
|
|
238
238
|
- The host-domain-free boundary is guarded by tests: `src/` imports no host-domain package, and the `Artifact*`/`AgentLoop*`/`LoopContext` contracts contain no `workflow`/`node`/`step` field names. Hosts supply their own schema; no host domain type is imported by `src/`.
|
|
239
239
|
|
|
@@ -45,7 +45,7 @@ createAgentSession(config: AgentSessionConfig & { agent: Agent }): AgentSession
|
|
|
45
45
|
string | Message | readonly Message[]
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
`AgentSessionConfig.store` overrides `AgentConfig.store`; otherwise the session gets a private memory store. `AgentSessionConfig.leafId` selects the branch leaf to resume from.
|
|
48
|
+
`AgentSessionConfig.store` overrides `AgentConfig.store`; otherwise the session gets a private memory store. `AgentSessionConfig.leafId` selects the branch leaf to resume from. `AgentSessionConfig.snapshotCacheTtlMs` tunes the in-memory branch cache behind `session.snapshot()`: default `DEFAULT_SNAPSHOT_CACHE_TTL_MS` (1000 ms), `0` disables caching so every read rebuilds from the store, maximum `HARD_MAX_SNAPSHOT_CACHE_TTL_MS` (30 s); values outside `0..hard` fail session construction with `TypeError`. The cache is invalidated by any new leaf or mutation, so the TTL only bounds reuse of an unchanged branch.
|
|
49
49
|
|
|
50
50
|
`AgentConfig.limits` sets run ceilings; `RunOptions.limits` may only narrow configured agent values (`null` counts as no cap, so a configured finite ceiling still wins). Limits cover turns, provider attempts, tool rounds/calls, wall time, request/response bytes, tokens, and optional single-currency cost. Policy axes accept `null` (0.5.4) to disable the axis; request/response bytes reject `null` and stay process-hard at 64 MiB. A breach emits one `run_limit_exceeded` event and throws `AgentRunError` with `result.limit`; see [Runs and usage ledger](runs-and-usage.md#run-limits).
|
|
51
51
|
|
|
@@ -55,6 +55,8 @@ string | Message | readonly Message[]
|
|
|
55
55
|
|
|
56
56
|
## Outputs / response / events
|
|
57
57
|
|
|
58
|
+
`session.fork(options?)` / `session.clone(options?)` take `AgentSessionForkOptions` / `AgentSessionCloneOptions` (leaf id, new session id, metadata, and store overrides), and `session.steer(input, options?)` takes `SteerOptions`. See [Public contracts](public-contracts.md) for the field tables, and the [options index](options-index.md) for every session option surface.
|
|
59
|
+
|
|
58
60
|
`session.run()` / `session.prompt()` resolve to an `AgentRunResult` with `sessionId`, `runId`, `status`, `text`, `content`, optional `message`/`usage`/`leafId`, and terminal `error`/`abortReason` when applicable. Callers may ignore the return value. Failed and aborted runs still emit their terminal events, then reject with `AgentRunError` whose `.result` carries the same shape.
|
|
59
61
|
|
|
60
62
|
`session.stream(input, options?)` subscribes first, starts exactly one run, yields only that run's events, and terminates when the run succeeds, fails, or aborts. Early consumer return aborts the owned run and releases the session. `SubscribeOptions.maxQueuedEvents` / `overflow` may be passed alongside `RunOptions`.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Browser automation
|
|
2
2
|
|
|
3
|
+
> **Optional peer install:** `playwright-core@1.63.0` (exact pin) — see [Optional peer dependencies](peer-dependencies.md).
|
|
4
|
+
|
|
3
5
|
## What it does
|
|
4
6
|
|
|
5
7
|
The `@arnilo/prism-web-tools/browser` subpath exposes six exclusive model-facing tools—`browser_open`, `browser_snapshot`, `browser_act`, `browser_close`, `browser_evaluate`, and `browser_observe`—over a host-supplied Playwright `Browser`. Prism creates one non-persistent `BrowserContext` per run, serializes actions, returns bounded AI-mode accessibility snapshots with snapshot-scoped refs, enforces egress/side-effect/upload/download/screenshot policy, and closes context/pages/listeners/quarantined downloads on close, abort, or manager disposal. Since 0.1.4 the package also rides playwright-core's existing CDP transport for bounded page evaluation, console/network observation, and network/emulation control on Chromium hosts — zero new dependencies, Prism still never launches or downloads browsers.
|
|
@@ -100,8 +102,9 @@ await browser.close();
|
|
|
100
102
|
|
|
101
103
|
## Extension and configuration notes
|
|
102
104
|
|
|
103
|
-
- Compatibility line: `playwright-core@1.
|
|
104
|
-
- Default/hard caps: pages 4/16; actions 100/256; queued actions 16/64; snapshot refs 2k/10k; depth 30/100; snapshot bytes 256 KiB/2 MiB; navigation 30s/120s; action 10s/60s; wait 30s/120s; run wall 20min/30min; popups 4/16; dialogs 16/64; close grace 5s/30s; network requests 1k/10k; redirects/request 10/32; WebSockets 8/32; screenshots 16/64 with 16/64 megapixels and 10 MiB/32 MiB encoded; uploads 8/32 files, 16 MiB/64 MiB each, 64 MiB/256 MiB aggregate; downloads 8/32 files, 32 MiB/256 MiB each, 64 MiB/512 MiB aggregate.
|
|
105
|
+
- Compatibility line: `playwright-core@1.63.0` optional peer. Hosts pin browser binaries/images; Prism package install downloads nothing.
|
|
106
|
+
- Default/hard caps: pages 4/16; actions 100/256; queued actions 16/64; snapshot refs 2k/10k; depth 30/100; snapshot bytes 256 KiB/2 MiB; navigation 30s/120s; action 10s/60s; wait 30s/120s; run wall 20min/30min; idle run TTL 0 (off)/30min; popups 4/16; dialogs 16/64; close grace 5s/30s; network requests 1k/10k; redirects/request 10/32; WebSockets 8/32; screenshots 16/64 with 16/64 megapixels and 10 MiB/32 MiB encoded; uploads 8/32 files, 16 MiB/64 MiB each, 64 MiB/256 MiB aggregate; downloads 8/32 files, 32 MiB/256 MiB each, 64 MiB/512 MiB aggregate.
|
|
107
|
+
- Optional `idleRunTtlMs` (default `0` = off, hard-capped at the run wall-time cap) arms one unref'd manager-scoped sweep interval. A run with no queued action and no activity for the TTL is disposed exactly like `manager.closeRun(runId)` — context and pages closed — so later calls fail with `ERR_PRISM_BROWSER_STATE` and the host can `open()` it again. Any operation (open/snapshot/act/evaluate/observe) resets the clock, in-flight work is never reaped mid-action, and the reaper is cleared by `manager.close()`. Hosts that already close runs explicitly can leave it off; enabling it bounds contexts leaked by abandoned runs.
|
|
105
108
|
- Contexts use `serviceWorkers: "block"` and install `BrowserContext.route()` for every visible HTTP(S)/WebSocket request. `acceptDownloads` is enabled only when `downloads` is configured.
|
|
106
109
|
- `networkPolicy` defaults to `requireContainedProxy: true` (fail closed). Hosts must supply `containedProxyAttestation: { proxyEndpoint, denyDirectEgress: true }`. Private/loopback/link-local, `file`/`data`/`blob`/`javascript`/`devtools` schemes are denied by default. Playwright routing is defense in depth — production DNS/private egress is a host firewall/proxy.
|
|
107
110
|
- Uploads require absolute paths under `uploads.roots` (realpath-contained; symlink escapes rejected). Downloads stream into `downloads.quarantine` with SHA-256/MIME/name metadata; `download_release` requires host `approveRelease`. Screenshots return bounded `ImageContent`.
|
package/docs/cli-rpc.md
CHANGED
|
@@ -10,7 +10,7 @@ The `prism` bin is a thin adapter over `AgentSession` plus a tiny project scaffo
|
|
|
10
10
|
- `prism init <dir>`: create a minimal TypeScript project with one selected provider, `.env.example`, and one offline mock test.
|
|
11
11
|
- `prism dev`: boot the loopback dev inspector over the scaffolded agent (delegates into `@arnilo/prism-coding-tools/dev` when resolvable; plan 040 Task 4).
|
|
12
12
|
|
|
13
|
-
It does not add a TUI, app tools, provider globals,
|
|
13
|
+
It does not add a TUI, app tools, provider globals, resource discovery, or credential storage, and it never auto-discovers extension packages — `--extension` loads only explicitly named modules (relative paths inside the working directory, or allow-listed package/absolute specifiers). `init` uses Node standard-library filesystem APIs and checked-in templates only — no interactive prompts or template-engine dependency.
|
|
14
14
|
|
|
15
15
|
## Live CLI journey (plans/064 Task 5)
|
|
16
16
|
|
|
@@ -115,6 +115,7 @@ Manifests of the negotiation are simple: the agent is loaded from the scaffold c
|
|
|
115
115
|
| `--discover` | Opt-in workspace contribution discovery (`SKILL.md`/`manifest.json`). Never auto-activates or imports. |
|
|
116
116
|
| `--discover-kinds <csv>` | Kinds to scan; defaults to `skill`. Accepts `skill,tool,context,instructions`. |
|
|
117
117
|
| `--no-discovery` | Hard-disable discovery even if `--discover` is set. |
|
|
118
|
+
| `--extension <specifier>` | Load a trusted extension module (repeatable). Relative `./`/`../` paths must realpath-contain inside the working directory; package names and absolute paths must exactly match an entry in `PRISM_EXTENSION_ALLOWLIST` (comma-separated), checked before `import()`. See the extension loading section below. |
|
|
118
119
|
| `--agents-config <path>` | App config root holding `agents/<name>/AGENT.md` bundles (opt-in). Envelopes only; the host resolves them via `resolveAgentBundle`. The CLI never defaults to the user's home directory. |
|
|
119
120
|
| `--instruction <name>` | Select a registered/discovered instruction injector (repeatable). `--instruction false` disables injectors for the run. Names resolve fail-closed. |
|
|
120
121
|
| `--injector-file <path>` | Load a markdown file as a static `every_turn` injector (repeatable). |
|
|
@@ -124,6 +125,19 @@ Manifests of the negotiation are simple: the agent is loaded from the scaffold c
|
|
|
124
125
|
| `--system-md-file <path>` | Read SYSTEM.md from `<path>` instead — user-owned, `source: "user"` (Phase 31). |
|
|
125
126
|
| `--help` | Print usage. |
|
|
126
127
|
|
|
128
|
+
### Extension loading (`--extension`)
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
# cwd-relative trusted module (no allow-list needed)
|
|
132
|
+
prism --provider mock --extension ./my-ext.js -p "hello"
|
|
133
|
+
|
|
134
|
+
# package name or absolute path requires an exact allow-list entry
|
|
135
|
+
PRISM_EXTENSION_ALLOWLIST=@acme/prism-foo \
|
|
136
|
+
prism --provider mock --extension @acme/prism-foo -p "hello"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Each module must export `createExtension()`, a default function, or a default `{ name, setup }` extension. Loaded modules are trusted host code (same trust level as the provider factory import) — the gates decide which code may load, not what loaded code may do: relative paths fail closed on realpath escape (symlinks cannot leave the working directory), package/absolute specifiers fail closed without an allow-list entry, and a broken or wrong-shaped module is a usage error, not a silent skip. Registered contributions activate through `activateKernel` into the run's agent (tools, skills, injectors, context, middleware); extension skills follow the normal fail-closed skill activation (`RunOptions.activeSkills`). There is no npm marketplace, no `plugin.json`, and no MCP auto-start.
|
|
140
|
+
|
|
127
141
|
RPC request envelope:
|
|
128
142
|
|
|
129
143
|
```ts
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Contribution quality budgets
|
|
2
|
+
|
|
3
|
+
## What it does
|
|
4
|
+
|
|
5
|
+
Records the code-quality ceilings a change must not raise, and the procedure for lowering them. Three budgets exist today: the non-null assertion allowance per directory, the public export surface per package, and the benchmark/timing envelopes. All live in `scripts/budgets.json` and are enforced by in-chain gates that run as part of `npm test`.
|
|
6
|
+
|
|
7
|
+
## When to use it
|
|
8
|
+
|
|
9
|
+
Before a change that adds a `!` non-null assertion, adds a public export, or moves code between directories; and after any sweep that removes them — the recorded numbers are lowered in the same change, never later.
|
|
10
|
+
|
|
11
|
+
## Non-null assertions
|
|
12
|
+
|
|
13
|
+
`style/noNonNullAssertion` is an **error** repo-wide in `biome.json`. Directories that still carry legacy sites are switched back to `off` per directory through `overrides`, and `scripts/budgets.json` → `nonNullAssertions` records how many sites remain in each of them.
|
|
14
|
+
|
|
15
|
+
Rules:
|
|
16
|
+
|
|
17
|
+
- New code does not add `!`. Narrow the value once — a local `const` behind an explicit guard — or capture the seam in a helper (the durable-session and elicitation seams are the models). Do not trade the assertion for an `as` cast or a `??` placeholder.
|
|
18
|
+
- Keep the allowlist and the budget rows identical: the gate asserts that the `biome.json` allowlist directories and the `nonNullAssertions.byPath` keys describe the same set, so there is no ambiguity about which row an override corresponds to.
|
|
19
|
+
- A sweep lowers the directory's `byPath` number and the `ceiling` in the same change. Raising a number needs a reason in the `$comment`.
|
|
20
|
+
- When a directory reaches zero sites, delete its `overrides` entry and its `byPath` row: the gate fails on a stale zero row rather than letting the allowance rot.
|
|
21
|
+
- The clusters swept in that pass — `src/agent-approval.ts`, `src/agent-loops.ts`, `src/agent-tool-dispatch.ts`, `packages/prism-coding-tools/src/agent/{glob-match,delete,git*}.ts`, `packages/prism-coding-tools/src/agent/language/framing.ts`, and `packages/prism-coding-tools/src/security/{sandbox-tar,sandbox-fs-operations}.ts` — are re-enabled as errors by the last override in `biome.json` (the last matching override wins), so they cannot regress. The gate rejects a rename that would silently drop that enforcement.
|
|
22
|
+
- Measure locally with one pass: `node_modules/.bin/biome lint --only=style/noNonNullAssertion --reporter=json .`. `--only` reports the rule inside allowlisted directories too, which is what makes the counting gate possible; the gate also fails when a site appears outside the allowlist.
|
|
23
|
+
|
|
24
|
+
## Public export surface
|
|
25
|
+
|
|
26
|
+
`scripts/budgets.json` → `exportCounts` records a ceiling per package. Growth fails the gate and names the package and the exact delta. Prefer re-exporting an existing symbol (or documenting the host-side composition) over widening a package's surface; moving an existing internal helper between modules does not change the count, because the counter dedupes by name.
|
|
27
|
+
|
|
28
|
+
## Timing envelopes
|
|
29
|
+
|
|
30
|
+
Benchmark medians and p95 ceilings in `scripts/budgets.json` are non-flaky sanity bounds, not portable SLOs; the startup gate additionally compares a machine-relative ratio so external CPU load cannot fail the suite. `scripts/benchmark.mjs` produces the evidence-of-record numbers.
|
|
31
|
+
|
|
32
|
+
## Related APIs
|
|
33
|
+
|
|
34
|
+
- `scripts/budget-gates.mjs`: `measureNonNullAssertions()`, `evaluateNonNullBudget()`, `measureExportCounts()`, `checkExportBudget()`, and the startup helpers.
|
|
35
|
+
- `scripts/budget-gate.test.mjs`: the in-chain gate, including the negative fixtures that prove each failure mode.
|
|
36
|
+
- `scripts/run-all-tests.mjs`: the stages `npm test` runs (`gate suites` includes the budget gate).
|
|
37
|
+
- [Release and install](release-and-install.md): the release gates that re-assert these budgets before publication.
|
package/docs/core.md
CHANGED
|
@@ -21,6 +21,8 @@ npm install pg
|
|
|
21
21
|
npm install @nats-io/jetstream @nats-io/transport-node
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
Every peer below is optional and fails closed at first use; the [optional peer dependencies](peer-dependencies.md) matrix lists the exact ranges, pins, and which of them reach the network.
|
|
25
|
+
|
|
24
26
|
## Subpaths Map
|
|
25
27
|
|
|
26
28
|
| Subpath | Description | Optional Peers |
|
package/docs/document-reader.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Document reader (`@arnilo/prism-coding-tools/document-reader`)
|
|
2
2
|
|
|
3
|
+
> **Optional peer install:** `pdf-parse` and/or `mammoth` — see [Optional peer dependencies](peer-dependencies.md).
|
|
4
|
+
|
|
3
5
|
## What it does
|
|
4
6
|
|
|
5
7
|
Optional bounded literal-text extraction for PDF and DOCX files, consumed by the coding `read` tool (plan 018 closeout `doc-reader`, 0.1.6). `createDocumentReader()` returns a `DocumentReader` that the host wires into `createReadTool(cwd, { documentReader })`; the read tool then extracts text from supported documents instead of falling back to the raw text page.
|
package/docs/documents.md
CHANGED
|
@@ -35,7 +35,7 @@ Do **not** use this package for collaborative real-time editing (OT/CRDT), macro
|
|
|
35
35
|
| `createPatchHistory` | `(initialModel: DocumentModel) => PatchHistory` | Creates an interactive undo/redo history manager for host editing workflows. |
|
|
36
36
|
| `renderPreviewBlocks` | `(model: DocumentModel, options?: PreviewBlocksOptions) => PreviewBlock[]` | Emits framework-neutral structured blocks (document outlines, bounded sheet grid chunks, slide summaries). |
|
|
37
37
|
| `renderPreviewHtml` | `(model: DocumentModel, options?: PreviewHtmlOptions) => string` | Emits safe, bounded HTML fragments with all entities escaped and external URLs neutralized. |
|
|
38
|
-
| `
|
|
38
|
+
| `documentModelSchema` | `(kind: DocumentKind, slice?: string \| readonly string[]) => JsonSchema` | Retrieves the Draft-07 JSON Schema for a document kind, or a self-contained sliced sub-schema with resolved `$defs` (`docModelSchema` / `sheetModelSchema` / `deckModelSchema` expose the unsliced schemas). |
|
|
39
39
|
| `validateDocumentModel`| `(model: unknown) => asserts model is DocumentModel` | Validates arbitrary JSON objects against Draft-07 document schemas and structural invariants. |
|
|
40
40
|
|
|
41
41
|
### Capacity Limits and Defaults
|