@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.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/contracts/data-files/checkpoint.schema.json +111 -0
- package/contracts/data-files/cli-main-jsonl-precompact.schema.json +27 -0
- package/contracts/data-files/cli-main-jsonl.schema.json +22 -0
- package/contracts/data-files/fallbacks.schema.json +71 -0
- package/contracts/data-files/index.json +124 -0
- package/contracts/data-files/pricing.schema.json +65 -0
- package/contracts/data-files/prompts.schema.json +130 -0
- package/contracts/data-files/providers.schema.json +177 -0
- package/contracts/data-files/registry-tsv.schema.json +74 -0
- package/contracts/data-files/session.schema.json +51 -0
- package/contracts/data-files/usage-ledger.schema.json +112 -0
- package/contracts/data-files/workspaces.schema.json +63 -0
- package/contracts/endpoints.json +4390 -0
- package/contracts/probe-evidence.json +219 -0
- package/contracts/route-table.snapshot.json +377 -0
- package/contracts/scope-hash-vectors.json +273 -0
- package/contracts/session-event.schema.json +441 -0
- package/contracts/sse-events.json +202 -0
- package/contracts/tools.json +730 -0
- package/dist/agent.d.ts +65 -0
- package/dist/agent.js +36 -0
- package/dist/celestea-home.d.ts +63 -0
- package/dist/celestea-home.js +96 -0
- package/dist/celestea-sources.d.ts +53 -0
- package/dist/celestea-sources.js +61 -0
- package/dist/context.d.ts +33 -0
- package/dist/context.js +55 -0
- package/dist/contracts/index.d.ts +234 -0
- package/dist/contracts/index.js +159 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +22 -0
- package/dist/event-bus.d.ts +60 -0
- package/dist/event-bus.js +100 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +66 -0
- package/dist/injection.d.ts +61 -0
- package/dist/injection.js +27 -0
- package/dist/json.d.ts +34 -0
- package/dist/json.js +127 -0
- package/dist/llm.d.ts +34 -0
- package/dist/llm.js +41 -0
- package/dist/memory.d.ts +72 -0
- package/dist/memory.js +123 -0
- package/dist/message.d.ts +189 -0
- package/dist/message.js +252 -0
- package/dist/plugin.d.ts +38 -0
- package/dist/plugin.js +49 -0
- package/dist/projection.d.ts +67 -0
- package/dist/projection.js +168 -0
- package/dist/question.d.ts +154 -0
- package/dist/question.js +82 -0
- package/dist/redact.d.ts +40 -0
- package/dist/redact.js +185 -0
- package/dist/repo.d.ts +14 -0
- package/dist/repo.js +87 -0
- package/dist/sandbox.d.ts +182 -0
- package/dist/sandbox.js +78 -0
- package/dist/session-event.d.ts +57 -0
- package/dist/session-event.js +425 -0
- package/dist/session-log.d.ts +71 -0
- package/dist/session-log.js +66 -0
- package/dist/skill-catalog.d.ts +29 -0
- package/dist/skill-catalog.js +52 -0
- package/dist/skills.d.ts +116 -0
- package/dist/skills.js +273 -0
- package/dist/sse-bus.d.ts +40 -0
- package/dist/sse-bus.js +105 -0
- package/dist/stream.d.ts +115 -0
- package/dist/stream.js +52 -0
- package/dist/tool-surface.d.ts +45 -0
- package/dist/tool-surface.js +98 -0
- package/dist/tool.d.ts +77 -0
- package/dist/tool.js +15 -0
- package/dist/turn-id.d.ts +37 -0
- package/dist/turn-id.js +76 -0
- package/dist/types.d.ts +396 -0
- package/dist/types.js +58 -0
- 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;
|
package/dist/question.js
ADDED
|
@@ -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
|
+
}
|
package/dist/redact.d.ts
ADDED
|
@@ -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;
|