@celestea/core 2.7.1

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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -0
  3. package/contracts/data-files/checkpoint.schema.json +111 -0
  4. package/contracts/data-files/cli-main-jsonl-precompact.schema.json +27 -0
  5. package/contracts/data-files/cli-main-jsonl.schema.json +22 -0
  6. package/contracts/data-files/fallbacks.schema.json +71 -0
  7. package/contracts/data-files/index.json +124 -0
  8. package/contracts/data-files/pricing.schema.json +65 -0
  9. package/contracts/data-files/prompts.schema.json +130 -0
  10. package/contracts/data-files/providers.schema.json +177 -0
  11. package/contracts/data-files/registry-tsv.schema.json +74 -0
  12. package/contracts/data-files/session.schema.json +51 -0
  13. package/contracts/data-files/usage-ledger.schema.json +112 -0
  14. package/contracts/data-files/workspaces.schema.json +63 -0
  15. package/contracts/endpoints.json +4390 -0
  16. package/contracts/probe-evidence.json +219 -0
  17. package/contracts/route-table.snapshot.json +377 -0
  18. package/contracts/scope-hash-vectors.json +273 -0
  19. package/contracts/session-event.schema.json +441 -0
  20. package/contracts/sse-events.json +202 -0
  21. package/contracts/tools.json +730 -0
  22. package/dist/agent.d.ts +65 -0
  23. package/dist/agent.js +36 -0
  24. package/dist/celestea-home.d.ts +63 -0
  25. package/dist/celestea-home.js +96 -0
  26. package/dist/celestea-sources.d.ts +53 -0
  27. package/dist/celestea-sources.js +61 -0
  28. package/dist/context.d.ts +33 -0
  29. package/dist/context.js +55 -0
  30. package/dist/contracts/index.d.ts +234 -0
  31. package/dist/contracts/index.js +159 -0
  32. package/dist/errors.d.ts +16 -0
  33. package/dist/errors.js +22 -0
  34. package/dist/event-bus.d.ts +60 -0
  35. package/dist/event-bus.js +100 -0
  36. package/dist/index.d.ts +66 -0
  37. package/dist/index.js +66 -0
  38. package/dist/injection.d.ts +61 -0
  39. package/dist/injection.js +27 -0
  40. package/dist/json.d.ts +34 -0
  41. package/dist/json.js +127 -0
  42. package/dist/llm.d.ts +34 -0
  43. package/dist/llm.js +41 -0
  44. package/dist/memory.d.ts +72 -0
  45. package/dist/memory.js +123 -0
  46. package/dist/message.d.ts +189 -0
  47. package/dist/message.js +252 -0
  48. package/dist/plugin.d.ts +38 -0
  49. package/dist/plugin.js +49 -0
  50. package/dist/projection.d.ts +67 -0
  51. package/dist/projection.js +168 -0
  52. package/dist/question.d.ts +154 -0
  53. package/dist/question.js +82 -0
  54. package/dist/redact.d.ts +40 -0
  55. package/dist/redact.js +185 -0
  56. package/dist/repo.d.ts +14 -0
  57. package/dist/repo.js +87 -0
  58. package/dist/sandbox.d.ts +182 -0
  59. package/dist/sandbox.js +78 -0
  60. package/dist/session-event.d.ts +57 -0
  61. package/dist/session-event.js +425 -0
  62. package/dist/session-log.d.ts +71 -0
  63. package/dist/session-log.js +66 -0
  64. package/dist/skill-catalog.d.ts +29 -0
  65. package/dist/skill-catalog.js +52 -0
  66. package/dist/skills.d.ts +116 -0
  67. package/dist/skills.js +273 -0
  68. package/dist/sse-bus.d.ts +40 -0
  69. package/dist/sse-bus.js +105 -0
  70. package/dist/stream.d.ts +115 -0
  71. package/dist/stream.js +52 -0
  72. package/dist/tool-surface.d.ts +45 -0
  73. package/dist/tool-surface.js +98 -0
  74. package/dist/tool.d.ts +77 -0
  75. package/dist/tool.js +15 -0
  76. package/dist/turn-id.d.ts +37 -0
  77. package/dist/turn-id.js +76 -0
  78. package/dist/types.d.ts +396 -0
  79. package/dist/types.js +58 -0
  80. package/package.json +27 -0
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The engine's `derive_messages` projection — a 1:1 port of
3
+ * `crates/session/src/log.rs:73-204`.
4
+ *
5
+ * A2 (W746): this algorithm lives in CORE, not in the L1 implementation
6
+ * package. `SessionLog.deriveMessages()` is a seam method, so the projection
7
+ * has to be part of the seam: an implementation that returns `[]` (or that
8
+ * re-implements the rules slightly differently) is a silent history loss, not
9
+ * an "alternative implementation". `@celestea/session` re-exports these
10
+ * functions; backends built with [projectingSessionLog] get them for free.
11
+ *
12
+ * Rules (all of them are contract, each pinned by a unit test):
13
+ * - `TurnStart` / `TurnEnd` are structural markers, never projected;
14
+ * - `ThinkingDelta` is replay-only decoration (W252), never projected;
15
+ * - `ToolCall` rows are ACCUMULATED, not projected: consecutive calls merge
16
+ * into ONE assistant message carrying one `tool_call` content per call
17
+ * (LLM protocols require all tool calls of a turn in a single message);
18
+ * - the accumulator is flushed before any other event, and after the last
19
+ * event;
20
+ * - rows with `parent_id` (W255 run_code sub-calls) stay in the log for
21
+ * audit/replay but never reach the model: both the call and its result are
22
+ * skipped — the outer run_code round trip is all the model sees;
23
+ * - `ToolResult` projects to a tool message whose text is `"Error: {err}"`
24
+ * when the error is a non-empty string, otherwise the serde_json text of
25
+ * the value (`"null"` when absent).
26
+ *
27
+ * `balance_tool_calls` (W267) then makes the projection protocol-valid: every
28
+ * assistant tool_calls message must be followed by one tool message per call.
29
+ */
30
+ import { assistantText, attachmentRefsOfValue, toolCallIds, toolResultMessage, toolResultWithImages, userMessage, userMessageWithImages, } from "./message.js";
31
+ import { toolSurfaceText } from "./tool-surface.js";
32
+ /** W267 synthetic result text — byte-for-byte the engine's string (b046564). */
33
+ export const CANCELLED_TOOL_CALL_TEXT = "Error: tool call was cancelled before execution (no result recorded)";
34
+ /** The model-visible history of a session log (the engine's `derive_messages_from`). */
35
+ export function deriveMessagesFrom(events) {
36
+ const messages = [];
37
+ const pending = [];
38
+ for (const event of events) {
39
+ if (event.type === "tool_call") {
40
+ // Sub-call rows (parent_id present) are audit-only.
41
+ if (event.parent_id === undefined) {
42
+ pending.push({ id: event.id, name: event.name, args: event.args });
43
+ }
44
+ continue;
45
+ }
46
+ flushToolCalls(messages, pending);
47
+ const message = projectEvent(event);
48
+ if (message !== null)
49
+ messages.push(message);
50
+ }
51
+ // Trailing tool calls (no following event) still need flushing.
52
+ flushToolCalls(messages, pending);
53
+ balanceToolCalls(messages);
54
+ return messages;
55
+ }
56
+ /**
57
+ * Flush accumulated tool calls as ONE assistant message (`flush_tool_calls`).
58
+ * It drains the accumulator, mirroring the engine's `std::mem::take`.
59
+ */
60
+ export function flushToolCalls(messages, pending) {
61
+ if (pending.length === 0)
62
+ return;
63
+ const calls = pending.splice(0, pending.length);
64
+ messages.push({
65
+ role: "assistant",
66
+ content: calls.map((call) => ({ type: "tool_call", content: call })),
67
+ tool_call_id: null,
68
+ });
69
+ }
70
+ /**
71
+ * Project one non-tool-call event (`project`). Returns null for the events the
72
+ * model never sees.
73
+ */
74
+ export function projectEvent(event) {
75
+ switch (event.type) {
76
+ case "user_message":
77
+ // W804 §4.2A: attachments become image content blocks; with none the
78
+ // construct is byte-identical to the pre-W804 userMessage.
79
+ return event.attachments !== undefined && event.attachments.length > 0
80
+ ? userMessageWithImages(event.text, event.attachments)
81
+ : userMessage(event.text);
82
+ case "assistant_message":
83
+ return assistantText(event.text);
84
+ case "tool_result":
85
+ if (event.parent_id !== undefined)
86
+ return null; // W255 sub-call result
87
+ // W804 §6.4: the tool value's attachments (read_image) become image blocks
88
+ // on the SAME tool message; the JSON text (with the metadata) stays first.
89
+ return toolResultOf(event);
90
+ case "turn_start":
91
+ case "turn_end":
92
+ case "thinking_delta":
93
+ return null;
94
+ // W783 §7: the host's user-question audit rows describe a PAUSED turn. The
95
+ // model sees the outcome as the ordinary `tool_result` of
96
+ // `ask_user_question`, so projecting these would invent history the model
97
+ // never received (and would break the tool_call/tool_result pairing).
98
+ case "user_question":
99
+ case "user_answer":
100
+ return null;
101
+ case "tool_call":
102
+ // Legacy: unreachable!("ToolCall must be accumulated by derive_messages…")
103
+ throw new Error("ToolCall must be accumulated by derive_messages, not projected");
104
+ }
105
+ }
106
+ /** W804: one projected tool result — JSON text first, image references after. */
107
+ function toolResultOf(event) {
108
+ // W855 (B6): the log stores the ORIGINAL value; `surface` turns it into the
109
+ // model-visible face (bounded preview + locator, or a truncation note).
110
+ const text = toolResultText(event.error, event.value, event.surface);
111
+ const images = attachmentRefsOfValue(event.value);
112
+ return images.length > 0 ? toolResultWithImages(event.id, text, images) : toolResultMessage(event.id, text);
113
+ }
114
+ /**
115
+ * The text of a projected ToolResult: error first, else the value with its
116
+ * `surface` applied (absent surface = the value as serde JSON, as before).
117
+ */
118
+ export function toolResultText(error, value, surface) {
119
+ if (typeof error === "string" && error.length > 0)
120
+ return `Error: ${error}`;
121
+ return toolSurfaceText(value === undefined ? null : value, surface);
122
+ }
123
+ /**
124
+ * W267 protocol balance: every assistant `tool_calls` message is followed by
125
+ * one `tool` message per call id. A cancelled/interrupted turn can stop between
126
+ * ToolCall and ToolResult, leaving a dangling call that makes the whole history
127
+ * invalid for OpenAI-compatible upstreams ("insufficient tool messages
128
+ * following tool_calls message"). A synthetic cancelled result is inserted for
129
+ * each unanswered call; the log itself is untouched (audit keeps the truth).
130
+ *
131
+ * UPSTREAM PARITY NOTE: the cursor advance `i = j + inserted + 1` is ported
132
+ * unchanged from `crates/session/src/log.rs:149`, including its quirk — when a
133
+ * call message is fully answered, the message sitting right after its results
134
+ * is skipped by the cursor, so an unbalanced trailing call message in exactly
135
+ * that position is NOT balanced (see `packages/session/src/log/derive.test.ts`:
136
+ * `mirrors the upstream cursor quirk`). Parity with the engine is the contract
137
+ * here; the quirk is reported upstream rather than diverged from silently.
138
+ */
139
+ export function balanceToolCalls(messages) {
140
+ let i = 0;
141
+ while (i < messages.length) {
142
+ const current = messages[i];
143
+ if (current === undefined)
144
+ break;
145
+ const callIds = toolCallIds(current);
146
+ if (callIds.length === 0) {
147
+ i += 1;
148
+ continue;
149
+ }
150
+ // Results must be the contiguous tool messages right after the call.
151
+ const answered = [];
152
+ let j = i + 1;
153
+ while (j < messages.length && messages[j]?.role === "tool") {
154
+ const id = messages[j]?.tool_call_id;
155
+ if (id !== null && id !== undefined)
156
+ answered.push(id);
157
+ j += 1;
158
+ }
159
+ let inserted = 0;
160
+ for (const id of callIds) {
161
+ if (answered.includes(id))
162
+ continue;
163
+ messages.splice(j + inserted, 0, toolResultMessage(id, CANCELLED_TOOL_CALL_TEXT));
164
+ inserted += 1;
165
+ }
166
+ i = j + inserted + 1;
167
+ }
168
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The user-question seam — L1 types only (design §4.1 A1).
3
+ *
4
+ * Three layers, exactly like the official DSH implementation:
5
+ * 1. **here** — the service interface, the service token, the error taxonomy
6
+ * and the wire-shaped request/answer types. `core` stays L1: no
7
+ * implementation, no transport, no storage.
8
+ * 2. the HOST (`apps/studio`) implements the service and owns the pending
9
+ * table;
10
+ * 3. `packages/tools` consumes it through the `ask_user_question` tool, which
11
+ * receives the service by CONSTRUCTION (the `runShellTool({sandbox})`
12
+ * pattern) because `packages/tools` never sees a Context.
13
+ *
14
+ * Why the answer cannot travel as a message: while the tool is awaited the
15
+ * session's turn slot is occupied, so `POST /api/turn` becomes steering, and
16
+ * steering is only drained at step boundaries — a boundary the parked tool call
17
+ * never reaches (§2.2). The answer therefore resolves the pending promise
18
+ * through the waterfall's RETURN VALUE (§4.2), never through message injection.
19
+ *
20
+ * `AskUserQuestionOutcome.timed_out` is this repo's increment: DSH waits without
21
+ * any TTL, while §6 gives the wait a maximum and returns an EMPTY answer set
22
+ * rather than deciding for the model.
23
+ */
24
+ /** One selectable answer offered to the user. */
25
+ export interface AskUserQuestionOption {
26
+ /** User-facing label. */
27
+ label: string;
28
+ /** Optional extra context rendered by capable UIs. */
29
+ description?: string;
30
+ }
31
+ /**
32
+ * A caller-declared presentation intent. It changes presentation only, never the
33
+ * protocol: the answer encoding is identical with or without one, so a UI that
34
+ * does not know the tag renders the generic option list (§3.3).
35
+ */
36
+ export interface AskUserQuestionIntent {
37
+ /** A plan submitted for review: `detail` carries the plan, `approve` the verdict. */
38
+ kind: "plan-review";
39
+ /**
40
+ * The option label that approves the plan; every other option declines it.
41
+ * Named rather than positional so no UI infers the verdict from option order.
42
+ */
43
+ approve: string;
44
+ }
45
+ /** One question in a user-questions request. */
46
+ export interface AskUserQuestionItem {
47
+ /** Stable caller-provided question id, echoed in the answer. */
48
+ id: string;
49
+ /** The question to display. */
50
+ question: string;
51
+ /** Optional supporting detail, kept OUT of option labels (§3.2 rule 3). */
52
+ detail?: string;
53
+ /** Optional short heading. */
54
+ header?: string;
55
+ /** Optional choices; absent = free-text only. */
56
+ options?: AskUserQuestionOption[];
57
+ /** Whether more than one option may be selected. Defaults to single-select. */
58
+ multiSelect?: boolean;
59
+ /** Optional presentation intent. */
60
+ intent?: AskUserQuestionIntent;
61
+ }
62
+ /** Answer to one question. `selected` holds LABELS, never indices (§3.2 rule 1). */
63
+ export interface AskUserQuestionAnswerItem {
64
+ id: string;
65
+ selected: string[];
66
+ /** Optional free-text answer, co-existing with `selected`. */
67
+ custom?: string;
68
+ }
69
+ /** The human's answer, or the empty set a timeout returns (§6.3). */
70
+ export interface AskUserQuestionOutcome {
71
+ answers: AskUserQuestionAnswerItem[];
72
+ /** `true` = the wait expired; the system decided nothing on the model's behalf. */
73
+ timed_out: boolean;
74
+ }
75
+ /**
76
+ * One request to the answerer waterfall.
77
+ *
78
+ * There is no `sessionId` here on purpose: the service is composed PER SESSION
79
+ * (the host builds one instance for each generation), so the asking session is
80
+ * already the instance's own identity and can never be spoofed by the caller.
81
+ */
82
+ export interface AskUserQuestionRequest {
83
+ questions: AskUserQuestionItem[];
84
+ /**
85
+ * Optional maximum wait in ms; clamped by [askTimeoutMs] (absent/non-positive
86
+ * = [DEFAULT_ASK_TIMEOUT_MS]). Never means "wait forever".
87
+ */
88
+ timeoutMs?: number;
89
+ /** Lifetime of the wait: aborting it rejects with `ASK_ABORTED`. */
90
+ signal?: AbortSignal;
91
+ }
92
+ /** The service seam the `ask_user_question` tool consumes. */
93
+ export interface UserQuestionService {
94
+ /**
95
+ * Ask the answerer waterfall and wait for the human's answer.
96
+ *
97
+ * @throws {UserQuestionError} `EMPTY_QUESTIONS` / `BAD_INTENT` on a malformed
98
+ * request, `DELEGATED_CALLER` when a sub-agent asks, `CALLER_NOT_LIVE` when
99
+ * the asking generation is gone, `ASK_ABORTED` when the signal aborted,
100
+ * `NO_PROVIDER` when no answerer claimed the request.
101
+ */
102
+ ask(request: AskUserQuestionRequest): Promise<AskUserQuestionOutcome>;
103
+ }
104
+ /** Well-known token for the user-question service (host-provided). */
105
+ export declare const USER_QUESTION_SERVICE = "celestea.core.UserQuestionService";
106
+ /**
107
+ * The waterfall event a request travels on. Answerers receive
108
+ * `(request, next)`: return an outcome to CLAIM the request, call `next()` to
109
+ * delegate to the next layer (§5.2).
110
+ */
111
+ export declare const USER_QUESTION_REQUEST_EVENT = "user-questions/request";
112
+ /** The stable error taxonomy of §5.3 (the DSH code set). */
113
+ export declare const USER_QUESTION_ERROR_CODES: readonly ["ASK_ABORTED", "ASK_CANCELLED", "BAD_INTENT", "CALLER_NOT_LIVE", "DELEGATED_CALLER", "EMPTY_QUESTIONS", "NO_PROVIDER"];
114
+ export type UserQuestionErrorCode = (typeof USER_QUESTION_ERROR_CODES)[number];
115
+ /** A user-question failure carrying its stable code. */
116
+ export declare class UserQuestionError extends Error {
117
+ readonly code: UserQuestionErrorCode;
118
+ constructor(message: string, code: UserQuestionErrorCode, options?: ErrorOptions);
119
+ }
120
+ /**
121
+ * Who is asking. An OWNED child agent has no human answerer and would block
122
+ * forever, so it is refused instead of being allowed to wait (§5.3).
123
+ *
124
+ * This repo has no agent-object registry (DSH resolves `ctx.get("agents")`), so
125
+ * ownership is expressed as the async scope the worker driver enters around
126
+ * every turn it drives: a root turn runs outside it, a driven worker turn inside
127
+ * it. That is the same boundary — runtime ownership, not durable session
128
+ * lineage.
129
+ */
130
+ export type AskUserCaller = {
131
+ kind: "root";
132
+ } | {
133
+ kind: "delegated";
134
+ owner: string | null;
135
+ };
136
+ /** The implicit caller of any turn that did not enter [delegatedCallerScope]. */
137
+ export declare const ROOT_ASK_CALLER: AskUserCaller;
138
+ /** The caller of the turn currently running on this async context. */
139
+ export declare function currentAskUserCaller(): AskUserCaller;
140
+ /** Run one driven (owned) agent turn: everything inside is a delegated caller. */
141
+ export declare function delegatedCallerScope<T>(owner: string | null, fn: () => Promise<T>): Promise<T>;
142
+ /** Default maximum wait for an answer (ms). */
143
+ export declare const DEFAULT_ASK_TIMEOUT_MS = 300000;
144
+ /** Hard ceiling for the tool's `timeout_ms` override (ms). */
145
+ export declare const MAX_ASK_TIMEOUT_MS = 3600000;
146
+ /**
147
+ * Clamp a requested wait into `[1, MAX_ASK_TIMEOUT_MS]`. An absent, non-finite or
148
+ * non-positive request means the default — never "wait forever".
149
+ *
150
+ * W834 F06 (R3 batch A): the lower bound is applied AFTER the floor. Flooring
151
+ * first let a request in (0, 1) — e.g. `timeout_ms: 0.5` — become 0, and
152
+ * `setTimeout(0)` expired the question before a human could answer it.
153
+ */
154
+ export declare function askTimeoutMs(requested?: number): number;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The user-question seam — L1 types only (design §4.1 A1).
3
+ *
4
+ * Three layers, exactly like the official DSH implementation:
5
+ * 1. **here** — the service interface, the service token, the error taxonomy
6
+ * and the wire-shaped request/answer types. `core` stays L1: no
7
+ * implementation, no transport, no storage.
8
+ * 2. the HOST (`apps/studio`) implements the service and owns the pending
9
+ * table;
10
+ * 3. `packages/tools` consumes it through the `ask_user_question` tool, which
11
+ * receives the service by CONSTRUCTION (the `runShellTool({sandbox})`
12
+ * pattern) because `packages/tools` never sees a Context.
13
+ *
14
+ * Why the answer cannot travel as a message: while the tool is awaited the
15
+ * session's turn slot is occupied, so `POST /api/turn` becomes steering, and
16
+ * steering is only drained at step boundaries — a boundary the parked tool call
17
+ * never reaches (§2.2). The answer therefore resolves the pending promise
18
+ * through the waterfall's RETURN VALUE (§4.2), never through message injection.
19
+ *
20
+ * `AskUserQuestionOutcome.timed_out` is this repo's increment: DSH waits without
21
+ * any TTL, while §6 gives the wait a maximum and returns an EMPTY answer set
22
+ * rather than deciding for the model.
23
+ */
24
+ import { AsyncLocalStorage } from "node:async_hooks";
25
+ /** Well-known token for the user-question service (host-provided). */
26
+ export const USER_QUESTION_SERVICE = "celestea.core.UserQuestionService";
27
+ /**
28
+ * The waterfall event a request travels on. Answerers receive
29
+ * `(request, next)`: return an outcome to CLAIM the request, call `next()` to
30
+ * delegate to the next layer (§5.2).
31
+ */
32
+ export const USER_QUESTION_REQUEST_EVENT = "user-questions/request";
33
+ /** The stable error taxonomy of §5.3 (the DSH code set). */
34
+ export const USER_QUESTION_ERROR_CODES = [
35
+ "ASK_ABORTED",
36
+ "ASK_CANCELLED",
37
+ "BAD_INTENT",
38
+ "CALLER_NOT_LIVE",
39
+ "DELEGATED_CALLER",
40
+ "EMPTY_QUESTIONS",
41
+ "NO_PROVIDER",
42
+ ];
43
+ /** A user-question failure carrying its stable code. */
44
+ export class UserQuestionError extends Error {
45
+ code;
46
+ constructor(message, code, options) {
47
+ super(message, options);
48
+ this.name = "UserQuestionError";
49
+ this.code = code;
50
+ }
51
+ }
52
+ /** The implicit caller of any turn that did not enter [delegatedCallerScope]. */
53
+ export const ROOT_ASK_CALLER = { kind: "root" };
54
+ const callerScope = new AsyncLocalStorage();
55
+ /** The caller of the turn currently running on this async context. */
56
+ export function currentAskUserCaller() {
57
+ return callerScope.getStore() ?? ROOT_ASK_CALLER;
58
+ }
59
+ /** Run one driven (owned) agent turn: everything inside is a delegated caller. */
60
+ export function delegatedCallerScope(owner, fn) {
61
+ return callerScope.run({ kind: "delegated", owner }, fn);
62
+ }
63
+ // ---------------------------------------------------------------------------
64
+ // The wait budget (§6.4)
65
+ // ---------------------------------------------------------------------------
66
+ /** Default maximum wait for an answer (ms). */
67
+ export const DEFAULT_ASK_TIMEOUT_MS = 300000;
68
+ /** Hard ceiling for the tool's `timeout_ms` override (ms). */
69
+ export const MAX_ASK_TIMEOUT_MS = 3600000;
70
+ /**
71
+ * Clamp a requested wait into `[1, MAX_ASK_TIMEOUT_MS]`. An absent, non-finite or
72
+ * non-positive request means the default — never "wait forever".
73
+ *
74
+ * W834 F06 (R3 batch A): the lower bound is applied AFTER the floor. Flooring
75
+ * first let a request in (0, 1) — e.g. `timeout_ms: 0.5` — become 0, and
76
+ * `setTimeout(0)` expired the question before a human could answer it.
77
+ */
78
+ export function askTimeoutMs(requested) {
79
+ if (requested === undefined || !Number.isFinite(requested) || requested <= 0)
80
+ return DEFAULT_ASK_TIMEOUT_MS;
81
+ return Math.max(1, Math.min(Math.floor(requested), MAX_ASK_TIMEOUT_MS));
82
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Secret redaction for golden fixtures.
3
+ *
4
+ * HARD REQUIREMENT (P0): no exported fixture may contain an api key / token in
5
+ * cleartext. Redaction is applied to every byte the exporter writes and the
6
+ * result is verified afterwards (see RedactionReport.leaksAfter).
7
+ */
8
+ export interface RedactionRule {
9
+ id: string;
10
+ re: RegExp;
11
+ replace: string;
12
+ }
13
+ export interface RedactionReport {
14
+ replacements: number;
15
+ byRule: Record<string, number>;
16
+ secretsRegistered: number;
17
+ /** Credentials discovered in credential contexts and propagated globally. */
18
+ secretsDiscovered?: number;
19
+ leaksAfter: string[];
20
+ }
21
+ /** Token shapes that are secrets regardless of where they came from. */
22
+ export declare const DEFAULT_RULES: RedactionRule[];
23
+ export interface Redactor {
24
+ redact(text: string): string;
25
+ report(): RedactionReport;
26
+ /** Secrets discovered in credential contexts while redacting (propagated globally). */
27
+ dynamicSecrets(): string[];
28
+ /** Throws when a registered secret or a generic token shape survives. */
29
+ assertClean(text: string, where: string): void;
30
+ }
31
+ export declare function createRedactor(knownSecrets: readonly string[], extraRules?: readonly RedactionRule[]): Redactor;
32
+ /**
33
+ * Collect candidate secrets from read-only sources (providers.json keys, an
34
+ * npm auth token, environment values). Never logs them.
35
+ */
36
+ export declare function collectKnownSecrets(input: {
37
+ providersJson?: unknown;
38
+ npmrc?: string;
39
+ env?: NodeJS.ProcessEnv;
40
+ }): string[];
package/dist/redact.js ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Secret redaction for golden fixtures.
3
+ *
4
+ * HARD REQUIREMENT (P0): no exported fixture may contain an api key / token in
5
+ * cleartext. Redaction is applied to every byte the exporter writes and the
6
+ * result is verified afterwards (see RedactionReport.leaksAfter).
7
+ */
8
+ const PLACEHOLDER = "<REDACTED>";
9
+ /** Token shapes that are secrets regardless of where they came from. */
10
+ export const DEFAULT_RULES = [
11
+ // NOTE: no leading \b on the token rules. Session logs embed JSON escapes as
12
+ // literal text ("...\nsk-<key>..."), so a preceding "n" is a word character
13
+ // and a \b boundary would silently skip a real key. Over-redaction is the
14
+ // safe direction here.
15
+ { id: "openai-sk", re: /sk-[A-Za-z0-9_-]{16,}(?![A-Za-z0-9_-])/g, replace: PLACEHOLDER },
16
+ { id: "npm-token", re: /npm_[A-Za-z0-9]{30,}/g, replace: PLACEHOLDER },
17
+ { id: "github-token", re: /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g, replace: PLACEHOLDER },
18
+ { id: "bearer", re: /(Bearer)\s+[A-Za-z0-9._~+/=-]{16,}/g, replace: "$1 " + PLACEHOLDER },
19
+ { id: "authorization-header", re: /("(?:authorization|x-api-key|api[_-]?key)"\s*:\s*")([^"\\]{8,})(")/gi, replace: "$1" + PLACEHOLDER + "$3" },
20
+ // W824 F01: JSON-quoted credential values. The key CLOSING quote sits between
21
+ // the credential word and the colon, so credential-assignment (name\s*[=:]\s*value)
22
+ // can never match a form like "token":"...". Match the quoted key form explicitly;
23
+ // the name set is the credential suffixes only (the authorization-header rule above
24
+ // owns authorization/api[_-]key, and dropping the broad "auth" fragment here keeps a
25
+ // JSON field such as "author":"..." intact).
26
+ { id: "json-credential", re: /("(?:[A-Za-z0-9_]*(?:token|secret|passwd|password|apikey|api_key)[A-Za-z0-9_]*)"\s*:\s*")((?:[^"\\]|\\.){12,})(")/gi, replace: "$1" + PLACEHOLDER + "$3" },
27
+ { id: "env-assignment", re: /([A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD)[A-Z0-9_]*\s*=\s*)("?)([^\s"'\\]{8,})\2/g, replace: "$1$2" + PLACEHOLDER + "$2" },
28
+ { id: "aws-key", re: /AKIA[0-9A-Z]{16}/g, replace: PLACEHOLDER },
29
+ // Cookie / Set-Cookie header values (a live session cookie is a credential).
30
+ { id: "cookie-header", re: /((?:set-)?cookie\s*:\s*)([^\r\n"'\\]{8,})/gi, replace: "$1" + PLACEHOLDER },
31
+ // Service-issued bearer tokens such as `dsh-auth-<token>`.
32
+ { id: "service-auth-token", re: /-auth-[A-Za-z0-9_-]{12,}/g, replace: "-auth-" + PLACEHOLDER },
33
+ // Any `...token=<value>` / `...key=<value>` / `...secret=<value>` assignment
34
+ // with a token-shaped (12+ char) value. Prose like `?token=...` stays intact.
35
+ { id: "credential-assignment", re: /(\b[A-Za-z0-9_]*(?:token|secret|passwd|password|apikey|api_key|auth)[A-Za-z0-9_]*\s*[=:]\s*)([A-Za-z0-9_\-.]{12,})/gi, replace: "$1" + PLACEHOLDER },
36
+ // NOTE: no generic `_authToken=<value>` rule on purpose. Session logs contain
37
+ // sed regex prose such as `s/(_authToken=)[A-Za-z0-9._-]+/.../`; the real
38
+ // npm token is caught by the npm-token rule and by the registered-secret pass
39
+ // (collectKnownSecrets reads ~/.npmrc).
40
+ ];
41
+ /**
42
+ * Credential contexts. Any token-shaped (16+ char) substring found inside one
43
+ * of these regions is registered as a dynamic secret and then redacted
44
+ * EVERYWHERE — so an alias such as `T=<token>` (a shell variable holding a
45
+ * cookie value) cannot survive just because its own context is not
46
+ * credential-shaped.
47
+ */
48
+ const CREDENTIAL_CONTEXTS = [
49
+ /(?:set-)?cookie\s*:\s*([^\r\n"'\\]{8,})/gi,
50
+ /authorization\s*:\s*([^\r\n"'\\]{8,})/gi,
51
+ /bearer\s+([A-Za-z0-9._~+/=-]{8,})/gi,
52
+ /\b[A-Za-z0-9_]*(?:token|secret|password|passwd|apikey|api_key|auth)[A-Za-z0-9_]*\s*[=:]\s*("?)([A-Za-z0-9_\-.+/=]{8,})\1/gi,
53
+ /(sk-[A-Za-z0-9_-]{16,})/g,
54
+ /(-auth-[A-Za-z0-9_-]{12,})/g,
55
+ /(npm_[A-Za-z0-9]{30,})/g,
56
+ ];
57
+ const TOKENISH = /[A-Za-z0-9_\-.+/=]{16,}/g;
58
+ export function createRedactor(knownSecrets, extraRules = []) {
59
+ const rules = [...extraRules, ...DEFAULT_RULES];
60
+ const byRule = {};
61
+ let replacements = 0;
62
+ // Exact registered secrets first (longest first so overlapping values are safe).
63
+ const secrets = [...new Set(knownSecrets.filter((s) => typeof s === "string" && s.length >= 8))].sort((a, b) => b.length - a.length);
64
+ const dynamic = new Set();
65
+ /** Discover credential-shaped values in the text and register them globally. */
66
+ function discover(text) {
67
+ for (const re of CREDENTIAL_CONTEXTS) {
68
+ re.lastIndex = 0;
69
+ for (const m of text.matchAll(re)) {
70
+ for (let g = 1; g < m.length; g++) {
71
+ const region = m[g];
72
+ if (typeof region !== "string")
73
+ continue;
74
+ TOKENISH.lastIndex = 0;
75
+ for (const tok of region.matchAll(TOKENISH)) {
76
+ const v = tok[0];
77
+ if (v.length >= 16 && !v.includes(PLACEHOLDER))
78
+ dynamic.add(v);
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ function redact(text) {
85
+ discover(text);
86
+ let out = text;
87
+ const allSecrets = [...new Set([...secrets, ...dynamic])].sort((a, b) => b.length - a.length);
88
+ for (const secret of allSecrets) {
89
+ if (!out.includes(secret))
90
+ continue;
91
+ const parts = out.split(secret);
92
+ const hits = parts.length - 1;
93
+ if (hits > 0) {
94
+ replacements += hits;
95
+ byRule["registered-secret"] = (byRule["registered-secret"] ?? 0) + hits;
96
+ out = parts.join(PLACEHOLDER);
97
+ }
98
+ }
99
+ for (const rule of rules) {
100
+ rule.re.lastIndex = 0;
101
+ out = out.replace(rule.re, (...args) => {
102
+ replacements += 1;
103
+ byRule[rule.id] = (byRule[rule.id] ?? 0) + 1;
104
+ // args = [match, g1, g2, ..., offset, string]; $1..$n are the groups.
105
+ const groups = args.slice(1, -2);
106
+ let replacement = rule.replace;
107
+ // Descending so $1 cannot clobber the prefix of $10.
108
+ for (let i = groups.length; i >= 1; i--) {
109
+ replacement = replacement.split(`$${i}`).join(groups[i - 1] ?? "");
110
+ }
111
+ return replacement;
112
+ });
113
+ }
114
+ return out;
115
+ }
116
+ function leaksAfter(text) {
117
+ // A placeholder is by definition not a leak. Replace it with a SPACE (not
118
+ // an empty string) so removing it cannot glue neighbouring text into a
119
+ // fake match for value-shaped rules like `KEY=<value>`.
120
+ const probe = text.split(PLACEHOLDER).join(" ");
121
+ const leaks = [];
122
+ for (const secret of secrets)
123
+ if (probe.includes(secret))
124
+ leaks.push("registered-secret");
125
+ for (const secret of dynamic)
126
+ if (probe.includes(secret))
127
+ leaks.push("discovered-secret");
128
+ for (const rule of rules) {
129
+ rule.re.lastIndex = 0;
130
+ if (rule.re.test(probe))
131
+ leaks.push(rule.id);
132
+ rule.re.lastIndex = 0;
133
+ }
134
+ return [...new Set(leaks)];
135
+ }
136
+ return {
137
+ redact,
138
+ report() {
139
+ return { replacements, byRule, secretsRegistered: secrets.length, secretsDiscovered: dynamic.size, leaksAfter: [] };
140
+ },
141
+ dynamicSecrets() {
142
+ return [...dynamic];
143
+ },
144
+ assertClean(text, where) {
145
+ const leaks = leaksAfter(text);
146
+ if (leaks.length > 0) {
147
+ throw new Error(`secret leak in ${where}: ${leaks.join(", ")}`);
148
+ }
149
+ },
150
+ };
151
+ }
152
+ /**
153
+ * Collect candidate secrets from read-only sources (providers.json keys, an
154
+ * npm auth token, environment values). Never logs them.
155
+ */
156
+ export function collectKnownSecrets(input) {
157
+ const out = [];
158
+ const providers = input.providersJson;
159
+ if (providers !== null && typeof providers === "object" && "providers" in providers) {
160
+ const list = providers.providers;
161
+ if (Array.isArray(list)) {
162
+ for (const p of list) {
163
+ if (p !== null && typeof p === "object" && "api_key" in p) {
164
+ const key = p.api_key;
165
+ if (typeof key === "string" && key.trim().length >= 8)
166
+ out.push(key);
167
+ }
168
+ }
169
+ }
170
+ }
171
+ if (input.npmrc) {
172
+ for (const m of input.npmrc.matchAll(/_authToken\s*=\s*(\S+)/g)) {
173
+ const tok = m[1];
174
+ if (tok && tok.length >= 8)
175
+ out.push(tok);
176
+ }
177
+ }
178
+ const env = input.env ?? {};
179
+ for (const name of ["CELESTEA_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DEEPSEEK_API_KEY"]) {
180
+ const v = env[name];
181
+ if (typeof v === "string" && v.trim().length >= 8)
182
+ out.push(v.trim());
183
+ }
184
+ return out;
185
+ }
package/dist/repo.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ /** Locate the repository root (the directory holding pnpm-workspace.yaml). */
2
+ export declare function repoRoot(from?: string): string;
3
+ /**
4
+ * Absolute path to the frozen `contracts/` directory.
5
+ *
6
+ * H: a SOURCE CHECKOUT always uses `<repo>/contracts` — the build-staged
7
+ * `packages/core/contracts/` is a shipping artifact and must never shadow a
8
+ * live edit of the repo's contracts in dev. Only an INSTALLED package (no
9
+ * workspace marker) reads its own bundled `contracts/`.
10
+ */
11
+ export declare function contractsDir(): string;
12
+ export declare function contractPath(...parts: string[]): string;
13
+ export declare function fixturePath(...parts: string[]): string;
14
+ export declare function reportPath(...parts: string[]): string;