@celestea/runtime 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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/dist/agent-config.d.ts +18 -0
  4. package/dist/agent-config.js +31 -0
  5. package/dist/autowake.d.ts +141 -0
  6. package/dist/autowake.js +262 -0
  7. package/dist/compact/index.d.ts +13 -0
  8. package/dist/compact/index.js +13 -0
  9. package/dist/compact/plan.d.ts +51 -0
  10. package/dist/compact/plan.js +98 -0
  11. package/dist/compact/rewrite.d.ts +23 -0
  12. package/dist/compact/rewrite.js +79 -0
  13. package/dist/compact/run.d.ts +44 -0
  14. package/dist/compact/run.js +59 -0
  15. package/dist/compact/summarize.d.ts +30 -0
  16. package/dist/compact/summarize.js +70 -0
  17. package/dist/compact/transcript.d.ts +35 -0
  18. package/dist/compact/transcript.js +88 -0
  19. package/dist/compose.d.ts +117 -0
  20. package/dist/compose.js +191 -0
  21. package/dist/errors.d.ts +25 -0
  22. package/dist/errors.js +34 -0
  23. package/dist/frames.d.ts +46 -0
  24. package/dist/frames.js +62 -0
  25. package/dist/gen.d.ts +86 -0
  26. package/dist/gen.js +129 -0
  27. package/dist/host/engine-session.d.ts +117 -0
  28. package/dist/host/engine-session.js +109 -0
  29. package/dist/host/index.d.ts +39 -0
  30. package/dist/host/index.js +39 -0
  31. package/dist/host/provider-target.d.ts +113 -0
  32. package/dist/host/provider-target.js +116 -0
  33. package/dist/inbox-checkpoint.d.ts +18 -0
  34. package/dist/inbox-checkpoint.js +37 -0
  35. package/dist/inbox.d.ts +94 -0
  36. package/dist/inbox.js +139 -0
  37. package/dist/index.d.ts +71 -0
  38. package/dist/index.js +71 -0
  39. package/dist/ledger-io.d.ts +27 -0
  40. package/dist/ledger-io.js +74 -0
  41. package/dist/ledger-llm.d.ts +48 -0
  42. package/dist/ledger-llm.js +115 -0
  43. package/dist/ledger-query.d.ts +91 -0
  44. package/dist/ledger-query.js +153 -0
  45. package/dist/ledger.d.ts +271 -0
  46. package/dist/ledger.js +444 -0
  47. package/dist/pricing.d.ts +100 -0
  48. package/dist/pricing.js +167 -0
  49. package/dist/profile.d.ts +26 -0
  50. package/dist/profile.js +39 -0
  51. package/dist/recovery.d.ts +56 -0
  52. package/dist/recovery.js +91 -0
  53. package/dist/retention.d.ts +49 -0
  54. package/dist/retention.js +119 -0
  55. package/dist/runtime.d.ts +197 -0
  56. package/dist/runtime.js +347 -0
  57. package/dist/sanitize.d.ts +35 -0
  58. package/dist/sanitize.js +36 -0
  59. package/dist/session-binding.d.ts +36 -0
  60. package/dist/session-binding.js +33 -0
  61. package/dist/session-registry.d.ts +238 -0
  62. package/dist/session-registry.js +388 -0
  63. package/dist/status.d.ts +279 -0
  64. package/dist/status.js +411 -0
  65. package/dist/tokens.d.ts +25 -0
  66. package/dist/tokens.js +25 -0
  67. package/dist/turn-runner.d.ts +169 -0
  68. package/dist/turn-runner.js +242 -0
  69. package/dist/usage.d.ts +64 -0
  70. package/dist/usage.js +88 -0
  71. package/dist/watchdog-mount.d.ts +79 -0
  72. package/dist/watchdog-mount.js +120 -0
  73. package/dist/worker-wiring.d.ts +74 -0
  74. package/dist/worker-wiring.js +107 -0
  75. package/package.json +31 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Pricing snapshot for the usage ledger (iteration E §3.2.2, W728 P0).
