@arnilo/prism 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +4 -2
  2. package/README.md +17 -7
  3. package/dist/agent-definitions.d.ts +12 -0
  4. package/dist/agent-definitions.js +131 -0
  5. package/dist/agent-loops.d.ts +14 -0
  6. package/dist/agent-loops.js +161 -0
  7. package/dist/agents.js +263 -76
  8. package/dist/cache-helpers.d.ts +28 -0
  9. package/dist/cache-helpers.js +73 -0
  10. package/dist/cli-runner.d.ts +38 -2
  11. package/dist/cli-runner.js +167 -5
  12. package/dist/compaction.js +2 -0
  13. package/dist/config.js +47 -12
  14. package/dist/contracts.d.ts +581 -6
  15. package/dist/contracts.js +41 -1
  16. package/dist/contribution-parsing.d.ts +19 -0
  17. package/dist/contribution-parsing.js +124 -0
  18. package/dist/contributions.d.ts +13 -3
  19. package/dist/contributions.js +96 -20
  20. package/dist/extensions.js +3 -0
  21. package/dist/index.d.ts +19 -9
  22. package/dist/index.js +10 -4
  23. package/dist/input.d.ts +7 -1
  24. package/dist/input.js +52 -11
  25. package/dist/instruction-injection.d.ts +28 -0
  26. package/dist/instruction-injection.js +55 -0
  27. package/dist/manifests.d.ts +1 -1
  28. package/dist/manifests.js +3 -3
  29. package/dist/models.d.ts +4 -1
  30. package/dist/models.js +5 -2
  31. package/dist/node/agent-definitions.d.ts +98 -0
  32. package/dist/node/agent-definitions.js +389 -0
  33. package/dist/node/contribution-discovery.d.ts +17 -0
  34. package/dist/node/contribution-discovery.js +163 -0
  35. package/dist/node/instruction-injectors.d.ts +32 -0
  36. package/dist/node/instruction-injectors.js +72 -0
  37. package/dist/node/session-store-jsonl.d.ts +1 -1
  38. package/dist/node/session-store-jsonl.js +42 -4
  39. package/dist/node/system-project-prompts.d.ts +30 -0
  40. package/dist/node/system-project-prompts.js +53 -0
  41. package/dist/provider-events.d.ts +3 -1
  42. package/dist/provider-events.js +34 -0
  43. package/dist/provider-request-policy.js +15 -1
  44. package/dist/providers/openai-compatible.js +1 -1
  45. package/dist/providers.d.ts +6 -2
  46. package/dist/providers.js +15 -1
  47. package/dist/redaction.d.ts +2 -1
  48. package/dist/redaction.js +3 -0
  49. package/dist/registry-options.d.ts +5 -0
  50. package/dist/registry-options.js +5 -0
  51. package/dist/rpc.d.ts +6 -2
  52. package/dist/rpc.js +71 -13
  53. package/dist/session-stores.d.ts +3 -1
  54. package/dist/session-stores.js +67 -6
  55. package/dist/skills.d.ts +4 -1
  56. package/dist/skills.js +3 -1
  57. package/dist/system-prompts.js +6 -2
  58. package/dist/testing/compaction-conformance.d.ts +17 -0
  59. package/dist/testing/compaction-conformance.js +61 -0
  60. package/dist/testing/extension-conformance.d.ts +26 -0
  61. package/dist/testing/extension-conformance.js +55 -0
  62. package/dist/testing/provider-conformance.d.ts +7 -0
  63. package/dist/testing/provider-conformance.js +18 -31
  64. package/dist/testing/session-store-conformance.d.ts +20 -0
  65. package/dist/testing/session-store-conformance.js +92 -0
  66. package/dist/testing/tool-conformance.d.ts +39 -0
  67. package/dist/testing/tool-conformance.js +79 -0
  68. package/dist/tools.d.ts +7 -2
  69. package/dist/tools.js +50 -13
  70. package/docs/agent-definitions.md +251 -0
  71. package/docs/agent-events.md +199 -0
  72. package/docs/agent-loops.md +217 -0
  73. package/docs/agent-session-runtime.md +20 -8
  74. package/docs/cli-rpc.md +39 -4
  75. package/docs/compaction-and-retry.md +2 -2
  76. package/docs/compaction-conformance.md +76 -0
  77. package/docs/compaction-llm.md +6 -3
  78. package/docs/compaction-observational-memory.md +4 -4
  79. package/docs/configuration-and-manifests.md +6 -1
  80. package/docs/context-and-skills.md +79 -6
  81. package/docs/contribution-discovery.md +149 -0
  82. package/docs/contribution-registries.md +9 -6
  83. package/docs/credentials-and-redaction.md +2 -0
  84. package/docs/customization.md +191 -0
  85. package/docs/database-persistence.md +407 -0
  86. package/docs/extension-authoring.md +193 -0
  87. package/docs/extension-conformance.md +80 -0
  88. package/docs/extensions.md +6 -0
  89. package/docs/host-security.md +141 -0
  90. package/docs/index.md +40 -19
  91. package/docs/input-and-prompt-assembly.md +19 -3
  92. package/docs/instruction-injection.md +183 -0
  93. package/docs/migration.md +201 -0
  94. package/docs/model-registry.md +122 -0
  95. package/docs/node-jsonl-session-store.md +5 -4
  96. package/docs/performance.md +127 -0
  97. package/docs/provider-caching.md +206 -0
  98. package/docs/provider-conformance.md +32 -5
  99. package/docs/provider-layer.md +51 -11
  100. package/docs/provider-packages.md +65 -5
  101. package/docs/provider-request-policies.md +113 -0
  102. package/docs/providers/kimi.md +22 -0
  103. package/docs/providers/neuralwatt.md +388 -0
  104. package/docs/providers/openai-compatible.md +1 -0
  105. package/docs/providers/openai.md +21 -0
  106. package/docs/providers/opencode-go.md +31 -3
  107. package/docs/providers/openrouter.md +29 -0
  108. package/docs/providers/zai.md +17 -0
  109. package/docs/public-contracts.md +87 -12
  110. package/docs/release-and-install.md +76 -26
  111. package/docs/runs-and-usage.md +236 -0
  112. package/docs/session-store-conformance.md +78 -0
  113. package/docs/session-stores-and-branching.md +10 -6
  114. package/docs/session-stores.md +126 -0
  115. package/docs/settings-auth-trust-security.md +18 -4
  116. package/docs/structured-output.md +247 -0
  117. package/docs/system-prompts.md +104 -2
  118. package/docs/tool-conformance.md +87 -0
  119. package/docs/tools.md +64 -8
  120. package/package.json +35 -2
