@arnilo/prism 0.0.5 → 0.0.6

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 (57) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/dist/agent-loops.d.ts +1 -0
  3. package/dist/agent-loops.js +26 -16
  4. package/dist/agents.js +2 -3
  5. package/dist/contracts.d.ts +2 -0
  6. package/dist/ids.d.ts +2 -0
  7. package/dist/ids.js +6 -0
  8. package/dist/index.d.ts +5 -1
  9. package/dist/index.js +3 -1
  10. package/dist/session-stores.js +2 -3
  11. package/dist/testing/persistence-schema.d.ts +45 -7
  12. package/dist/testing/persistence-schema.js +138 -24
  13. package/dist/thinking.d.ts +42 -0
  14. package/dist/thinking.js +92 -0
  15. package/dist/tools.js +2 -3
  16. package/dist/use-case-model.d.ts +63 -0
  17. package/dist/use-case-model.js +52 -0
  18. package/docs/a2a.md +4 -2
  19. package/docs/agent-events.md +10 -15
  20. package/docs/agent-loops.md +11 -8
  21. package/docs/coding-agent-tools.md +33 -12
  22. package/docs/coding-security.md +2 -2
  23. package/docs/compaction-llm.md +17 -7
  24. package/docs/compaction-observational-memory.md +28 -4
  25. package/docs/credential-storage.md +58 -9
  26. package/docs/credentials-and-redaction.md +1 -1
  27. package/docs/database-persistence.md +8 -3
  28. package/docs/host-security.md +10 -6
  29. package/docs/index.md +23 -20
  30. package/docs/mcp-tools.md +26 -10
  31. package/docs/migration.md +146 -2
  32. package/docs/node-filesystem-config.md +1 -0
  33. package/docs/node-jsonl-session-store.md +5 -4
  34. package/docs/postgres-persistence.md +3 -3
  35. package/docs/provider-caching.md +16 -4
  36. package/docs/provider-conformance.md +39 -1
  37. package/docs/provider-packages.md +60 -3
  38. package/docs/providers/ai-sdk.md +36 -0
  39. package/docs/providers/kimi.md +124 -61
  40. package/docs/providers/neuralwatt.md +19 -13
  41. package/docs/providers/openai.md +56 -13
  42. package/docs/providers/opencode-go.md +118 -30
  43. package/docs/providers/openrouter.md +105 -35
  44. package/docs/providers/zai.md +94 -45
  45. package/docs/release-and-install.md +47 -49
  46. package/docs/review-coverage-2026-07-17-provider-validation.md +192 -0
  47. package/docs/runs-and-usage.md +1 -1
  48. package/docs/sqlite-persistence.md +2 -2
  49. package/docs/structured-output.md +1 -1
  50. package/docs/thinking-and-reasoning.md +98 -0
  51. package/docs/tool-execution-primitives.md +3 -3
  52. package/docs/tools.md +15 -0
  53. package/docs/use-case-model-selection.md +109 -0
  54. package/docs/workflow-orchestration-primitives.md +1 -0
  55. package/docs/workflows.md +17 -10
  56. package/docs/working-and-semantic-memory.md +1 -0
  57. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -5,7 +5,34 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.0.6] - 2026-07-19
9
+
10
+ ### Added
11
+
12
+ - Caller-gated model discovery: `listOpenAIModels`, `listKimiModels`, `listZaiModels`, `listOpenRouterModels`, and `listOpenCodeGoModels`. Provider setup remains network-free; hosts explicitly fetch and register current models.
13
+ - Shared `ThinkingLevel` helpers and use-case model bindings. Background compaction and observational-memory jobs can use an explicit provider/model or a supplied session-model fallback.
14
+ - Opt-in sequential artifact-loop tools: `loop: { strategy: "generate-validate-revise", toolCalls: "bounded" }`. Tool rounds use existing authorization/redaction/ledger paths, share `maxToolRounds` across candidates, and fail with `artifact_failed` metadata `{ reason: "tool_round_limit" }` after exhaustion.
15
+ - Checksummed SQLite/PostgreSQL migration histories and catalog-shape verification, bounded JSON Schema compilation LRU, and public `assertFiniteVector` validation.
16
+
17
+ ### Changed
18
+
19
+ - Provider packages now document and implement current cache, reasoning, streaming, and discovery behavior. OpenAI Responses replay/function-call/SSE argument handling is corrected; Kimi adds optional Moonshot support; Z.AI and OpenCode Go catalogs/routes were refreshed; OpenRouter discovery/reasoning and NeuralWatt thinking controls are hardened. AI SDK remains host-model-owned.
20
+ - Workflow definitions now require a non-empty `revision`; cancellation requires exact ownership and the current workflow definition. All workflow limits have finite hard caps.
21
+ - Coding tools now enforce bounded streamed reads, write/edit inputs, shell wall time, total output, and spill-file lifecycle. Custom coding operation interfaces now receive bounded read/stat/write/edit options and abort signals.
22
+ - Encrypted credential helpers `encryptBytes`, `decryptBytes`, and envelope rotation are asynchronous. Existing credential files must meet restrictive Unix permission requirements. Linux Secret Service/GNOME Keyring byte-array reads are accepted by the keychain store.
23
+ - MCP Streamable HTTP requires HTTPS and explicit `allowedOrigins`; loopback HTTP requires explicit opt-in. Discovery, schemas, results, and response bodies are bounded.
24
+ - Compaction and observational-memory workers now have finite turn/call/transcript/error budgets. A2A streaming uses strict incremental UTF-8 and LF/CRLF SSE parsing.
25
+ - Generated Prism, workflow, and evaluation IDs use cryptographic UUIDs; non-finite embedding vectors now fail before scoring or persistence.
26
+
27
+ ### Security
28
+
29
+ - Fixed cross-owner workflow cancellation and duplicate active-run overwrite risks.
30
+ - Added fail-closed limits and validation at file, process, credential, MCP, migration, schema, vector, provider-worker, and A2A trust boundaries.
31
+
32
+ ### Upgrade notes
33
+
34
+ - Finish or deliberately migrate pre-0.0.6 workflow runs/checkpoints before upgrading: their definition hashes lack the required revision.
35
+ - Update workflow definitions with `revision`, cancellation callers with `workflow` plus exact ownership, MCP HTTP configs with `allowedOrigins`, and custom coding/credential integrations for the changed interfaces above.
9
36
 