3
+ *
4
+ * The engine converts tokens into money with EXACTLY one rule:
5
+ * `tokens / 1e6 × unit price`. It deliberately does NOT re-implement the
6
+ * platform's billing (group multipliers, expressions, cache discounts):
7
+ * `newapi` owns the price of record (LTS `biz/newapi.md` I1–I3), so a snapshot
8
+ * here is an engine-side ESTIMATE, never a second source of truth (§3.5 R3-2).
9
+ *
10
+ * `pricing.json` is operator/ops supplied (§3.5 R3-1: P0 does not depend on the
11
+ * newapi sync script). A model absent from the table is NOT priced as 0:
12
+ * [priceFor] returns null and the caller must mark the record `unpriced`.
13
+ * A missing or unreadable file is an empty table (every model unpriced) — it is
14
+ * reported on stderr and never throws, because a pricing problem must not break
15
+ * a turn.
16
+ */
17
+ import { readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ /** `<data dir>/pricing.json` (§3.2.2). */
20
+ export const PRICING_FILE = "pricing.json";
21
+ /** Path override (`CELESTEA_PRICING_FILE`). */
22
+ export const ENV_PRICING_FILE = "CELESTEA_PRICING_FILE";
23
+ /** The only unit the ledger understands (per million tokens). */
24
+ export const PRICING_UNIT = "per_mtok";
25
+ /** A table with no models: every lookup is `unpriced` (never 0). */
26
+ export function emptyPricing(path = null) {
27
+ return { version: "none", currency: "CNY", unit: PRICING_UNIT, models: {}, effective_from: null, path };
28
+ }
29
+ /** `<data dir>/pricing.json`, overridable with `CELESTEA_PRICING_FILE`. */
30
+ export function pricingPath(dataDir, env = process.env) {
31
+ const override = env[ENV_PRICING_FILE];
32
+ return override === undefined || override.trim() === "" ? join(dataDir, PRICING_FILE) : override;
33
+ }
34
+ /** Price of `model`, or null when the snapshot does not cover it. */
35
+ export function priceFor(table, model) {
36
+ if (model === null)
37
+ return null;
38
+ const price = table.models[model];
39
+ return price === undefined ? null : price;
40
+ }
41
+ /**
42
+ * `uncached_in × in + completion × out + cache_read × cache_read`, rounded
43
+ * to 6 decimals.
44
+ *
45
+ * The provider's `prompt_tokens` ALREADY CONTAINS the cache-hit region (host
46
+ * turn-usage: `total - output === input + cacheRead + cacheWrite`; the LLM seam
47
+ * reads `prompt_tokens` as that total and the cache counters separately), so
48
+ * the hit region must be billed ONCE, at the cache price:
49
+ * `billableIn = max(0, prompt_tokens - cache_read)` is the input the provider
50
+ * did NOT serve from cache. Charging the whole prompt at the input price AND
51
+ * the cache counter on top would bill the hit region twice; that reading was
52
+ * rejected by the product, so this function never does it.
53
+ *
54
+ * `max(0, ...)` guards anomalous data (`cache_read > prompt_tokens`, which the
55
+ * provider should never report): the input component floors at 0 instead of
56
+ * going negative. The cache component still charges what was reported.
57
+ */
58
+ export function costOf(usage, price) {
59
+ const billableIn = Math.max(0, usage.prompt_tokens - usage.cache_read);
60
+ const inCost = round6((billableIn / 1e6) * price.in);
61
+ const outCost = round6((usage.completion_tokens / 1e6) * price.out);
62
+ const cacheCost = round6((usage.cache_read / 1e6) * price.cache_read);
63
+ return { in: inCost, out: outCost, cache: cacheCost, total: round6(inCost + outCost + cacheCost) };
64
+ }
65
+ /** Sum of two costs, component-wise (so the total cannot drift from the parts). */
66
+ export function costAdd(a, b) {
67
+ const inCost = round6(a.in + b.in);
68
+ const outCost = round6(a.out + b.out);
69
+ const cacheCost = round6(a.cache + b.cache);
70
+ return { in: inCost, out: outCost, cache: cacheCost, total: round6(inCost + outCost + cacheCost) };
71
+ }
72
+ /** `price` field of a priced ledger row. */
73
+ export function priceSnapshot(table, price) {
74
+ return {
75
+ version: table.version,
76
+ currency: table.currency,
77
+ unit: table.unit,
78
+ in: price.in,
79
+ out: price.out,
80
+ cache_read: price.cache_read,
81
+ };
82
+ }
83
+ /** Parse a pricing.json document; null = malformed (caller falls back to empty). */
84
+ export function parsePricing(input, path = null) {
85
+ if (typeof input !== "object" || input === null || Array.isArray(input))
86
+ return null;
87
+ const rec = input;
88
+ const models = parseModels(rec["models"]);
89
+ if (models === null)
90
+ return null;
91
+ const effective = rec["effective_from"];
92
+ return {
93
+ version: text(rec["version"]) ?? "unknown",
94
+ currency: text(rec["currency"]) ?? "CNY",
95
+ unit: PRICING_UNIT,
96
+ models,
97
+ effective_from: typeof effective === "number" && Number.isFinite(effective) ? effective : null,
98
+ path,
99
+ };
100
+ }
101
+ /**
102
+ * Read the snapshot. A missing file is an empty table (everything unpriced). A
103
+ * file that exists but does not parse is ALSO an empty table, reported on
104
+ * stderr: pricing half the fleet by a half-read table would under-report cost
105
+ * silently (G3-7).
106
+ */
107
+ export function loadPricingFile(path) {
108
+ let raw;
109
+ try {
110
+ raw = readFileSync(path, "utf8");
111
+ }
112
+ catch {
113
+ return emptyPricing(path);
114
+ }
115
+ let parsed;
116
+ try {
117
+ parsed = JSON.parse(raw);
118
+ }
119
+ catch (e) {
120
+ warn(`pricing file ${path} is not valid JSON (${errorText(e)}) — every model is unpriced`);
121
+ return emptyPricing(path);
122
+ }
123
+ const table = parsePricing(parsed, path);
124
+ if (table === null) {
125
+ warn(`pricing file ${path} has no usable "models" map — every model is unpriced`);
126
+ return emptyPricing(path);
127
+ }
128
+ return table;
129
+ }
130
+ /** The `models` map, or null when the document has no usable shape at all. */
131
+ function parseModels(input) {
132
+ if (typeof input !== "object" || input === null || Array.isArray(input))
133
+ return null;
134
+ const out = {};
135
+ for (const [model, value] of Object.entries(input)) {
136
+ const price = parsePrice(value);
137
+ if (price !== null)
138
+ out[model] = price;
139
+ }
140
+ return out;
141
+ }
142
+ /** One table row; `in`/`out` are required, `cache_read` defaults to 0. */
143
+ function parsePrice(value) {
144
+ if (typeof value !== "object" || value === null)
145
+ return null;
146
+ const rec = value;
147
+ const inCost = asPrice(rec["in"]);
148
+ const outCost = asPrice(rec["out"]);
149
+ if (inCost === null || outCost === null)
150
+ return null;
151
+ return { in: inCost, out: outCost, cache_read: asPrice(rec["cache_read"]) ?? 0 };
152
+ }
153
+ function asPrice(v) {
154
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
155
+ }
156
+ function text(v) {
157
+ return typeof v === "string" && v !== "" ? v : null;
158
+ }
159
+ function round6(v) {
160
+ return Math.round(v * 1e6) / 1e6;
161
+ }
162
+ function warn(message) {
163
+ process.stderr.write(`usage ledger: ${message}\n`);
164
+ }
165
+ function errorText(e) {
166
+ return e instanceof Error ? e.message : String(e);
167
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Runtime compose seam (P3 placeholder).
3
+ *
4
+ * P0 freezes the profile key set (12 keys) and the three key-resolution paths
5
+ * so P3 cannot invent new ones.
6
+ */
7
+ export interface Profile {
8
+ model: string;
9
+ base_url: string;
10
+ api_key_env: string;
11
+ api_key_file: string | null;
12
+ max_steps: number;
13
+ max_parallel_tool_calls: number;
14
+ reasoning_effort: string | null;
15
+ max_output_tokens: number | null;
16
+ context_window_tokens: number;
17
+ system_prompt: string;
18
+ request_format: "chat_completions" | "responses" | "anthropic_messages";
19
+ temperature: number | null;
20
+ }
21
+ export declare const PROFILE_KEYS: readonly ["model", "base_url", "api_key_env", "api_key_file", "max_steps", "max_parallel_tool_calls", "reasoning_effort", "max_output_tokens", "context_window_tokens", "system_prompt", "request_format", "temperature"];
22
+ /** resolve_api_key order: env -> api_key_file -> ~/.celestea config. */
23
+ export type KeySource = "env" | "api_key_file" | "home_config" | "provider_store" | "borrowed_engine_key" | "none";
24
+ export declare const COMPOSE_STEPS: readonly ["load_dotenv", "load_workspaces_registry", "restore_active_session", "resolve_profile", "raise_max_steps", "load_providers", "apply_startup_default", "build_gen", "assemble_system_prompt", "runtime_compose", "build_app_state", "spawn_autowake", "register_routes", "serve", "shutdown"];
25
+ export declare const DEFAULT_BIND = "127.0.0.1:3777";
26
+ export declare const CONTEXT_WINDOW = 1000000;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Runtime compose seam (P3 placeholder).
3
+ *
4
+ * P0 freezes the profile key set (12 keys) and the three key-resolution paths
5
+ * so P3 cannot invent new ones.
6
+ */
7
+ export const PROFILE_KEYS = [
8
+ "model",
9
+ "base_url",
10
+ "api_key_env",
11
+ "api_key_file",
12
+ "max_steps",
13
+ "max_parallel_tool_calls",
14
+ "reasoning_effort",
15
+ "max_output_tokens",
16
+ "context_window_tokens",
17
+ "system_prompt",
18
+ "request_format",
19
+ "temperature",
20
+ ];
21
+ export const COMPOSE_STEPS = [
22
+ "load_dotenv",
23
+ "load_workspaces_registry",
24
+ "restore_active_session",
25
+ "resolve_profile",
26
+ "raise_max_steps",
27
+ "load_providers",
28
+ "apply_startup_default",
29
+ "build_gen",
30
+ "assemble_system_prompt",
31
+ "runtime_compose",
32
+ "build_app_state",
33
+ "spawn_autowake",
34
+ "register_routes",
35
+ "serve",
36
+ "shutdown",
37
+ ];
38
+ export const DEFAULT_BIND = "127.0.0.1:3777";
39
+ export const CONTEXT_WINDOW = 1_000_000;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Boot recovery — "close the turn the previous process died inside" (§1.3 P0 ③).
3
+ *
4
+ * This is the ONLY caller of the §1.2.3 decision table that owns a session
5
+ * DIRECTORY: it checks that a log exists at all (a session that never ran must
6
+ * not gain an empty `cli-main.jsonl` because the host scanned it), opens the log
7
+ * through the persistent implementation — so a torn tail left by the crash is
8
+ * truncated exactly like it would be on the next turn — and then lets the pure
9
+ * decision function in `@celestea/session` do the (append-only) repair.
10
+ *
11
+ * Layering: runtime -> session is a downward edge (L2 -> L1) and already the
12
+ * package's own dependency; the DECISION itself stays in the session package
13
+ * because it is about the log + sidecar contract, not about assembly.
14
+ *
15
+ * The report is the observability channel of P0 (§5.2 ①): the host logs it, and
16
+ * the sidecar's `repaired[]` keeps the durable record. P0 adds no endpoint.
17
+ */
18
+ import type { SessionLog } from "@celestea/core";
19
+ import { type CheckpointIdentity, type CheckpointRead, type RecoveryAction } from "@celestea/session";
20
+ import { SESSION_LOG_ID } from "./host/engine-session.js";
21
+ /**
22
+ * The engine's log file name / id inside a session directory (contract).
23
+ *
24
+ * W747: single source — `host/engine-session.ts` owns the session log id/name
25
+ * (it opens the log); this module imports and re-exports it instead of keeping a
26
+ * second declaration of the same literal. Public name and value are unchanged.
27
+ */
28
+ export { SESSION_LOG_ID };
29
+ /** `<dir>/cli-main.jsonl` — the log this orchestrator may repair. */
30
+ export declare function sessionLogPath(dir: string, sessionId?: string): string;
31
+ export type BootRecoveryAction = RecoveryAction | "skipped_absent_log";
32
+ export interface BootRecoveryOptions {
33
+ /** Session directory (`<workspace>/<session>`). */
34
+ dir: string;
35
+ /** Self-description of the sidecar (`<workspace>/<session>`). */
36
+ session: string;
37
+ identity?: CheckpointIdentity;
38
+ now?: () => number;
39
+ warn?: (message: string) => void;
40
+ /** Log opener override (tests); default = the persistent JSONL log. */
41
+ open?: (dir: string) => SessionLog;
42
+ }
43
+ export interface BootRecoveryReport {
44
+ session: string;
45
+ dir: string;
46
+ action: BootRecoveryAction;
47
+ turn_id: string | null;
48
+ /** True when exactly one `turn_end: interrupted` row was appended. */
49
+ appended: boolean;
50
+ dangling_before: string[];
51
+ dangling_after: string[];
52
+ checkpoint: CheckpointRead["kind"];
53
+ warnings: string[];
54
+ }
55
+ /** Recover ONE session directory. Never throws; nothing is created for a session that never ran. */
56
+ export declare function recoverSessionOnBoot(opts: BootRecoveryOptions): BootRecoveryReport;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Boot recovery — "close the turn the previous process died inside" (§1.3 P0 ③).
3
+ *
4
+ * This is the ONLY caller of the §1.2.3 decision table that owns a session
5
+ * DIRECTORY: it checks that a log exists at all (a session that never ran must
6
+ * not gain an empty `cli-main.jsonl` because the host scanned it), opens the log
7
+ * through the persistent implementation — so a torn tail left by the crash is
8
+ * truncated exactly like it would be on the next turn — and then lets the pure
9
+ * decision function in `@celestea/session` do the (append-only) repair.
10
+ *
11
+ * Layering: runtime -> session is a downward edge (L2 -> L1) and already the
12
+ * package's own dependency; the DECISION itself stays in the session package
13
+ * because it is about the log + sidecar contract, not about assembly.
14
+ *
15
+ * The report is the observability channel of P0 (§5.2 ①): the host logs it, and
16
+ * the sidecar's `repaired[]` keeps the durable record. P0 adds no endpoint.
17
+ */
18
+ import { existsSync } from "node:fs";
19
+ import { CheckpointStore, checkpointPathFor, filePathFor, PersistentSessionLog, recoverOpenTurn, writeErrorCountOf, } from "@celestea/session";
20
+ import { SESSION_LOG_ID } from "./host/engine-session.js";
21
+ /**
22
+ * The engine's log file name / id inside a session directory (contract).
23
+ *
24
+ * W747: single source — `host/engine-session.ts` owns the session log id/name
25
+ * (it opens the log); this module imports and re-exports it instead of keeping a
26
+ * second declaration of the same literal. Public name and value are unchanged.
27
+ */
28
+ export { SESSION_LOG_ID };
29
+ /** `<dir>/cli-main.jsonl` — the log this orchestrator may repair. */
30
+ export function sessionLogPath(dir, sessionId = SESSION_LOG_ID) {
31
+ return filePathFor(dir, sessionId);
32
+ }
33
+ /** Recover ONE session directory. Never throws; nothing is created for a session that never ran. */
34
+ export function recoverSessionOnBoot(opts) {
35
+ const logPath = sessionLogPath(opts.dir);
36
+ const exists = existsSync(logPath);
37
+ const store = new CheckpointStore({
38
+ dir: opts.dir,
39
+ session: opts.session,
40
+ ...(opts.identity === undefined ? {} : { identity: opts.identity }),
41
+ ...(opts.now === undefined ? {} : { now: opts.now }),
42
+ ...(opts.warn === undefined ? {} : { warn: opts.warn }),
43
+ });
44
+ const checkpoint = store.load();
45
+ const base = { opts, checkpoint, before: [], after: [] };
46
+ if (!exists)
47
+ return report({ ...base, action: "skipped_absent_log", turnId: null, appended: false, warnings: store.warnings() });
48
+ const log = (opts.open ?? defaultOpen)(opts.dir);
49
+ try {
50
+ const outcome = recoverOpenTurn(log, store);
51
+ return report({
52
+ opts,
53
+ action: outcome.action,
54
+ turnId: outcome.turn_id,
55
+ appended: outcome.appended,
56
+ before: outcome.dangling_before,
57
+ after: outcome.dangling_after,
58
+ checkpoint: store.load(),
59
+ warnings: outcome.warnings,
60
+ });
61
+ }
62
+ finally {
63
+ closeLog(log);
64
+ }
65
+ }
66
+ function defaultOpen(dir) {
67
+ return PersistentSessionLog.open(dir, SESSION_LOG_ID);
68
+ }
69
+ function report(parts) {
70
+ const all = [...parts.warnings];
71
+ if (parts.checkpoint.kind === "invalid") {
72
+ all.push(`checkpoint invalid at ${checkpointPathFor(parts.opts.dir)}: ${parts.checkpoint.error}`);
73
+ }
74
+ return {
75
+ session: parts.opts.session,
76
+ dir: parts.opts.dir,
77
+ action: parts.action,
78
+ turn_id: parts.turnId,
79
+ appended: parts.appended,
80
+ dangling_before: parts.before,
81
+ dangling_after: parts.after,
82
+ checkpoint: parts.checkpoint.kind,
83
+ warnings: all,
84
+ };
85
+ }
86
+ /** Release the descriptor a persistent log owns (no-op for any other log). */
87
+ function closeLog(log) {
88
+ const close = log?.close;
89
+ if (typeof close === "function")
90
+ close.call(log);
91
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Host-side half of tool-result retention (W855): WHERE the spilled bytes go.
3
+ *
4
+ * The policy (thresholds, head/tail window, the "spill and replace" decision)
5
+ * lives in \@celestea/agent-loop (L1, core-only). This module owns the
6
+ * persistence: full text -> <session-dir>/spills/<call>-<n>.txt, a locator the
7
+ * model can read back, and a tool-shaped retrieval hint.
8
+ *
9
+ * Fail-soft by construction: no session dir, an unwritable dir or a write error
10
+ * all return null, and the loop then keeps the full result inline. A successful
11
+ * tool call is never turned into an error by a failed spill.
12
+ */
13
+ import { type ToolResultRetention } from "@celestea/agent-loop";
14
+ /** Single-result threshold override (bytes). */
15
+ export declare const SINGLE_RESULT_ENV = "CELESTEA_TOOL_RESULT_MAX_BYTES";
16
+ /** Per-step cumulative threshold override (bytes). */
17
+ export declare const STEP_RESULT_ENV = "CELESTEA_STEP_TOOL_RESULT_MAX_BYTES";
18
+ /** Inline head window override (bytes). */
19
+ export declare const PREVIEW_HEAD_ENV = "CELESTEA_TOOL_RESULT_PREVIEW_HEAD_BYTES";
20
+ /** Inline tail window override (bytes). */
21
+ export declare const PREVIEW_TAIL_ENV = "CELESTEA_TOOL_RESULT_PREVIEW_TAIL_BYTES";
22
+ /** Spill age override (ms); 0 disables the startup sweep entirely. */
23
+ export declare const SPILL_TTL_ENV = "CELESTEA_SPILL_TTL_MS";
24
+ /** Default spill age: `<session>/spills/*.txt` older than this are reaped at compose. */
25
+ export declare const DEFAULT_SPILL_TTL_MS: number;
26
+ export interface RetentionSettings {
27
+ /** Session directory; null = nothing can be spilled (retention is inert). */
28
+ dir: string | null;
29
+ singleResultBytes: number;
30
+ stepResultBytes: number;
31
+ previewHeadBytes: number;
32
+ previewTailBytes: number;
33
+ /** Age after which a spilled file is reaped at writer creation (0 = never). */
34
+ spillTtlMs: number;
35
+ }
36
+ /** Thresholds from the environment, with the built-in defaults. */
37
+ export declare function retentionSettingsFromEnv(dir: string | null, env?: NodeJS.ProcessEnv): RetentionSettings;
38
+ /**
39
+ * Reap spilled files older than `ttlMs` from `<dir>/spills` (W855 #8a).
40
+ *
41
+ * Policy: an AGE threshold, not "delete everything" — a fresh spill is the
42
+ * model's retrieval target and must survive a same-day recompose; only files a
43
+ * full TTL old are removed, so the directory cannot grow without bound across
44
+ * restarts. Synchronous and fail-soft: a missing/unreadable directory or one
45
+ * failed unlink never fails a compose. Returns the number of files removed.
46
+ */
47
+ export declare function sweepSpills(dir: string | null, ttlMs: number, now?: number): number;
48
+ /** Build the session-scoped spill writer. */
49
+ export declare function createToolResultRetention(settings: RetentionSettings): ToolResultRetention;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Host-side half of tool-result retention (W855): WHERE the spilled bytes go.
3
+ *
4
+ * The policy (thresholds, head/tail window, the "spill and replace" decision)
5
+ * lives in \@celestea/agent-loop (L1, core-only). This module owns the
6
+ * persistence: full text -> <session-dir>/spills/<call>-<n>.txt, a locator the
7
+ * model can read back, and a tool-shaped retrieval hint.
8
+ *
9
+ * Fail-soft by construction: no session dir, an unwritable dir or a write error
10
+ * all return null, and the loop then keeps the full result inline. A successful
11
+ * tool call is never turned into an error by a failed spill.
12
+ */
13
+ import { readdirSync, statSync, unlinkSync } from "node:fs";
14
+ import { mkdir, writeFile } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+ import { DEFAULT_PREVIEW_HEAD_BYTES, DEFAULT_PREVIEW_TAIL_BYTES, DEFAULT_SINGLE_RESULT_BYTES, DEFAULT_STEP_RESULT_BYTES, } from "@celestea/agent-loop";
17
+ /** Single-result threshold override (bytes). */
18
+ export const SINGLE_RESULT_ENV = "CELESTEA_TOOL_RESULT_MAX_BYTES";
19
+ /** Per-step cumulative threshold override (bytes). */
20
+ export const STEP_RESULT_ENV = "CELESTEA_STEP_TOOL_RESULT_MAX_BYTES";
21
+ /** Inline head window override (bytes). */
22
+ export const PREVIEW_HEAD_ENV = "CELESTEA_TOOL_RESULT_PREVIEW_HEAD_BYTES";
23
+ /** Inline tail window override (bytes). */
24
+ export const PREVIEW_TAIL_ENV = "CELESTEA_TOOL_RESULT_PREVIEW_TAIL_BYTES";
25
+ /** Spill age override (ms); 0 disables the startup sweep entirely. */
26
+ export const SPILL_TTL_ENV = "CELESTEA_SPILL_TTL_MS";
27
+ /** Default spill age: `<session>/spills/*.txt` older than this are reaped at compose. */
28
+ export const DEFAULT_SPILL_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
29
+ /** Non-negative integer from the environment, else the frozen default. */
30
+ function envBytes(env, name, fallback) {
31
+ const raw = env[name];
32
+ if (raw === undefined || raw.trim() === "")
33
+ return fallback;
34
+ const n = Number.parseInt(raw, 10);
35
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
36
+ }
37
+ /** Thresholds from the environment, with the built-in defaults. */
38
+ export function retentionSettingsFromEnv(dir, env = process.env) {
39
+ return {
40
+ dir,
41
+ singleResultBytes: envBytes(env, SINGLE_RESULT_ENV, DEFAULT_SINGLE_RESULT_BYTES),
42
+ stepResultBytes: envBytes(env, STEP_RESULT_ENV, DEFAULT_STEP_RESULT_BYTES),
43
+ previewHeadBytes: envBytes(env, PREVIEW_HEAD_ENV, DEFAULT_PREVIEW_HEAD_BYTES),
44
+ previewTailBytes: envBytes(env, PREVIEW_TAIL_ENV, DEFAULT_PREVIEW_TAIL_BYTES),
45
+ spillTtlMs: envBytes(env, SPILL_TTL_ENV, DEFAULT_SPILL_TTL_MS),
46
+ };
47
+ }
48
+ /**
49
+ * Reap spilled files older than `ttlMs` from `<dir>/spills` (W855 #8a).
50
+ *
51
+ * Policy: an AGE threshold, not "delete everything" — a fresh spill is the
52
+ * model's retrieval target and must survive a same-day recompose; only files a
53
+ * full TTL old are removed, so the directory cannot grow without bound across
54
+ * restarts. Synchronous and fail-soft: a missing/unreadable directory or one
55
+ * failed unlink never fails a compose. Returns the number of files removed.
56
+ */
57
+ export function sweepSpills(dir, ttlMs, now = Date.now()) {
58
+ if (dir === null || dir === "" || ttlMs <= 0)
59
+ return 0;
60
+ const spills = join(dir, "spills");
61
+ let names;
62
+ try {
63
+ names = readdirSync(spills);
64
+ }
65
+ catch {
66
+ return 0; // ENOENT (nothing spilled yet) or unreadable: nothing to do.
67
+ }
68
+ let removed = 0;
69
+ for (const name of names) {
70
+ if (!name.endsWith(".txt"))
71
+ continue;
72
+ const full = join(spills, name);
73
+ try {
74
+ if (now - statSync(full).mtimeMs > ttlMs) {
75
+ unlinkSync(full);
76
+ removed += 1;
77
+ }
78
+ }
79
+ catch {
80
+ // A file that vanished or cannot be stat'ed/unlinked is left alone.
81
+ }
82
+ }
83
+ return removed;
84
+ }
85
+ /** Build the session-scoped spill writer. */
86
+ export function createToolResultRetention(settings) {
87
+ // W855 #8a: reap stale spills once per session generation (startup sweep).
88
+ sweepSpills(settings.dir, settings.spillTtlMs);
89
+ let seq = 0;
90
+ return {
91
+ singleResultBytes: settings.singleResultBytes,
92
+ stepResultBytes: settings.stepResultBytes,
93
+ previewHeadBytes: settings.previewHeadBytes,
94
+ previewTailBytes: settings.previewTailBytes,
95
+ async spill(text, meta) {
96
+ if (settings.dir === null || settings.dir === "")
97
+ return null;
98
+ try {
99
+ const dir = join(settings.dir, "spills");
100
+ await mkdir(dir, { recursive: true });
101
+ seq += 1;
102
+ const safe = meta.callId.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 48) || "call";
103
+ const locator = join(dir, safe + "-" + String(seq) + ".txt");
104
+ await writeFile(locator, text, "utf8");
105
+ return {
106
+ locator,
107
+ bytes: Buffer.byteLength(text, "utf8"),
108
+ retrievalHint: 'read_file path="' + locator + '"',
109
+ };
110
+ }
111
+ catch (e) {
112
+ // Best-effort: a failed spill must never fail the tool call. Record it
113
+ // and let the loop keep the full result inline.
114
+ process.stderr.write("[celestea-runtime] tool-result spill failed: " + (e instanceof Error ? e.message : String(e)) + "\n");
115
+ return null;
116
+ }
117
+ },
118
+ };
119
+ }