package/dist/rpc.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Readable, Writable } from "node:stream";
2
- import type { AgentSession, CommandDefinition } from "./contracts.js";
3
- export type RpcCommandName = "prompt" | "steer" | "followUp" | "abort" | "state" | "messages" | "setModel" | "compact" | "switchSession" | "forkSession" | "cloneSession" | "command";
2
+ import type { AgentSession, CommandDefinition, InstructionInjector } from "./contracts.js";
3
+ import type { ContributionRegistry } from "./contributions.js";
4
+ export type RpcCommandName = "prompt" | "steer" | "followUp" | "abort" | "state" | "messages" | "setModel" | "compact" | "switchSession" | "forkSession" | "cloneSession" | "checkout" | "command";
4
5
  export interface RpcRequest {
5
6
  readonly id: string | number;
6
7
  readonly command: RpcCommandName;
@@ -9,6 +10,9 @@ export interface RpcRequest {
9
10
  export interface RpcSessionFactory {
10
11
  createSession(id?: string): AgentSession;
11
12
  readonly commands?: readonly CommandDefinition[];
13
+ /** Optional registry for resolving `instructionInjectors` names in `prompt`/`followUp`
14
+ * params (Phase 30). Names resolve fail-closed. */
15
+ readonly instructionInjectors?: ContributionRegistry<InstructionInjector>;
12
16
  }
13
17
  export interface RpcServerOptions extends RpcSessionFactory {
14
18
  readonly stdin: Readable;
package/dist/rpc.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { errorToErrorInfo } from "./redaction.js";
3
+ import { resolveInstructionInjectors } from "./instruction-injection.js";
3
4
  export async function runRpcServer(options) {
4
5
  const first = options.createSession();
5
6
  const state = {
6
7
  current: first,
8
+ currentHandleId: first.id,
7
9
  sessions: new Map([[first.id, first]]),
8
10
  commands: new Map((options.commands ?? []).map((command) => [command.name, command])),
9
11
  createSession: options.createSession,
12
+ ...(options.instructionInjectors ? { instructionInjectors: options.instructionInjectors } : {}),
10
13
  };
11
14
  const activeRuns = new Map();
12
15
  const lines = createInterface({ input: options.stdin, crlfDelay: Infinity });
@@ -58,10 +61,14 @@ async function handleRequest(request, state, stdout, activeRuns) {
58
61
  write(stdout, { id: request.id, ok: false, error: errorToErrorInfo(new Error(`Session ${session.id} already has an active run for request ${existing.requestId}`)) });
59
62
  return;
60
63
  }
64
+ // ponytail: Phase 30 — resolve run options (incl. instructionInjectors names) BEFORE opening the
65
+ // event pump. A fail-closed name resolution throws here, surfaces as an error response via the
66
+ // outer try/catch, and never strands the event subscription's for-await.
67
+ const runOpts = runOptions(state, request.params);
61
68
  const events = pumpEvents(session, stdout, request.id);
62
69
  const promise = (async () => {
63
70
  try {
64
- await session.run(input, runOptions(state, request.params));
71
+ await session.run(input, runOpts);
65
72
  }
66
73
  finally {
67
74
  await events;
@@ -82,7 +89,16 @@ async function handleRequest(request, state, stdout, activeRuns) {
82
89
  write(stdout, { id: request.id, ok: true, result: { sessionId: state.current.id } });
83
90
  break;
84
91
  case "state":
85
- write(stdout, { id: request.id, ok: true, result: { sessionId: state.current.id, sessions: [...state.sessions.keys()], model: state.model } });
92
+ write(stdout, { id: request.id, ok: true, result: {
93
+ sessionId: state.current.id,
94
+ leafId: state.current.leafId,
95
+ handleId: state.currentHandleId,
96
+ // ponytail: `sessions` kept as a backward-compatible handle-id string list (== sessionId
97
+ // for the initial session and clones); `handles` is the branch-handle detail view.
98
+ sessions: [...state.sessions.keys()],
99
+ handles: [...state.sessions.entries()].map(([handleId, session]) => ({ handleId, sessionId: session.id, leafId: session.leafId })),
100
+ model: state.model,
101
+ } });
86
102
  break;
87
103
  case "messages":
88
104
  write(stdout, { id: request.id, ok: true, result: { sessionId: state.current.id, entries: await state.current.entries() } });
@@ -95,26 +111,37 @@ async function handleRequest(request, state, stdout, activeRuns) {
95
111
  write(stdout, { id: request.id, ok: true, result: await state.current.compact() });
96
112
  break;
97
113
  case "switchSession": {
98
- const id = stringParam(request.params, "sessionId") ?? stringParam(request.params, "id");
99
- if (!id)
100
- throw new Error("switchSession requires params.sessionId");
101
- const session = state.sessions.get(id) ?? makeSession(state, id);
114
+ const handleId = stringParam(request.params, "handleId") ?? stringParam(request.params, "sessionId") ?? stringParam(request.params, "id");
115
+ if (!handleId)
116
+ throw new Error("switchSession requires params.handleId (or sessionId)");
117
+ const session = state.sessions.get(handleId) ?? makeSession(state, handleId);
102
118
  state.current = session;
103
- write(stdout, { id: request.id, ok: true, result: { sessionId: session.id } });
119
+ state.currentHandleId = handleId;
120
+ write(stdout, { id: request.id, ok: true, result: { sessionId: session.id, leafId: session.leafId, handleId } });
104
121
  break;
105
122
  }
106
123
  case "forkSession": {
107
124
  const session = state.current.fork({ leafId: stringParam(request.params, "leafId") });
108
- state.sessions.set(session.id, session);
125
+ const handleId = registerSession(state, session);
109
126
  state.current = session;
110
- write(stdout, { id: request.id, ok: true, result: { sessionId: session.id } });
127
+ state.currentHandleId = handleId;
128
+ write(stdout, { id: request.id, ok: true, result: { sessionId: session.id, leafId: session.leafId, handleId } });
111
129
  break;
112
130
  }
113
131
  case "cloneSession": {
114
132
  const session = await state.current.clone({ id: stringParam(request.params, "id"), leafId: stringParam(request.params, "leafId") });
115
- state.sessions.set(session.id, session);
133
+ const handleId = registerSession(state, session);
116
134
  state.current = session;
117
- write(stdout, { id: request.id, ok: true, result: { sessionId: session.id } });
135
+ state.currentHandleId = handleId;
136
+ write(stdout, { id: request.id, ok: true, result: { sessionId: session.id, leafId: session.leafId, handleId } });
137
+ break;
138
+ }
139
+ case "checkout": {
140
+ const leafId = stringParam(request.params, "leafId");
141
+ if (!leafId)
142
+ throw new Error("checkout requires params.leafId");
143
+ await state.current.checkout(leafId);
144
+ write(stdout, { id: request.id, ok: true, result: { sessionId: state.current.id, leafId: state.current.leafId, handleId: state.currentHandleId } });
118
145
  break;
119
146
  }
120
147
  case "command": {
@@ -142,13 +169,38 @@ function eventEnvelope(event, requestId) {
142
169
  const runId = "runId" in event ? event.runId : undefined;
143
170
  return { type: "event", id: requestId, sessionId, runId, event };
144
171
  }
172
+ function registerSession(state, session, preferredHandleId) {
173
+ const base = preferredHandleId ?? session.id;
174
+ // ponytail: branch handles coexist for one sessionId. fork() reuses the sessionId (it is a
175
+ // branch of the same session, not a copy; clone() is the copy), so on collision we mint a
176
+ // stable, self-describing handle id (`{sessionId}#2`, `#3`, ...). Clients switch among handles
177
+ // via the handleId returned by fork/clone/switch; the (sessionId, leafId) pair is read live
178
+ // from the session so it stays accurate as the leaf advances on append/run.
179
+ if (!state.sessions.has(base)) {
180
+ state.sessions.set(base, session);
181
+ return base;
182
+ }
183
+ let n = 2;
184
+ while (state.sessions.has(`${base}#${n}`))
185
+ n++;
186
+ const handleId = `${base}#${n}`;
187
+ state.sessions.set(handleId, session);
188
+ return handleId;
189
+ }
145
190
  function makeSession(state, id) {
146
191
  const session = state.createSession(id);
147
192
  state.sessions.set(session.id, session);
148
193
  return session;
149
194
  }
150
195
  function runOptions(state, params) {
151
- return { model: modelParam(params) ?? state.model, maxToolRounds: numberParam(params, "maxToolRounds") };
196
+ const names = stringArrayParam(params, "instructionInjectors");
197
+ // ponytail: fail-closed — unknown name throws (caller surfaces as RPC error), matching CLI.
198
+ const injectors = names.length ? resolveInstructionInjectors({ registry: state.instructionInjectors, names }) : undefined;
199
+ return {
200
+ model: modelParam(params) ?? state.model,
201
+ maxToolRounds: numberParam(params, "maxToolRounds"),
202
+ ...(injectors ? { instructionInjectors: injectors } : {}),
203
+ };
152
204
  }
153
205
  function modelParam(params) {
154
206
  if (!params)
@@ -172,11 +224,17 @@ function objectParam(params, key) {
172
224
  const value = params?.[key];
173
225
  return isObject(value) ? value : undefined;
174
226
  }
227
+ function stringArrayParam(params, key) {
228
+ const value = params?.[key];
229
+ if (!Array.isArray(value))
230
+ return [];
231
+ return value.filter((v) => typeof v === "string");
232
+ }
175
233
  function isObject(value) {
176
234
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
177
235
  }
178
236
  function isCommand(value) {
179
- return typeof value === "string" && ["prompt", "steer", "followUp", "abort", "state", "messages", "setModel", "compact", "switchSession", "forkSession", "cloneSession", "command"].includes(value);
237
+ return typeof value === "string" && ["prompt", "steer", "followUp", "abort", "state", "messages", "setModel", "compact", "switchSession", "forkSession", "cloneSession", "checkout", "command"].includes(value);
180
238
  }
181
239
  function readRequestId(value) {
182
240
  return isObject(value) && (typeof value.id === "string" || typeof value.id === "number") ? value.id : null;
@@ -1,4 +1,4 @@
1
- import type { Message, SessionEntry, SessionStore } from "./contracts.js";
1
+ import type { Message, SessionBranchRead, BranchReader, SessionEntry, SessionStore } from "./contracts.js";
2
2
  export interface CreateSessionEntryOptions extends Omit<SessionEntry, "id" | "timestamp"> {
3
3
  readonly id?: string;
4
4
  readonly timestamp?: string;
@@ -19,7 +19,9 @@ export interface SessionContextSnapshot {
19
19
  readonly summaries: readonly string[];
20
20
  }
21
21
  export declare function createSessionEntry(options: CreateSessionEntryOptions): SessionEntry;
22
+ export declare function getSessionBranchEntries(reader: BranchReader, query: SessionBranchRead): Promise<readonly SessionEntry[]>;
22
23
  export declare function getSessionBranchEntries(entries: readonly SessionEntry[], options?: SessionBranchOptions): readonly SessionEntry[];
23
24
  export declare function listSessionBranches(entries: readonly SessionEntry[]): readonly SessionBranch[];
25
+ export declare function rebuildSessionContext(reader: BranchReader, query: SessionBranchRead): Promise<SessionContextSnapshot>;
24
26
  export declare function rebuildSessionContext(entries: readonly SessionEntry[], options?: SessionBranchOptions): SessionContextSnapshot;
25
27
  export declare function createMemorySessionStore(initialEntries?: readonly SessionEntry[]): SessionStore;
@@ -1,3 +1,4 @@
1
+ import { SESSION_APPEND_CONFLICT_CODE, SessionAppendConflictError } from "./contracts.js";
1
2
  export function createSessionEntry(options) {
2
3
  const { createId, now, ...entry } = options;
3
4
  return {
@@ -6,7 +7,28 @@ export function createSessionEntry(options) {
6
7
  timestamp: entry.timestamp ?? (now?.() ?? new Date()).toISOString(),
7
8
  };
8
9
  }
9
- export function getSessionBranchEntries(entries, options = {}) {
10
+ // ponytail: max pages the reader path will follow before stopping. Guards against a buggy/
11
+ // malicious reader that never ends `nextCursor`. Ancestor chains are short in practice; bump
12
+ // this if a legitimate branch exceeds it.
13
+ const MAX_BRANCH_PAGES = 64;
14
+ export function getSessionBranchEntries(input, options = {}) {
15
+ return typeof input === "function" ? readBranchFromReader(input, options) : getSessionBranchEntriesCore(input, options);
16
+ }
17
+ async function readBranchFromReader(reader, query) {
18
+ const items = [];
19
+ let cursor;
20
+ for (let page = 0; page < MAX_BRANCH_PAGES; page++) {
21
+ const result = await reader(cursor ? { ...query, cursor } : query);
22
+ items.push(...result.items);
23
+ cursor = result.nextCursor;
24
+ if (!cursor)
25
+ break;
26
+ }
27
+ // Reuse the validated in-memory walk: the reader returns the ancestor SET (any order);
28
+ // indexEntries + the parentId walk order it and still reject missing parents / dupes.
29
+ return getSessionBranchEntriesCore(items, { leafId: query.leafId });
30
+ }
31
+ function getSessionBranchEntriesCore(entries, options = {}) {
10
32
  const index = indexEntries(entries);
11
33
  const leafId = options.leafId ?? entries.at(-1)?.id;
12
34
  if (!leafId)
@@ -29,8 +51,20 @@ export function listSessionBranches(entries) {
29
51
  .filter((entry) => !index.parentIds.has(entry.id))
30
52
  .map((entry) => ({ leafId: entry.id, entries: getSessionBranchEntries(entries, { leafId: entry.id }) }));
31
53
  }
32
- export function rebuildSessionContext(entries, options = {}) {
33
- const branch = getSessionBranchEntries(entries, options);
54
+ export function rebuildSessionContext(input, options = {}) {
55
+ if (typeof input === "function") {
56
+ return rebuildSessionContextFromReader(input, options);
57
+ }
58
+ return rebuildSessionContextCore(input, options);
59
+ }
60
+ async function rebuildSessionContextFromReader(reader, query) {
61
+ // ponytail: pass the drained branch back through the sync core so compaction logic has ONE
62
+ // code path; the redundant re-walk is O(branch length) and branch chains are short.
63
+ const branch = await readBranchFromReader(reader, query);
64
+ return rebuildSessionContextCore(branch, { leafId: query.leafId });
65
+ }
66
+ function rebuildSessionContextCore(entries, options = {}) {
67
+ const branch = getSessionBranchEntriesCore(entries, options);
34
68
  const compaction = [...branch].reverse().find((entry) => entry.kind === "compaction" && entry.summary && isCompactionEntryData(entry.data));
35
69
  if (!compaction || !isCompactionEntryData(compaction.data)) {
36
70
  return {
@@ -61,11 +95,13 @@ export function rebuildSessionContext(entries, options = {}) {
61
95
  export function createMemorySessionStore(initialEntries = []) {
62
96
  const byId = new Map();
63
97
  const bySession = new Map();
98
+ const leafBySession = new Map();
99
+ const idempotencySeen = new Set();
64
100
  for (const entry of initialEntries)
65
101
  add(entry);
66
102
  return {
67
- async append(entry) {
68
- add(entry);
103
+ async append(entry, options) {
104
+ add(entry, options);
69
105
  },
70
106
  async list(sessionId) {
71
107
  return (bySession.get(sessionId) ?? []).map(cloneEntry);
@@ -75,13 +111,38 @@ export function createMemorySessionStore(initialEntries = []) {
75
111
  return entry ? cloneEntry(entry) : undefined;
76
112
  },
77
113
  };
78
- function add(entry) {
114
+ function add(entry, options) {
115
+ // ponytail: idempotency dedup keyed on (session, key, expectedParentId) so a
116
+ // run-level key shared across distinct linear appends (each at a different
117
+ // parentId) does not collapse them; only an exact retry at the same position
118
+ // deduplicates. DB adapters may enforce stricter per-key uniqueness.
119
+ const dedupKey = options?.idempotencyKey
120
+ ? `${entry.sessionId}\u0000${options.idempotencyKey}\u0000${options.expectedParentId ?? ""}`
121
+ : undefined;
122
+ if (dedupKey !== undefined && idempotencySeen.has(dedupKey)) {
123
+ throw new SessionAppendConflictError({ code: SESSION_APPEND_CONFLICT_CODE, idempotencyDuplicate: true });
124
+ }
125
+ // expectedParentId is existence validation (the parent must already be in the
126
+ // store or be undefined for a root). Tip-CAS is intentionally NOT used: prism
127
+ // allows branching from any existing leaf (checkout + append), so a stale-but-
128
+ // existing parent is a valid branch, not a conflict. DB adapters may layer
129
+ // stricter tip-CAS via unique constraints for linear-only sessions.
130
+ if (options?.expectedParentId !== undefined && !byId.has(options.expectedParentId)) {
131
+ throw new SessionAppendConflictError({
132
+ code: SESSION_APPEND_CONFLICT_CODE,
133
+ expectedParentId: options.expectedParentId,
134
+ currentLeafId: leafBySession.get(entry.sessionId),
135
+ });
136
+ }
79
137
  if (byId.has(entry.id))
80
138
  throw new Error(`Duplicate session entry id: ${entry.id}`);
139
+ if (dedupKey !== undefined)
140
+ idempotencySeen.add(dedupKey);
81
141
  byId.set(entry.id, entry);
82
142
  const entries = bySession.get(entry.sessionId) ?? [];
83
143
  entries.push(entry);
84
144
  bySession.set(entry.sessionId, entries);
145
+ leafBySession.set(entry.sessionId, entry.id);
85
146
  }
86
147
  }
87
148
  function cloneEntry(entry) {
package/dist/skills.d.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import type { Skill, SkillRegistry, ToolDefinition } from "./contracts.js";
2
+ import { type DuplicateRegistrationOptions } from "./registry-options.js";
2
3
  export interface ResolveActiveSkillsOptions {
3
4
  readonly registry: SkillRegistry;
4
5
  readonly names?: readonly string[];
5
6
  readonly tools?: readonly ToolDefinition[];
6
7
  }
7
- export declare function createSkillRegistry(skills?: readonly Skill[]): SkillRegistry;
8
+ export interface SkillRegistryOptions extends DuplicateRegistrationOptions {
9
+ }
10
+ export declare function createSkillRegistry(skills?: readonly Skill[], options?: SkillRegistryOptions): SkillRegistry;
8
11
  export declare function resolveActiveSkills(options: ResolveActiveSkillsOptions): readonly Skill[];
package/dist/skills.js CHANGED
@@ -1,7 +1,9 @@
1
- export function createSkillRegistry(skills = []) {
1
+ import { assertCanRegister } from "./registry-options.js";
2
+ export function createSkillRegistry(skills = [], options = {}) {
2
3
  const byName = new Map();
3
4
  const registry = {
4
5
  register(skill) {
6
+ assertCanRegister(byName, skill.name, "skill", skill.name, options.duplicate);
5
7
  byName.set(skill.name, skill);
6
8
  },
7
9
  get(name) {
@@ -1,4 +1,8 @@
1
- const sourceRank = new Map([["package", 0], ["app", 1], ["user", 2], ["run", 3]]);
1
+ // ponytail: Phase 31 — `source: "user"` is the global base layer; `source: "app"` sits above package.
2
+ // Behavioral change from Phase 14: `source: "user"` is now the global base (rank 0), not a high-priority caller override.
3
+ // Unknown custom sources sort after package but before app/run so they cannot override host/run layers.
4
+ const sourceRank = new Map([["user", 0], ["package", 1], ["app", 2], ["run", 3]]);
5
+ const unknownSourceRank = 1.5;
2
6
  export function composeSystemPrompt(contributions = [], options = {}) {
3
7
  const parts = baseParts(options.base);
4
8
  if (contributions === false)
@@ -39,7 +43,7 @@ function baseParts(base) {
39
43
  return (typeof base === "string" ? [base] : base ?? []).filter((text) => text.length > 0);
40
44
  }
41
45
  function rank(layer) {
42
- return sourceRank.get(layer.source ?? "") ?? 10;
46
+ return sourceRank.get(layer.source ?? "") ?? unknownSourceRank;
43
47
  }
44
48
  function joinPrompt(parts) {
45
49
  return parts.length ? parts.join("\n\n") : undefined;
@@ -0,0 +1,17 @@
1
+ import type { CompactionStrategy } from "../contracts.js";
2
+ export interface CompactionConformanceOptions {
3
+ /** Secret strings that must not appear in the summary or returned entries. */
4
+ readonly secrets?: readonly string[];
5
+ /** When true, asserts the strategy observes an already-aborted signal. */
6
+ readonly exerciseAbort?: boolean;
7
+ }
8
+ /**
9
+ * Assert that a `CompactionStrategy` satisfies the core adapter contract:
10
+ * `compact()` returns a `CompactionResult` with a non-empty summary, known
11
+ * secrets are redacted from the summary and any returned entries, and (when
12
+ * requested) an already-aborted signal is observed. Throws on the first
13
+ * violation; returns the result when the strategy conforms.
14
+ */
15
+ export declare function assertCompactionStrategyConforms(strategy: CompactionStrategy, options?: CompactionConformanceOptions): Promise<{
16
+ summary: string;
17
+ }>;
@@ -0,0 +1,61 @@
1
+ // ponytail: dependency-free conformance helper for the CompactionStrategy
2
+ // adapter contract. The core default strategy and the first-party LLM
3
+ // compaction package both implement CompactionStrategy; adapter authors call
4
+ // this once to assert the summary-result shape, secret redaction, and
5
+ // abort-observation invariants that compaction.test.ts and the LLM
6
+ // compaction package's strategy tests already check. Throws plain Error; no
7
+ // test runner, no network, no real credentials.
8
+ import { createSessionEntry } from "../session-stores.js";
9
+ /**
10
+ * Assert that a `CompactionStrategy` satisfies the core adapter contract:
11
+ * `compact()` returns a `CompactionResult` with a non-empty summary, known
12
+ * secrets are redacted from the summary and any returned entries, and (when
13
+ * requested) an already-aborted signal is observed. Throws on the first
14
+ * violation; returns the result when the strategy conforms.
15
+ */
16
+ export async function assertCompactionStrategyConforms(strategy, options = {}) {
17
+ const secrets = options.secrets ?? [];
18
+ const entries = [
19
+ createSessionEntry({ sessionId: "s", kind: "message", message: { role: "user", content: [{ type: "text", text: `old ${secrets[0] ?? "history"}` }] } }),
20
+ createSessionEntry({ sessionId: "s", kind: "message", message: { role: "assistant", content: [{ type: "text", text: "recent" }] } }),
21
+ ];
22
+ const context = {
23
+ sessionId: "conformance",
24
+ entries,
25
+ keepRecentEntries: 1,
26
+ trigger: "manual",
27
+ secrets,
28
+ };
29
+ const result = await strategy.compact(context);
30
+ if (!result.summary || typeof result.summary !== "string") {
31
+ throw new Error("CompactionStrategy must return a non-empty string summary");
32
+ }
33
+ for (const secret of secrets) {
34
+ if (secret && result.summary.includes(secret)) {
35
+ throw new Error(`CompactionStrategy leaked a secret into the summary: ${secret.slice(0, 8)}...`);
36
+ }
37
+ }
38
+ if (result.entries) {
39
+ for (const secret of secrets) {
40
+ if (secret && JSON.stringify(result.entries).includes(secret)) {
41
+ throw new Error(`CompactionStrategy leaked a secret into returned entries: ${secret.slice(0, 8)}...`);
42
+ }
43
+ }
44
+ }
45
+ if (options.exerciseAbort) {
46
+ const controller = new AbortController();
47
+ controller.abort(new Error("aborted"));
48
+ let observed = false;
49
+ try {
50
+ await strategy.compact({ ...context, signal: controller.signal });
51
+ }
52
+ catch {
53
+ observed = true;
54
+ }
55
+ if (!observed) {
56
+ throw new Error("CompactionStrategy did not observe an already-aborted signal");
57
+ }
58
+ }
59
+ return { summary: result.summary };
60
+ }
61
+ //# sourceMappingURL=compaction-conformance.js.map
@@ -0,0 +1,26 @@
1
+ import type { Extension } from "../contracts.js";
2
+ import { createExtensionKernel, type ExtensionKernel } from "../extensions.js";
3
+ export interface ExtensionConformanceOptions {
4
+ /**
5
+ * Secret strings that must be redacted from any setup-error event under the
6
+ * default `errorPolicy: "event"`. When omitted, error redaction is not
7
+ * asserted. Ignored under `expectThrow`.
8
+ */
9
+ readonly secrets?: readonly string[];
10
+ /**
11
+ * When true, asserts that a thrown setup error is rethrown to the caller
12
+ * (the `errorPolicy: "throw"` opt-in). Use this to confirm the host's throw
13
+ * policy surfaces setup failures instead of isolating them.
14
+ */
15
+ readonly expectThrow?: boolean;
16
+ }
17
+ /**
18
+ * Assert that an `Extension` satisfies the core adapter contract: `setup` runs
19
+ * on load, registered contributions land in the inert contribution registries,
20
+ * and (when a `secrets` list is provided) a failing setup emits a redacted
21
+ * `extension_error` event under the default event policy, or rethrows under
22
+ * `expectThrow`. Throws on the first violation; returns the loaded kernel so
23
+ * the caller can inspect registered contributions.
24
+ */
25
+ export declare function assertExtensionConforms(extension: Extension, options?: ExtensionConformanceOptions): Promise<ExtensionKernel>;
26
+ export { createExtensionKernel };
@@ -0,0 +1,55 @@
1
+ // ponytail: dependency-free conformance helper for the Extension adapter
2
+ // contract. Extension authors call this once to assert their Extension's
3
+ // setup runs, contributions land in the inert registries (no side effects
4
+ // until the host selects them), and setup errors are handled per the kernel's
5
+ // error policy: redacted as `extension_error` events under the default
6
+ // `errorPolicy: "event"`, or rethrown under `errorPolicy: "throw"`. Mirrors
7
+ // the assertions in src/__tests__/extensions.test.ts. Throws plain Error; no
8
+ // test runner, no network.
9
+ import { createExtensionKernel } from "../extensions.js";
10
+ /**
11
+ * Assert that an `Extension` satisfies the core adapter contract: `setup` runs
12
+ * on load, registered contributions land in the inert contribution registries,
13
+ * and (when a `secrets` list is provided) a failing setup emits a redacted
14
+ * `extension_error` event under the default event policy, or rethrows under
15
+ * `expectThrow`. Throws on the first violation; returns the loaded kernel so
16
+ * the caller can inspect registered contributions.
17
+ */
18
+ export async function assertExtensionConforms(extension, options = {}) {
19
+ const kernel = createExtensionKernel({ secrets: options.secrets, errorPolicy: options.expectThrow ? "throw" : undefined });
20
+ await kernel.load([extension]);
21
+ // setup ran: the extension loaded without rejecting. Contribution registries
22
+ // are inert by construction (the kernel stores envelopes; it never invokes
23
+ // provider/tool/skill capabilities until host code resolves and calls them).
24
+ if (options.expectThrow) {
25
+ // A second extension that throws must rethrow under errorPolicy: "throw".
26
+ const failing = { name: "conformance-failing", setup: () => { throw new Error("conformance setup failed"); } };
27
+ let threw = false;
28
+ try {
29
+ await kernel.load([failing]);
30
+ }
31
+ catch {
32
+ threw = true;
33
+ }
34
+ if (!threw)
35
+ throw new Error("Failing extension setup did not rethrow under errorPolicy: throw");
36
+ return kernel;
37
+ }
38
+ if (options.secrets && options.secrets.length > 0) {
39
+ const secret = options.secrets[0];
40
+ const failing = { name: "conformance-failing", setup: () => { throw new Error(`boom ${secret}`); } };
41
+ const errors = [];
42
+ kernel.events.on("extension_error", (event) => { errors.push(event.error ?? {}); });
43
+ await kernel.load([failing]);
44
+ if (errors.length === 0)
45
+ throw new Error("Failing extension setup did not emit an extension_error event");
46
+ if (errors[0]?.message?.includes(secret)) {
47
+ throw new Error("Setup error event leaked a secret instead of redacting it");
48
+ }
49
+ }
50
+ return kernel;
51
+ }
52
+ // Re-export the kernel factory so adapter authors can build a fresh kernel
53
+ // without an extra import when composing custom probes.
54
+ export { createExtensionKernel };
55
+ //# sourceMappingURL=extension-conformance.js.map
@@ -23,6 +23,12 @@ export interface ToolCallDeltaExpectation {
23
23
  export interface SerializedContentCoverageOptions {
24
24
  readonly unsupported?: readonly ContentBlock["type"][];
25
25
  }
26
+ export interface ProviderHeaderOwnershipConformanceOptions {
27
+ /** Provider-owned header names mapped to the authoritative values the provider must set. */
28
+ readonly owned: Readonly<Record<string, string>>;
29
+ /** Caller-supplied headers, including attempts to override owned names and non-owned additions. */
30
+ readonly caller: Readonly<Record<string, string>>;
31
+ }
26
32
  export interface ProviderSecretLeakConformanceOptions {
27
33
  readonly events: readonly ProviderEvent[];
28
34
  readonly secrets: readonly string[];
@@ -32,5 +38,6 @@ export declare function assertProviderStreamConforms(options: ProviderStreamConf
32
38
  export declare function assertAbortIsObserved(options: ProviderAbortConformanceOptions): Promise<void>;
33
39
  export declare function assertToolCallDeltasReconstruct(events: readonly ProviderEvent[], expected: readonly ToolCallDeltaExpectation[]): readonly ToolCallContent[];
34
40
  export declare function assertSerializedRequestCoversContent(request: ProviderRequest, body: unknown, options?: SerializedContentCoverageOptions): void;
41
+ export declare function assertProviderOwnedHeadersWin(captured: Headers, options: ProviderHeaderOwnershipConformanceOptions): void;
35
42
  export declare function assertNoSecretLeak(events: readonly ProviderEvent[], secrets: readonly string[]): void;
36
43
  export declare function assertUsageAccounting(events: readonly ProviderEvent[], expected: Usage): Usage;
@@ -1,3 +1,4 @@
1
+ import { reconstructToolCallDeltas } from "../provider-events.js";
1
2
  export async function collectProviderEvents(provider, request) {
2
3
  const events = [];
3
4
  for await (const event of provider.generate(request))
@@ -62,6 +63,23 @@ export function assertSerializedRequestCoversContent(request, body, options = {}
62
63
  }
63
64
  }
64
65
  }
66
+ export function assertProviderOwnedHeadersWin(captured, options) {
67
+ const ownedLower = {};
68
+ for (const [name, expected] of Object.entries(options.owned))
69
+ ownedLower[name.toLowerCase()] = expected;
70
+ for (const [name, expected] of Object.entries(ownedLower)) {
71
+ const actual = captured.get(name);
72
+ if (actual !== expected)
73
+ throw new Error(`Caller header overrode provider-owned "${name}": expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
74
+ }
75
+ for (const [name, callerValue] of Object.entries(options.caller)) {
76
+ if (Object.prototype.hasOwnProperty.call(ownedLower, name.toLowerCase()))
77
+ continue;
78
+ const actual = captured.get(name);
79
+ if (actual !== callerValue)
80
+ throw new Error(`Provider dropped non-owned caller header "${name}": expected ${JSON.stringify(callerValue)}, got ${JSON.stringify(actual)}`);
81
+ }
82
+ }
65
83
  export function assertNoSecretLeak(events, secrets) {
66
84
  const eventText = JSON.stringify(events);
67
85
  for (const secret of secrets) {
@@ -82,37 +100,6 @@ export function assertUsageAccounting(events, expected) {
82
100
  }
83
101
  return actual;
84
102
  }
85
- function reconstructToolCallDeltas(events) {
86
- const partials = new Map();
87
- for (const event of events) {
88
- if (event.type !== "tool_call_delta")
89
- continue;
90
- const partial = partials.get(event.index) ?? { argumentsText: "" };
91
- if (event.id !== undefined)
92
- partial.id = event.id;
93
- if (event.name !== undefined)
94
- partial.name = event.name;
95
- if (event.argumentsText !== undefined)
96
- partial.argumentsText += event.argumentsText;
97
- partials.set(event.index, partial);
98
- }
99
- return [...partials.entries()].sort(([a], [b]) => a - b).map(([index, partial]) => {
100
- if (!partial.id || !partial.name)
101
- throw new Error(`Incomplete tool call delta at index ${index}`);
102
- return { type: "tool_call", id: partial.id, name: partial.name, arguments: parseArguments(partial.argumentsText, index) };
103
- });
104
- }
105
- function parseArguments(text, index) {
106
- try {
107
- const value = text ? JSON.parse(text) : {};
108
- if (!value || typeof value !== "object" || Array.isArray(value))
109
- throw new Error("not object");
110
- return value;
111
- }
112
- catch (error) {
113
- throw new Error(`Invalid tool call arguments at index ${index}: ${error instanceof Error ? error.message : String(error)}`);
114
- }
115
- }
116
103
  function contentBlockCanaries(block) {
117
104
  switch (block.type) {
118
105
  case "text":
@@ -0,0 +1,20 @@
1
+ import type { SessionStore } from "../contracts.js";
2
+ export interface SessionStoreConformanceOptions {
3
+ /** Stable session id used for the conformance run; defaults to "conformance". */
4
+ readonly sessionId?: string;
5
+ /**
6
+ * When true, also exercises the optional `readBranchPath` branch-reader path
7
+ * and asserts it returns the ancestor chain in root-to-leaf order. Skipped
8
+ * when the store does not implement `readBranchPath`.
9
+ */
10
+ readonly exerciseReadBranchPath?: boolean;
11
+ }
12
+ /**
13
+ * Assert that a `SessionStore` implementation satisfies the core adapter
14
+ * contract: round-trip append/list, duplicate-entry-id rejection,
15
+ * `expectedParentId` conflict (with nothing appended), `idempotencyKey`
16
+ * deduplication, branching from any existing entry (not just the tip), and
17
+ * distinct linear appends sharing a key are not collapsed. Throws on the first
18
+ * violation; returns silently when the store conforms.
19
+ */
20
+ export declare function assertSessionStoreConforms(store: SessionStore, options?: SessionStoreConformanceOptions): Promise<void>;