10
37
  ## [0.0.5] - 2026-07-16
11
38
 
@@ -6,6 +6,7 @@ export declare function generateValidateReviseLoop(opts: {
6
6
  readonly parser?: ArtifactParser<unknown>;
7
7
  readonly repairer?: ArtifactRepairer<unknown>;
8
8
  readonly maxRevisions?: number;
9
+ readonly toolCalls?: "disabled" | "bounded";
9
10
  }): AgentLoopStrategy;
10
11
  export declare function resolveToolConcurrency(options: {
11
12
  loop?: AgentLoopStrategy | AgentLoopOptions;
@@ -1,4 +1,5 @@
1
1
  import { inputMessages } from "./input.js";
2
+ import { createId } from "./ids.js";
2
3
  function throwIfAborted(signal) {
3
4
  if (signal.aborted)
4
5
  throw signal.reason instanceof Error ? signal.reason : new Error("Agent run aborted");
@@ -55,10 +56,8 @@ function defaultRepairer() {
55
56
  });
56
57
  }
57
58
  // ponytail: GenerateValidateReviseLoop reuses LoopContext primitives only —
58
- // no provider/retry/store/event re-implementation. T is host-defined, Prism
59
- // never instantiates it. No tools in artifact revisions (roadmap scope is
60
- // generate→validate→revise; tool coupling deferred). Phase 28 fires
61
- // artifact_* events at the marked seams (noop here).
59
+ // no provider/retry/store/event re-implementation. Bounded artifact tools use
60
+ // same dispatcher at concurrency one; add parallelism only with ordering need.
62
61
  export function generateValidateReviseLoop(opts) {
63
62
  const max = opts.maxRevisions ?? 3;
64
63
  const repairer = opts.repairer ?? defaultRepairer();
@@ -68,12 +67,14 @@ export function generateValidateReviseLoop(opts) {
68
67
  let usage;
69
68
  let nextInput = ctx.input;
70
69
  let pendingHistory = [];
71
- for (let turn = 1; turn <= max + 1; turn += 1) {
70
+ let toolRounds = 0;
71
+ let attempts = 0;
72
+ for (let turn = 1; attempts <= max; turn += 1) {
72
73
  throwIfAborted(ctx.signal);
73
74
  ctx.emit({ type: "turn_started", sessionId: ctx.sessionId, runId: ctx.runId, turn });
74
75
  const request = await ctx.assemble(nextInput, undefined, turn);
75
76
  throwIfAborted(ctx.signal);
76
- const { content, messageId, started, usage: turnUsage } = await ctx.generate(request);
77
+ const { content, calls, messageId, started, usage: turnUsage } = await ctx.generate(request);
77
78
  usage = turnUsage ?? usage;
78
79
  if (pendingHistory.length > 0) {
79
80
  ctx.history.push(...pendingHistory);
@@ -88,10 +89,17 @@ export function generateValidateReviseLoop(opts) {
88
89
  ctx.emit({ type: "message_finished", sessionId: ctx.sessionId, runId: ctx.runId, message });
89
90
  }
90
91
  ctx.emit({ type: "turn_finished", sessionId: ctx.sessionId, runId: ctx.runId, turn });
91
- const text = content
92
- .filter((b) => b.type === "text")
93
- .map((b) => b.text)
94
- .join("");
92
+ if (opts.toolCalls === "bounded" && calls.length > 0) {
93
+ if (toolRounds >= ctx.maxToolRounds) {
94
+ const result = { ok: false, errors: [{ message: "maximum tool rounds exceeded" }], metadata: { reason: "tool_round_limit" } };
95
+ ctx.emit({ type: "artifact_failed", sessionId: ctx.sessionId, runId: ctx.runId, turn, attempt: attempts + 1, result });
96
+ return usage;
97
+ }
98
+ toolRounds += 1;
99
+ await dispatchToolCallsInOrder(calls, { ...ctx, toolConcurrency: 1 });
100
+ nextInput = [];
101
+ continue;
102
+ }
95
103
  const artifactCtx = {
96
104
  sessionId: ctx.sessionId,
97
105
  runId: ctx.runId,
@@ -99,13 +107,17 @@ export function generateValidateReviseLoop(opts) {
99
107
  signal: ctx.signal,
100
108
  metadata: ctx.metadata,
101
109
  };
110
+ const text = content
111
+ .filter((b) => b.type === "text")
112
+ .map((b) => b.text)
113
+ .join("");
102
114
  const parsed = opts.parser
103
115
  ? await opts.parser(text, artifactCtx)
104
116
  : { ok: true, value: text };
105
117
  // Parse failure ends the loop silently (terminal parse errors stay on `error`).
106
118
  if (!parsed.ok || parsed.value === undefined)
107
119
  return usage;
108
- const attempt = turn;
120
+ const attempt = ++attempts;
109
121
  ctx.emit({ type: "artifact_validation_started", sessionId: ctx.sessionId, runId: ctx.runId, turn, attempt });
110
122
  const result = await opts.validator(parsed.value, artifactCtx);
111
123
  ctx.emit({ type: "artifact_validation_finished", sessionId: ctx.sessionId, runId: ctx.runId, turn, attempt, result });
@@ -113,7 +125,7 @@ export function generateValidateReviseLoop(opts) {
113
125
  ctx.emit({ type: "artifact_finished", sessionId: ctx.sessionId, runId: ctx.runId, turn, attempt, result });
114
126
  return usage;
115
127
  }
116
- if (turn > max) {
128
+ if (attempt > max) {
117
129
  ctx.emit({ type: "artifact_failed", sessionId: ctx.sessionId, runId: ctx.runId, turn, attempt, result });
118
130
  return usage;
119
131
  }
@@ -124,7 +136,6 @@ export function generateValidateReviseLoop(opts) {
124
136
  await ctx.appendMessage(message);
125
137
  pendingHistory = repairMessages;
126
138
  nextInput = repairMessages;
127
- continue;
128
139
  }
129
140
  return usage;
130
141
  },
@@ -195,13 +206,12 @@ export function resolveLoop(options, config) {
195
206
  parser: loop.parser,
196
207
  repairer: loop.repairer,
197
208
  maxRevisions: loop.maxRevisions,
209
+ toolCalls: loop.toolCalls,
198
210
  });
199
211
  }
200
212
  throw new Error(`Unknown agent loop strategy: ${strategy}`);
201
213
  }
202
214
  return loop;
203
215
  }
204
- function randomId(prefix) {
205
- return `${prefix}_${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
206
- }
216
+ const randomId = createId;
207
217
  //# sourceMappingURL=agent-loops.js.map
package/dist/agents.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AgentRunError } from "./contracts.js";
2
2
  import { resolveLoop, resolveToolConcurrency } from "./agent-loops.js";
3
+ import { createId } from "./ids.js";
3
4
  import { createProviderTurnMetadata, readProviderHttpStatus } from "./observability.js";
4
5
  import { createDefaultCompactionStrategy, isCompactionEntryData } from "./compaction.js";
5
6
  import { assembleProviderInput } from "./input.js";
@@ -800,7 +801,5 @@ function createUsageAccumulator() {
800
801
  },
801
802
  };
802
803
  }
803
- function randomId(prefix) {
804
- return `${prefix}_${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
805
- }
804
+ const randomId = createId;
806
805
  //# sourceMappingURL=agents.js.map
@@ -1440,6 +1440,8 @@ export type AgentLoopOptions = {
1440
1440
  readonly parser?: ArtifactParser<unknown>;
1441
1441
  readonly repairer?: ArtifactRepairer<unknown>;
1442
1442
  readonly maxRevisions?: number;
1443
+ /** Dispatch provider tool calls in artifact turns. Default `"disabled"`; `"bounded"` uses RunOptions.maxToolRounds sequentially. */
1444
+ readonly toolCalls?: "disabled" | "bounded";
1443
1445
  /** Native provider JSON-schema output. Ignored when `structuredOutputMode` is `artifact-loop`. */
1444
1446
  readonly structuredOutput?: StructuredOutputOptions;
1445
1447
  /** `native` maps schema to capable providers; `artifact-loop` keeps repair turns only. */
package/dist/ids.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Cryptographically unpredictable identifier for internal Prism records. */
2
+ export declare function createId(prefix: string): string;
package/dist/ids.js ADDED
@@ -0,0 +1,6 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /** Cryptographically unpredictable identifier for internal Prism records. */
3
+ export function createId(prefix) {
4
+ return `${prefix}_${randomUUID()}`;
5
+ }
6
+ //# sourceMappingURL=ids.js.map
package/dist/index.d.ts CHANGED
@@ -30,6 +30,10 @@ export { assertStructuredOutputRequestSupported, DEFAULT_MAX_STRUCTURED_OUTPUT_N
30
30
  export { createProviderTurnMetadata, readProviderHttpStatus } from "./observability.js";
31
31
  export { createProviderRequestPolicyChain, createSessionCachePolicy, mergeProviderRequestOptions } from "./provider-request-policy.js";
32
32
  export type { SessionCachePolicyOptions } from "./provider-request-policy.js";
33
+ export { THINKING_LEVELS, applyThinkingLevel, isThinkingLevel, normalizeThinkingLevel, thinkingCompatFor, thinkingFamilyForModel, } from "./thinking.js";
34
+ export type { ThinkingCompatFamily, ThinkingLevel } from "./thinking.js";
35
+ export { resolveUseCaseModel, resolveUseCaseModelBinding, useCaseCredentialProviderId, } from "./use-case-model.js";
36
+ export type { ResolveUseCaseModelInput, ResolvedUseCaseModel, UseCaseModelBinding, } from "./use-case-model.js";
33
37
  export { composeSystemPrompt, mergeSystemPromptConfig } from "./system-prompts.js";
34
38
  export type { ComposeSystemPromptOptions } from "./system-prompts.js";
35
39
  export type { ModelRegistry, ModelRegistryOptions } from "./models.js";
@@ -67,5 +71,5 @@ export type { DispatchToolCallOptions, ToolArgumentValidationError, ToolArgument
67
71
  export type { DuplicateRegistrationOptions, DuplicateRegistrationPolicy } from "./registry-options.js";
68
72
  export { dispatchToolCallsInOrder, generateValidateReviseLoop, isAgentLoopOptions, resolveLoop, resolveToolConcurrency, singleShotLoop } from "./agent-loops.js";
69
73
  export declare const name = "prism";
70
- export declare const version = "0.0.5";
74
+ export declare const version = "0.0.6";
71
75
  export declare const description = "Agent harness for AI providers, agents, sessions, and tools.";
package/dist/index.js CHANGED
@@ -18,6 +18,8 @@ export { authMethodKey, defineProviderPackage, systemPromptContributionKey } fro
18
18
  export { assertStructuredOutputRequestSupported, DEFAULT_MAX_STRUCTURED_OUTPUT_NAME_LENGTH, DEFAULT_MAX_STRUCTURED_OUTPUT_SCHEMA_BYTES, modelSupportsStructuredOutput, resolveRunProviderOptions, StructuredOutputError, validateStructuredOutputOptions, } from "./structured-output.js";
19
19
  export { createProviderTurnMetadata, readProviderHttpStatus } from "./observability.js";
20
20
  export { createProviderRequestPolicyChain, createSessionCachePolicy, mergeProviderRequestOptions } from "./provider-request-policy.js";
21
+ export { THINKING_LEVELS, applyThinkingLevel, isThinkingLevel, normalizeThinkingLevel, thinkingCompatFor, thinkingFamilyForModel, } from "./thinking.js";
22
+ export { resolveUseCaseModel, resolveUseCaseModelBinding, useCaseCredentialProviderId, } from "./use-case-model.js";
21
23
  export { composeSystemPrompt, mergeSystemPromptConfig } from "./system-prompts.js";
22
24
  export { definePrismManifest, parsePrismManifest } from "./manifests.js";
23
25
  export { assertDeclaredMediaTypeMatches, assertMediaBlocksWithinBounds, assertMessagesSupportModelCapabilities, assertModelSupportsContentBlocks, assertSsrfAllowedUrl, collectMessageContentBlocks, contentBlockInputModality, DEFAULT_MAX_AUDIO_DURATION_MS, DEFAULT_MAX_MEDIA_ITEM_BYTES, DEFAULT_MAX_MEDIA_ITEMS_PER_REQUEST, DEFAULT_MAX_MEDIA_REQUEST_BYTES, DEFAULT_MEDIA_FETCH_TIMEOUT_MS, loadBoundedBinaryResource, MediaContentError, MODEL_INPUT_CAPABILITIES, resolveMediaContentBlock, resolveMediaContentBlocks, sniffMediaMimeType, UnsupportedModalityError, } from "./content.js";
@@ -37,6 +39,6 @@ export { resolveInstructionInjectors, runInstructionInjectors } from "./instruct
37
39
  export { createToolRegistry, dispatchToolCall, filterTools, createToolParameterValidator } from "./tools.js";
38
40
  export { dispatchToolCallsInOrder, generateValidateReviseLoop, isAgentLoopOptions, resolveLoop, resolveToolConcurrency, singleShotLoop } from "./agent-loops.js";
39
41
  export const name = "prism";
40
- export const version = "0.0.5";
42
+ export const version = "0.0.6";
41
43
  export const description = "Agent harness for AI providers, agents, sessions, and tools.";
42
44
  //# sourceMappingURL=index.js.map
@@ -1,4 +1,5 @@
1
1
  import { SESSION_APPEND_CONFLICT_CODE, SessionAppendConflictError } from "./contracts.js";
2
+ import { createId } from "./ids.js";
2
3
  export function createSessionEntry(options) {
3
4
  const { createId, now, ...entry } = options;
4
5
  return {
@@ -171,7 +172,5 @@ function indexEntries(entries) {
171
172
  }
172
173
  return { byId, parentIds };
173
174
  }
174
- function randomId(prefix) {
175
- return `${prefix}_${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
176
- }
175
+ const randomId = createId;
177
176
  //# sourceMappingURL=session-stores.js.map
@@ -7,6 +7,8 @@ export interface PersistenceColumnDefinition {
7
7
  readonly name: string;
8
8
  readonly type: PersistenceColumnType;
9
9
  readonly nullable?: boolean;
10
+ /** Portable SQL default literal, if the schema requires one. */
11
+ readonly defaultValue?: string;
10
12
  /** Column participates in tenant isolation boundaries when true. */
11
13
  readonly tenantScoped?: boolean;
12
14
  }
@@ -42,6 +44,8 @@ export interface PersistenceMigrationStep {
42
44
  readonly version: number;
43
45
  readonly name: string;
44
46
  readonly description?: string;
47
+ /** SHA-256 of the canonical checked-in migration schema content. */
48
+ readonly checksum: string;
45
49
  }
46
50
  /** Versioned migration expectations shared by production database adapters. */
47
51
  export interface PersistenceMigrationContract {
@@ -74,6 +78,46 @@ export declare function tenantScopedUniqueKey(baseColumns: readonly string[], te
74
78
  export declare function assertPersistenceSchemaModel(model: PersistenceSchemaModel): void;
75
79
  /** Assert migration steps are strictly increasing and end at the target schema version. */
76
80
  export declare function assertPersistenceMigrationContract(contract: PersistenceMigrationContract): void;
81
+ export type PersistenceSchemaDialect = "sqlite" | "postgres";
82
+ export interface PersistenceSchemaShapeColumn {
83
+ readonly name: string;
84
+ readonly type: string;
85
+ readonly nullable: boolean;
86
+ readonly defaultValue?: string;
87
+ }
88
+ export interface PersistenceSchemaShapeForeignKey {
89
+ readonly columns: readonly string[];
90
+ readonly referencesTable: string;
91
+ readonly referencesColumns: readonly string[];
92
+ }
93
+ export interface PersistenceSchemaShapeTable {
94
+ readonly name: string;
95
+ readonly columns: readonly PersistenceSchemaShapeColumn[];
96
+ readonly primaryKey: readonly string[];
97
+ readonly uniqueKeys: readonly (readonly string[])[];
98
+ readonly foreignKeys: readonly PersistenceSchemaShapeForeignKey[];
99
+ }
100
+ export interface PersistenceSchemaShapeIndex {
101
+ readonly name: string;
102
+ readonly table: string;
103
+ readonly columns: readonly string[];
104
+ readonly unique: boolean;
105
+ }
106
+ export interface PersistenceSchemaShape {
107
+ readonly tables: readonly PersistenceSchemaShapeTable[];
108
+ readonly indexes: readonly PersistenceSchemaShapeIndex[];
109
+ }
110
+ /** Compare bounded dialect catalog output against every required schema-v3 detail. */
111
+ export declare function assertPersistenceSchemaShape(shape: PersistenceSchemaShape, dialect: PersistenceSchemaDialect, model?: PersistenceSchemaModel): void;
112
+ export interface AppliedPersistenceMigration {
113
+ readonly name: string;
114
+ readonly version: string;
115
+ readonly checksum: string | null;
116
+ }
117
+ /** Reject altered migration history before any new DDL or runtime write. */
118
+ export declare function assertAppliedPersistenceMigrations(contract: PersistenceMigrationContract, applied: readonly AppliedPersistenceMigration[]): {
119
+ readonly legacyChecksums: boolean;
120
+ };
77
121
  /**
78
122
  * Assert a dialect-local adapter exposes the canonical table and index names.
79
123
  * Adapters pass the table/index names their migration runner created.
@@ -82,13 +126,7 @@ export declare function assertAdapterSchemaMatchesModel(adapterTables: readonly
82
126
  /** Guard adapter SQL tests: reject obvious value interpolation into statement text. */
83
127
  export declare function assertParameterizedQuery(sql: string, boundValues: readonly unknown[]): void;
84
128
  /** Simulate migration up + reopen: applied steps must match the contract in order. */
85
- export declare function assertMigrationUpAndReopen(contract: PersistenceMigrationContract, appliedAfterUp: readonly {
86
- readonly name: string;
87
- readonly version: string;
88
- }[], appliedAfterReopen: readonly {
89
- readonly name: string;
90
- readonly version: string;
91
- }[]): void;
129
+ export declare function assertMigrationUpAndReopen(contract: PersistenceMigrationContract, appliedAfterUp: readonly AppliedPersistenceMigration[], appliedAfterReopen: readonly AppliedPersistenceMigration[]): void;
92
130
  export interface PersistenceQueryConformanceFixture {
93
131
  readonly seedEntries: (entries: readonly SessionEntry[]) => Promise<void> | void;
94
132
  readonly queryEntries: (query: SessionEntryQuery) => Promise<PersistencePage<SessionEntry>>;
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  // ponytail: dialect-neutral persistence schema model and migration contracts for
2
3
  // SQLite/PostgreSQL adapter packages (Plan 056 Task 1). SQL stays package-local;
3
4
  // this module defines the shared table/index/pagination/migration expectations
@@ -107,7 +108,7 @@ export function createPersistenceSchemaModel() {
107
108
  { name: "run_id", type: "text", nullable: true },
108
109
  { name: "timestamp", type: "timestamp" },
109
110
  { name: "kind", type: "text" },
110
- { name: "schema_version", type: "integer" },
111
+ { name: "schema_version", type: "integer", nullable: true },
111
112
  { name: "message", type: "json", nullable: true },
112
113
  { name: "event", type: "json", nullable: true },
113
114
  { name: "model", type: "json", nullable: true },
@@ -156,7 +157,6 @@ export function createPersistenceSchemaModel() {
156
157
  ...TENANT_COLUMNS,
157
158
  { name: "metadata", type: "json", nullable: true },
158
159
  ],
159
- uniqueKeys: [["tenant_id", "idempotency_key"]],
160
160
  foreignKeys: [{ columns: ["session_id"], referencesTable: "prism_sessions", referencesColumns: ["id"] }],
161
161
  },
162
162
  {
@@ -210,7 +210,7 @@ export function createPersistenceSchemaModel() {
210
210
  { name: "session_id", type: "text" },
211
211
  { name: "run_id", type: "text", nullable: true },
212
212
  { name: "entry_id", type: "text", nullable: true },
213
- { name: "scope", type: "text" },
213
+ { name: "scope", type: "text", defaultValue: "'run_total'" },
214
214
  { name: "turn", type: "integer", nullable: true },
215
215
  { name: "attempt", type: "integer", nullable: true },
216
216
  { name: "usage", type: "json" },
@@ -235,7 +235,9 @@ export function createPersistenceSchemaModel() {
235
235
  { name: "evaluation_ids", type: "json" },
236
236
  { name: "created_at", type: "timestamp" },
237
237
  { name: "created_by", type: "text", nullable: true },
238
- ...TENANT_COLUMNS,
238
+ { name: "tenant_id", type: "text", tenantScoped: true },
239
+ { name: "account_id", type: "text", nullable: true, tenantScoped: true },
240
+ { name: "user_id", type: "text", nullable: true, tenantScoped: true },
239
241
  { name: "metadata", type: "json", nullable: true },
240
242
  ],
241
243
  foreignKeys: [{ columns: ["run_id"], referencesTable: "prism_runs", referencesColumns: ["id"] }],
@@ -281,7 +283,7 @@ export function createPersistenceSchemaModel() {
281
283
  { name: "prism_session_entries_session_run_ts_idx", table: "prism_session_entries", columns: ["session_id", "run_id", "timestamp"], purpose: "run-scoped entry listing" },
282
284
  { name: "prism_session_entries_session_ts_id_idx", table: "prism_session_entries", columns: ["session_id", "timestamp", "id"], purpose: "cursor pagination without full scans" },
283
285
  { name: "prism_session_entries_session_id_idx", table: "prism_session_entries", columns: ["session_id", "id"], purpose: "append parent validation and recursive branch reads" },
284
- { name: "prism_session_append_idempotency_unique", table: "prism_session_append_idempotency", columns: ["session_id", "expected_parent_id", "idempotency_key"], unique: true, purpose: "append retry deduplication" },
286
+ { name: "prism_session_append_idempotency_unique", table: "prism_session_append_idempotency", columns: ["session_id", "expected_parent_id", "idempotency_key"], purpose: "append retry deduplication (primary key enforces uniqueness)" },
285
287
  { name: "prism_runs_session_started_idx", table: "prism_runs", columns: ["session_id", "started_at", "id"], purpose: "run history pagination" },
286
288
  { name: "prism_runs_branch_started_idx", table: "prism_runs", columns: ["branch_id", "started_at", "id"], purpose: "branch-scoped runs" },
287
289
  { name: "prism_runs_tenant_idempotency_unique", table: "prism_runs", columns: ["tenant_id", "idempotency_key"], unique: true, purpose: "run-level idempotency deduplication per tenant" },
@@ -296,19 +298,40 @@ export function createPersistenceSchemaModel() {
296
298
  { name: "prism_run_feedback_run_created_idx", table: "prism_run_feedback", columns: ["run_id", "created_at", "id"], purpose: "run feedback lookup" },
297
299
  { name: "prism_run_feedback_trace_created_idx", table: "prism_run_feedback", columns: ["trace_id", "created_at", "id"], purpose: "trace feedback lookup" },
298
300
  { name: "prism_agent_definitions_name_version_idx", table: "prism_agent_definitions", columns: ["name", "version"], purpose: "definition lookup" },
299
- { name: "prism_migrations_name_version_idx", table: "prism_migrations", columns: ["name", "version"], unique: true, purpose: "applied-migration uniqueness" },
301
+ { name: "prism_migrations_name_version_idx", table: "prism_migrations", columns: ["name", "version"], purpose: "applied-migration lookup (table constraint enforces uniqueness)" },
300
302
  ],
301
303
  };
302
304
  }
305
+ function migrationStep(version, name, description) {
306
+ const model = createPersistenceSchemaModel();
307
+ const content = version === 1
308
+ ? {
309
+ tables: model.tables
310
+ .filter((table) => table.name !== "prism_run_feedback")
311
+ .map((table) => table.name === "prism_usage"
312
+ ? { ...table, columns: table.columns.filter((column) => !["scope", "turn", "attempt"].includes(column.name)) }
313
+ : table),
314
+ indexes: model.indexes.filter((index) => !index.name.startsWith("prism_usage_session_scope_") && !index.name.startsWith("prism_run_feedback_")),
315
+ }
316
+ : version === 2
317
+ ? { table: "prism_usage", columns: ["scope", "turn", "attempt"], indexes: ["prism_usage_session_scope_recorded_idx"] }
318
+ : { tables: ["prism_run_feedback"], indexes: model.indexes.filter((index) => index.name.startsWith("prism_run_feedback_")).map((index) => index.name) };
319
+ return {
320
+ version,
321
+ name,
322
+ description,
323
+ checksum: createHash("sha256").update(JSON.stringify({ version, name, content })).digest("hex"),
324
+ };
325
+ }
303
326
  /** Canonical migration contract for production adapters. */
304
327
  export function createPersistenceMigrationContract() {
305
328
  return {
306
329
  targetSchemaVersion: PERSISTENCE_SCHEMA_VERSION,
307
330
  appliedMigrationsTable: "prism_migrations",
308
331
  steps: [
309
- { version: 1, name: "001_init", description: "Create core session, branch, entry, idempotency, run, ledger, and migration tables." },
310
- { version: 2, name: "002_usage_scope", description: "Distinguish provider-turn usage from aggregate run totals." },
311
- { version: 3, name: "003_run_feedback", description: "Add immutable ownership-scoped run/trace feedback and evaluation links." },
332
+ migrationStep(1, "001_init", "Create core session, branch, entry, idempotency, run, ledger, and migration tables."),
333
+ migrationStep(2, "002_usage_scope", "Distinguish provider-turn usage from aggregate run totals."),
334
+ migrationStep(3, "003_run_feedback", "Add immutable ownership-scoped run/trace feedback and evaluation links."),
312
335
  ],
313
336
  lockGuidance: "Acquire a dialect-specific migration lock before applying steps (PostgreSQL advisory lock; SQLite exclusive transaction). Only one process should migrate at a time.",
314
337
  leastPrivilegeGuidance: "Run migrations with a DDL-capable role; use a separate least-privilege runtime role limited to INSERT/SELECT/UPDATE on adapter tables. Never grant migration credentials to the agent runtime.",
@@ -387,6 +410,8 @@ export function assertPersistenceMigrationContract(contract) {
387
410
  throw new Error(`Migration steps must be strictly increasing; ${step.name} is out of order`);
388
411
  if (names.has(step.name))
389
412
  throw new Error(`Duplicate migration step name: ${step.name}`);
413
+ if (!/^[a-f0-9]{64}$/.test(step.checksum))
414
+ throw new Error(`Migration step ${step.name} must have a SHA-256 checksum`);
390
415
  names.add(step.name);
391
416
  previous = step.version;
392
417
  }
@@ -394,6 +419,101 @@ export function assertPersistenceMigrationContract(contract) {
394
419
  throw new Error(`Last migration step version ${previous} must equal targetSchemaVersion ${contract.targetSchemaVersion}`);
395
420
  }
396
421
  }
422
+ function schemaKey(columns) {
423
+ return columns.join("\u0000");
424
+ }
425
+ function normalizedDefault(value) {
426
+ return value?.trim().toLowerCase().replace(/::[a-z_ ]+$/, "");
427
+ }
428
+ function compatibleColumnType(dialect, actual, expected) {
429
+ const type = actual.trim().toUpperCase();
430
+ if (dialect === "sqlite") {
431
+ if (expected === "integer" || expected === "boolean")
432
+ return type === "INTEGER";
433
+ if (expected === "number")
434
+ return type === "REAL";
435
+ return type === "TEXT";
436
+ }
437
+ if (expected === "integer")
438
+ return type === "INTEGER";
439
+ if (expected === "boolean")
440
+ return type === "BOOLEAN";
441
+ if (expected === "number")
442
+ return type === "DOUBLE PRECISION";
443
+ return type === "TEXT";
444
+ }
445
+ /** Compare bounded dialect catalog output against every required schema-v3 detail. */
446
+ export function assertPersistenceSchemaShape(shape, dialect, model = createPersistenceSchemaModel()) {
447
+ assertPersistenceSchemaModel(model);
448
+ const actualTables = new Map(shape.tables.map((table) => [table.name, table]));
449
+ for (const expected of model.tables) {
450
+ const actual = actualTables.get(expected.name);
451
+ if (!actual)
452
+ throw new Error(`Persistence schema missing table ${expected.name}`);
453
+ if (actual.columns.length !== expected.columns.length)
454
+ throw new Error(`Persistence schema table ${expected.name} has unexpected columns`);
455
+ const actualColumns = new Map(actual.columns.map((column) => [column.name, column]));
456
+ for (const column of expected.columns) {
457
+ const found = actualColumns.get(column.name);
458
+ if (!found)
459
+ throw new Error(`Persistence schema table ${expected.name} missing column ${column.name}`);
460
+ if (!compatibleColumnType(dialect, found.type, column.type)) {
461
+ throw new Error(`Persistence schema column ${expected.name}.${column.name} has incompatible type`);
462
+ }
463
+ if (found.nullable !== (column.nullable === true)) {
464
+ throw new Error(`Persistence schema column ${expected.name}.${column.name} has incompatible nullability`);
465
+ }
466
+ if (normalizedDefault(found.defaultValue) !== normalizedDefault(column.defaultValue)) {
467
+ throw new Error(`Persistence schema column ${expected.name}.${column.name} has incompatible default`);
468
+ }
469
+ }
470
+ if (schemaKey(actual.primaryKey) !== schemaKey(expected.primaryKey)) {
471
+ throw new Error(`Persistence schema table ${expected.name} has incompatible primary key`);
472
+ }
473
+ const unique = new Set(actual.uniqueKeys.map(schemaKey));
474
+ for (const key of expected.uniqueKeys ?? []) {
475
+ if (!unique.has(schemaKey(key)) && schemaKey(actual.primaryKey) !== schemaKey(key)) {
476
+ throw new Error(`Persistence schema table ${expected.name} missing unique key (${key.join(", ")})`);
477
+ }
478
+ }
479
+ const foreign = new Set(actual.foreignKeys.map((key) => `${schemaKey(key.columns)}>${key.referencesTable}:${schemaKey(key.referencesColumns)}`));
480
+ for (const key of expected.foreignKeys ?? []) {
481
+ if (!foreign.has(`${schemaKey(key.columns)}>${key.referencesTable}:${schemaKey(key.referencesColumns)}`)) {
482
+ throw new Error(`Persistence schema table ${expected.name} missing foreign key (${key.columns.join(", ")})`);
483
+ }
484
+ }
485
+ }
486
+ const actualIndexes = new Map(shape.indexes.map((index) => [index.name, index]));
487
+ for (const expected of model.indexes) {
488
+ const actual = actualIndexes.get(expected.name);
489
+ if (!actual)
490
+ throw new Error(`Persistence schema missing required index ${expected.name}`);
491
+ if (actual.table !== expected.table || schemaKey(actual.columns) !== schemaKey(expected.columns) || actual.unique !== (expected.unique === true)) {
492
+ throw new Error(`Persistence schema index ${expected.name} has incompatible definition`);
493
+ }
494
+ }
495
+ }
496
+ /** Reject altered migration history before any new DDL or runtime write. */
497
+ export function assertAppliedPersistenceMigrations(contract, applied) {
498
+ assertPersistenceMigrationContract(contract);
499
+ if (applied.length > contract.steps.length)
500
+ throw new Error("Migration history has unknown rows");
501
+ const legacyChecksums = applied.some((row) => row.checksum === null);
502
+ if (legacyChecksums && (applied.length !== contract.steps.length || !applied.every((row) => row.checksum === null))) {
503
+ throw new Error("Migration history has incomplete legacy checksums");
504
+ }
505
+ for (let index = 0; index < applied.length; index++) {
506
+ const row = applied[index];
507
+ const expected = contract.steps[index];
508
+ if (row.name !== expected.name || row.version !== String(expected.version)) {
509
+ throw new Error(`Migration history row ${index} does not match ${expected.name}`);
510
+ }
511
+ if (!legacyChecksums && row.checksum !== expected.checksum) {
512
+ throw new Error(`Migration history checksum mismatch for ${expected.name}`);
513
+ }
514
+ }
515
+ return { legacyChecksums };
516
+ }
397
517
  /**
398
518
  * Assert a dialect-local adapter exposes the canonical table and index names.
399
519
  * Adapters pass the table/index names their migration runner created.
@@ -423,23 +543,17 @@ export function assertParameterizedQuery(sql, boundValues) {
423
543
  }
424
544
  /** Simulate migration up + reopen: applied steps must match the contract in order. */
425
545
  export function assertMigrationUpAndReopen(contract, appliedAfterUp, appliedAfterReopen) {
426
- assertPersistenceMigrationContract(contract);
427
- if (appliedAfterUp.length !== contract.steps.length) {
428
- throw new Error("Migration up did not apply every contract step");
429
- }
430
- for (let i = 0; i < contract.steps.length; i++) {
431
- const step = contract.steps[i];
432
- const row = appliedAfterUp[i];
433
- if (row.name !== step.name || row.version !== String(step.version)) {
434
- throw new Error(`Applied migration row ${i} does not match contract step ${step.name}`);
435
- }
546
+ const first = assertAppliedPersistenceMigrations(contract, appliedAfterUp);
547
+ if (appliedAfterUp.length !== contract.steps.length || first.legacyChecksums) {
548
+ throw new Error("Migration up did not apply every checksummed contract step");
436
549
  }
437
- if (appliedAfterReopen.length !== appliedAfterUp.length) {
438
- throw new Error("Reopened adapter must not re-apply migrations; applied row count changed");
550
+ const second = assertAppliedPersistenceMigrations(contract, appliedAfterReopen);
551
+ if (second.legacyChecksums || appliedAfterReopen.length !== appliedAfterUp.length) {
552
+ throw new Error("Reopened adapter migration history diverged");
439
553
  }
440
- for (let i = 0; i < appliedAfterUp.length; i++) {
441
- if (appliedAfterReopen[i].name !== appliedAfterUp[i].name) {
442
- throw new Error("Reopened adapter migration history diverged");
554
+ for (let index = 0; index < appliedAfterUp.length; index++) {
555
+ if (appliedAfterReopen[index].checksum !== appliedAfterUp[index].checksum) {
556
+ throw new Error("Reopened adapter migration checksums diverged");
443
557
  }
444
558
  }
445
559
  }
@@ -0,0 +1,42 @@
1
+ import type { JsonObject, ModelConfig, ProviderRequestOptions } from "./contracts.js";
2
+ /**
3
+ * Portable thinking / reasoning effort levels shared across first-party providers.
4
+ * Model-dependent legality (which values a given model accepts) stays provider-owned.
5
+ */
6
+ export declare const THINKING_LEVELS: readonly ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
7
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
8
+ /**
9
+ * Compat mapping families used by ≥2 packages, or explicit no-op for host-owned adapters.
10
+ * Provider packages keep unique escape hatches (budgets, keep/all, tool_stream) local.
11
+ */
12
+ export type ThinkingCompatFamily = "openai_reasoning" | "reasoning_effort" | "thinking_type" | "noop";
13
+ export declare function isThinkingLevel(value: unknown): value is ThinkingLevel;
14
+ /**
15
+ * Normalize a host thinkingLevel string. Known levels are lowercased; other non-empty
16
+ * strings pass through as opaque effort values for forward-compatible provider fields.
17
+ */
18
+ export declare function normalizeThinkingLevel(level: string): ThinkingLevel | string | undefined;
19
+ /**
20
+ * Build the `ProviderRequestOptions.compat` patch for a shared thinking level.
21
+ * Does not invent a second options tree — providers keep reading official fields from `compat`.
22
+ */
23
+ export declare function thinkingCompatFor(family: ThinkingCompatFamily, level: ThinkingLevel | string): JsonObject;
24
+ /**
25
+ * Merge a shared thinking level into `providerOptions.compat` for the given family.
26
+ * Per-turn patches win over prior compat via {@link mergeProviderRequestOptions}.
27
+ */
28
+ export declare function applyThinkingLevel(options: ProviderRequestOptions | undefined, level: ThinkingLevel | string, family?: ThinkingCompatFamily): ProviderRequestOptions;
29
+ /**
30
+ * Best-effort family inference from model metadata without a second options tree.
31
+ * Prefer an explicit family in hosts/use-case workers when the provider is known.
32
+ *
33
+ * Heuristics (ordered):
34
+ * 1. Existing `compat.thinking` object → `thinking_type`
35
+ * 2. Existing `compat.reasoning` → `openai_reasoning`
36
+ * 3. Existing `compat.reasoning_effort` → `reasoning_effort`
37
+ * 4. Provider id starting with `openai` → `openai_reasoning`
38
+ * 5. Provider id `neuralwatt` → `reasoning_effort`
39
+ * 6. `capabilities.reasoning` → `reasoning_effort` (portable string field)
40
+ * 7. Else `noop`
41
+ */
42
+ export declare function thinkingFamilyForModel(model: Pick<ModelConfig, "provider" | "compat" | "capabilities">): ThinkingCompatFamily;