@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mcd0LUO
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # `@celestea/runtime` (L2 · 装配层)
2
+
3
+ 一句职责:**把插件按显式顺序挂进一个 `Context`,交回一个「已装配、可驱动、可换代」的引擎代(Gen)**。
4
+ 对应旧实现 `crates/runtime/{compose.rs,run.rs}` + `celestea_studio/src/main.rs` 的 statusline / 换代部分。
5
+
6
+ ```
7
+ core ← runtime → session / llm / tools / agent-loop / workers
8
+ ```
9
+
10
+ 依赖方向:**只向下**。runtime 位于 L2,可以依赖全部 L1,但**不 import 任何一个 L1 实现**——
11
+ 具体 agent loop 走 `ComposeConfig.loopFactory`,事件映射走 `frameMapper`,会话日志走
12
+ `sessionBinding`/插件,worker 日志工厂走 `workers.logFactory`。这样 P3 期间可以在
13
+ `packages/agent-loop` / `packages/tools` 并行开发的同时用 fake 验证装配层。
14
+
15
+ ## 公开 API(只从 `src/index.ts` 收口)
16
+
17
+ | 导出 | 作用 |
18
+ |---|---|
19
+ | `compose(config)` → `Runtime` | 组装一代引擎(见下方顺序) |
20
+ | `ComposeConfig` | profile + 插件列表 + loopFactory/frameMapper/usage/status/workers/shutdownHooks/now |
21
+ | `Runtime` | 句柄 + 生命周期:`runTurn` / `cancelTurn` / `statusline` / `rebind` / `shutdown` / `release` |
22
+ | `GenerationHub` / `Gen` / `createGen` / `migrateReceipts` | 热换代:原子翻转 + 回执迁移 + 旧代拆解 |
23
+ | `SessionBinding` / `createSessionBinding` / `bindSession` | 会话绑定与重绑(同一目录重建绑定) |
24
+ | `TurnRunner` / `LoopFactory` / `LoopBindings` / `TurnOptions` / `resolveOutcome` | 单轮驱动语义(单并发槽、取消、终态) |
25
+ | `StatusTracker` / `createStatusTracker` / `statuslineOf` / `estimatedContextChars` | steps / tokens_per_sec / context usage |
26
+ | `UsageTracker` / `createUsageTracker` / `usageStatus` / `cacheHitRatioRounded` | latest / total / cache_hit_ratio |
27
+ | `loopEventToFrame` / `TurnFrame` / `loopEventToSse` 等价物 | LoopEvent → SSE 帧(契约映射) |
28
+ | `agentConfigFromProfile` / `MIN_STEPS` | profile → `AgentConfig`(步数下限 4096) |
29
+ | `sanitizeProfile` / `sanitizeConfigJson` | 面向 `/api/config` 的消毒投影(白名单 + 出口脱敏) |
30
+ | `TURN_ABORT_SERVICE` / `TURN_SINK_SERVICE` / `USAGE_TRACKER_SERVICE` / `STATUS_TRACKER_SERVICE` / `HOST_SESSION_ID` | 运行时服务 token 与宿主会话 id |
31
+ | `TurnBusyError` / `RuntimeReleasedError` / `ComposeError` | 机器可读错误(409 / 410 / 装配失败) |
32
+
33
+ ## compose 顺序(顺序即语义,测试锁定)
34
+
35
+ 1. 运行时服务:`EVENT_BUS_SERVICE` / `USAGE_TRACKER_SERVICE` / `STATUS_TRACKER_SERVICE`;
36
+ 2. `sessionBinding`(若给):打开宿主日志并提供 `SESSION_LOG_SERVICE`;
37
+ 3. `config.plugins` 按序 mount(**后注册覆盖先注册**,测试可挂 fake 顶掉真实现);
38
+ 4. worker 接线:宿主没提供注册表时,**最后**挂默认 workers 插件(三个工具要落进已解析的 `ToolRegistry`);
39
+ 5. 解析 seam:`SessionLog`(必需)+ `LlmRegistry`/`ToolRegistry`/`AgentLoop`(可选,缺则 `null`);
40
+ 6. 驱动接线:把 Llm/ToolRegistry/AgentLoop 交给 worker 注册表(`spawn_worker` 才会被后台驱动),
41
+ 并把宿主会话登记进会话注册表(回执可寻址);
42
+ 7. 组装 `TurnRunner`(loopFactory + frameMapper + usage + 回执 drain)。
43
+
44
+ ## 生命周期语义
45
+
46
+ - **热换代**:`hub.swap(gen)` = ① 同步翻转 `this.gen = next`(读者要么拿到旧代、要么拿到新代,
47
+ 永远看不到混合状态)② 把旧代 mailbox 里 pending 的宿主回执迁到新代 ③ 再 shutdown + release 旧代。
48
+ - **会话重绑**:`runtime.rebind(binding)` 重新 `binding.open()`(**同一 session id + 同一目录**),
49
+ 重新 provide `SESSION_LOG_SERVICE`,并同步 runtime 的 session 句柄;turn 进行中重绑 → `TurnBusyError`。
50
+ - **shutdown**:**幂等 + 可重入**。停驱动(abort + join)→ 跑宿主 teardown hooks(杀进程)→
51
+ purge mailbox → clear 会话注册表。重复调用是 no-op,并发调用共享同一个 promise(hook 只跑一次)。
52
+ - **release**:显式断开强引用(`parts = null`),配合 workers 工具持 `WeakRef`,
53
+ 解开 `Runtime → ctx → ToolRegistry → worker tool → registry` 环,让被换代的代可被回收(W248)。
54
+ - **取消**:`runTurn(input, {signal})` 把调用方 signal 链进本轮 signal,注入 loop bindings
55
+ 与 turn scope(`TURN_ABORT_SERVICE`);`cancelTurn()` 取消进行中的轮次。
56
+
57
+ ## runTurn 语义
58
+
59
+ - **单并发 busy 槽**:进行中再发起 → `TurnBusyError`(409,`kind: "turn_busy"`),不排队。
60
+ - **回执注入**:轮次开始前先把宿主 mailbox 的 pending 消息按 FIFO 注入 session log
61
+ (`[from W1] …`),因此 worker 回执在宿主**下一轮真实可见**(W232)。
62
+ - **事件流**:loop 的每个 `LoopEvent` → 喂 `StatusTracker`(tool_call 记一步;text/thinking 记字符)
63
+ → 映射成一个 `TurnFrame`(SSE 名 + payload)→ 交给 `sink`。
64
+ - **终态**:以 session log 的 `turn_end` 为准(唯一真源);本轮没写 `turn_end` 时:
65
+ 抛错则向上抛(装配失败不是终态)、已取消则 `cancelled`、静默停止则 `interrupted`——
66
+ 撕裂的一轮**绝不**报成 `completed`。
67
+
68
+ ## StatusTracker / UsageTracker
69
+
70
+ - steps:一次 tool **call** 记一步(tool_result 不重复计,W263 口径);
71
+ - tokens_per_sec:text/thinking delta 字符的速率(近似 token 速率,~1:1),**只在本轮有流时段的区间上平均**
72
+ (相邻 delta 间隔 > `GAP_MS` = 1s 视为无流间断,不计入分母;每个区间下限 `MIN_ACTIVE_MS` = 1s);
73
+ 窗口有样本时给响应式的 5s 滑窗速率(W754),窗口空时(停顿 > 5s 或轮次结束)回落到**本轮活动区间均值**(W763,
74
+ `turnRate` / `turnSpanMs` / `pushTurnDelta`,区间按停顿压缩,内存 O(停顿次数) 而非 O(delta 数));
75
+ 只有本轮还没有任何 delta(TTFT)才是 0,`beginTurn()` 重置;时钟可注入(`now`);
76
+ - context usage:优先真实 usage(`latest().prompt_tokens > 0` → `estimated:false` /
77
+ `usage_prompt_tokens`),否则回退 session log 字符估算(`estimated:true` / `session_event_chars`);
78
+ - usage:`latest` + `total`,`cache_hit_ratio = cache_read / prompt_tokens`(clamp [0,1]、4 位小数、分母 0 时为 0)。
79
+
80
+ ## 接入真实实现(apps/studio 侧)
81
+
82
+ ```ts
83
+ const usage = new UsageTracker(); // 或 agent-loop 的同名 tracker(结构等价)
84
+ const runtime = compose({
85
+ profile,
86
+ plugins: [sessionPlugin, llmPlugin, toolsPlugin, agentLoopPlugin, /* … */],
87
+ loopFactory: ({ config, signal, sink, usage }) => new DefaultAgentLoop(config, { signal, sink, usage }),
88
+ frameMapper: loopEventToSse, // 可选:用 agent-loop 的映射
89
+ usage,
90
+ workers: { resultsDir: "results" },
91
+ shutdownHooks: [() => processRegistry.killAll()],
92
+ });
93
+ ```
94
+
95
+ ## 测试
96
+
97
+ `packages/runtime/src/*.test.ts`(61 例,全部用 fake:内存 SessionLog / 脚本化 AgentLoop /
98
+ 记录型 ToolRegistry / 队列型 Llm):
99
+
100
+ - `compose.test.ts`:挂载顺序与覆盖语义、worker 插件最后挂载并注册三工具、缺 session 报错、
101
+ agentConfig 推导、消毒 config 无密钥;
102
+ - `turn.test.ts`:事件→帧顺序、终态取自日志、单并发 409、取消传播(含 turn scope token)、
103
+ 宿主回执 FIFO 注入;
104
+ - `lifecycle.test.ts`:shutdown 幂等/可重入/hook 只跑一次、release 断引用、会话重绑(同目录)、
105
+ 换代无混合态(并发读者)、回执迁移、buildAndSwap;
106
+ - `status.test.ts`:steps 口径、速率(窗口 + 本轮活动区间均值,注入时钟)、context usage 真实/估算回退、cache_hit_ratio。
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Profile -> `AgentConfig` projection (`runtime/src/compose.rs:196-204`).
3
+ *
4
+ * The step cap is floored at [MIN_STEPS]: the engine loop runs
5
+ * `for step in 0..max_steps`, so `max_steps = 0` means ZERO steps (not
6
+ * unlimited). A profile that leaves the cap unset therefore gets a high cap
7
+ * instead of an engine that cannot take a single step (studio W218).
8
+ */
9
+ import { type AgentConfig } from "@celestea/core";
10
+ import type { Profile } from "./profile.js";
11
+ /** Step-cap floor: covers realistic long turns while still bounding runaway loops. */
12
+ export declare const MIN_STEPS = 4096;
13
+ /** Trim factor of the window that triggers old-message trimming. */
14
+ export declare const CONTEXT_TRIM_THRESHOLD = 0.8;
15
+ /** How many most-recent messages survive a trim (plus the system message). */
16
+ export declare const CONTEXT_KEEP_RECENT = 10;
17
+ /** Derive the loop configuration from a profile (identity prompt included). */
18
+ export declare function agentConfigFromProfile(profile: Profile, overrides?: Partial<AgentConfig>): AgentConfig;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Profile -> `AgentConfig` projection (`runtime/src/compose.rs:196-204`).
3
+ *
4
+ * The step cap is floored at [MIN_STEPS]: the engine loop runs
5
+ * `for step in 0..max_steps`, so `max_steps = 0` means ZERO steps (not
6
+ * unlimited). A profile that leaves the cap unset therefore gets a high cap
7
+ * instead of an engine that cannot take a single step (studio W218).
8
+ */
9
+ import { defaultAgentConfig } from "@celestea/core";
10
+ /** Step-cap floor: covers realistic long turns while still bounding runaway loops. */
11
+ export const MIN_STEPS = 4096;
12
+ /** Trim factor of the window that triggers old-message trimming. */
13
+ export const CONTEXT_TRIM_THRESHOLD = 0.8;
14
+ /** How many most-recent messages survive a trim (plus the system message). */
15
+ export const CONTEXT_KEEP_RECENT = 10;
16
+ /** Derive the loop configuration from a profile (identity prompt included). */
17
+ export function agentConfigFromProfile(profile, overrides = {}) {
18
+ const base = defaultAgentConfig();
19
+ const steps = profile.max_steps > 0 ? profile.max_steps : MIN_STEPS;
20
+ return {
21
+ ...base,
22
+ model: profile.model,
23
+ system_prompt: profile.system_prompt,
24
+ max_steps: steps,
25
+ max_parallel_tool_calls: profile.max_parallel_tool_calls,
26
+ context_window_tokens: profile.context_window_tokens,
27
+ context_trim_threshold: CONTEXT_TRIM_THRESHOLD,
28
+ context_keep_recent: CONTEXT_KEEP_RECENT,
29
+ ...overrides,
30
+ };
31
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Host auto-wake (W769) — the TS counterpart of the legacy `autowake_loop`
3
+ * (`crates/../src/main.rs:1044-1187`, semantics in
4
+ * `docs/DEVELOPMENT.md` §2.6).
5
+ *
6
+ * The problem it solves: a worker's completion receipt is delivered into its
7
+ * HOST session's mailbox, and the host only drains that mailbox at a turn
8
+ * boundary — so without a loop the receipt lies in the queue until the user
9
+ * happens to type something. Auto-wake is "delivery wakes the agent".
10
+ *
11
+ * The shape here is the legacy one, adapted to this repo's per-session
12
+ * generations:
13
+ * - the loop PARKS on the mailbox notification ([SessionMailbox.onQueued]) and
14
+ * rebinds to whatever generation is in force on every pass, so a rebuilt
15
+ * instance (a config/grant epoch bump) is picked up without a stale
16
+ * subscription — `mailbox()` is a hook, never a captured object;
17
+ * - a BUSY host leaves the message QUEUED (it is not drained) and retries on
18
+ * the legacy cadence (250ms): nothing is lost and nothing is consumed twice;
19
+ * - on wake it drains the WHOLE queue FIFO into one input
20
+ * (`[from <label>] <content>`, blank-line separated) and asks the host to run
21
+ * ONE ordinary turn — the host's own turn path, so SSE/status/audit are the
22
+ * manual ones (legacy note: "SSE 与手动 turn 完全一致");
23
+ * - hard errors are logged with a small backoff (500ms); the loop never
24
+ * throws, never panics and never spins hot;
25
+ * - `stop()` unparks immediately and is what a shutdown hook calls.
26
+ *
27
+ * The loop knows nothing about sessions, HTTP or workers: the host supplies the
28
+ * three facts it needs (mailbox, busy, wake).
29
+ */
30
+ import type { MailboxMessage, MailboxSendOptions, SessionMailbox } from "@celestea/workers";
31
+ /** Env knob: `0/off/false/no` (case-insensitive) disables auto-wake. */
32
+ export declare const ENV_AUTOWAKE = "CELESTEA_AUTOWAKE";
33
+ /** Busy-host retry cadence (the message stays queued). */
34
+ export declare const AUTOWAKE_BUSY_RETRY_MS = 250;
35
+ /** Backoff after a hard error / an unresolvable mailbox. */
36
+ export declare const AUTOWAKE_ERROR_BACKOFF_MS = 500;
37
+ /**
38
+ * Safety net for a notification that cannot arrive: a mailbox that appeared
39
+ * while the loop was parked (an instance REBUILD hands the session a brand-new
40
+ * mailbox, and the loop only subscribes to it on its next pass), or a queue that
41
+ * was refilled by something that is not this mailbox. The notification path is
42
+ * the mechanism (it wakes in the same tick); this floor only bounds how long a
43
+ * generation swap can delay a receipt — it is the TS stand-in for the legacy
44
+ * loop's `gen_epoch` watch, at the same cadence as the busy-retry.
45
+ */
46
+ export declare const AUTOWAKE_POLL_MS = 250;
47
+ /** `CELESTEA_AUTOWAKE`: default ON; the four literals turn it off. */
48
+ export declare function autowakeEnabled(env?: NodeJS.ProcessEnv): boolean;
49
+ /**
50
+ * The turn input of one wake: every drained message in FIFO order, labelled with
51
+ * its sender (the legacy loop joins with a blank line).
52
+ */
53
+ export declare function autowakeInput(messages: readonly MailboxMessage[]): string;
54
+ /** The mailbox surface the loop needs (structural: `SessionMailbox` satisfies it). */
55
+ export interface AutowakeMailbox {
56
+ onQueued(listener: (to: string, message: MailboxMessage) => void): () => void;
57
+ poll(to: string): MailboxMessage[];
58
+ pending(to: string): number;
59
+ send(to: string, content: string, fromLabel: string, opts?: MailboxSendOptions): MailboxMessage;
60
+ }
61
+ /** What the host must tell the loop (all three are read fresh on every pass). */
62
+ export interface AutowakeHooks {
63
+ /** Queue key of the host conversation in this generation (`cli-main` / the session id). */
64
+ queueKey: string;
65
+ /** The mailbox IN FORCE right now; null = no live generation to bind. */
66
+ mailbox: () => AutowakeMailbox | null;
67
+ /** Is the host currently running a turn? */
68
+ isBusy: () => boolean;
69
+ /**
70
+ * W855 (C8): messages the USER parked on the `next-turn` lane, read fresh on
71
+ * every pass. The loop only OBSERVES this count: it must never drain the lane
72
+ * (the turn-start `drainPending` is its single consumer), which is what makes
73
+ * double delivery structurally impossible. Absent/0 = the pre-C8 mailbox-only
74
+ * trigger.
75
+ */
76
+ userPending?: () => number;
77
+ /**
78
+ * Claim the slot and run ONE ordinary turn with `input`. Return `false` when
79
+ * the slot was taken in the window (the loop re-queues and retries); throwing
80
+ * is a hard error and is logged.
81
+ *
82
+ * W855 (C8): `null` means "wake for the user lane only" — the turn has no
83
+ * input of its own and the turn-start drain supplies the content.
84
+ */
85
+ wake: (input: string | null) => boolean;
86
+ /** Diagnostics sink (default: silent). */
87
+ log?: (line: string) => void;
88
+ }
89
+ /** Injectable timers so the loop is testable without sleeping. */
90
+ export interface AutowakeTiming {
91
+ setTimeout: (fn: () => void, ms: number) => unknown;
92
+ clearTimeout: (handle: unknown) => void;
93
+ }
94
+ export interface AutowakeOptions {
95
+ busyRetryMs?: number;
96
+ errorBackoffMs?: number;
97
+ pollMs?: number;
98
+ timing?: AutowakeTiming;
99
+ }
100
+ /** One auto-wake loop, bound to ONE host conversation. */
101
+ export declare class AutowakeLoop {
102
+ private readonly hooks;
103
+ private readonly busyRetryMs;
104
+ private readonly errorBackoffMs;
105
+ private readonly pollMs;
106
+ private readonly timing;
107
+ private stopped;
108
+ private notified;
109
+ private unpark;
110
+ private unsubscribe;
111
+ private bound;
112
+ private running;
113
+ /** Consecutive passes that could not hand the queue to a host (log throttle). */
114
+ private stalled;
115
+ constructor(hooks: AutowakeHooks, options?: AutowakeOptions);
116
+ get isStopped(): boolean;
117
+ /** Start the loop (idempotent). */
118
+ start(): void;
119
+ /**
120
+ * Stop for good: unbind, unpark and wait for the pass in flight to return.
121
+ * Safe to call twice; never rejects.
122
+ */
123
+ stop(): Promise<void>;
124
+ /** Test/diagnostic handle: the pass currently in flight (null when idle). */
125
+ get pass(): Promise<void> | null;
126
+ private loop;
127
+ /** Resolve the current mailbox and (re)subscribe when the generation moved. */
128
+ private bind;
129
+ private requeue;
130
+ private notify;
131
+ /** Park until the next notification or the safety-net timeout. */
132
+ private waitForWork;
133
+ private pause;
134
+ private park;
135
+ /** Log with the legacy loop's thrift: first attempt, then every 20th. */
136
+ private report;
137
+ }
138
+ /** Factory form (ARCHITECTURE.md §6.1). */
139
+ export declare function createAutowakeLoop(hooks: AutowakeHooks, options?: AutowakeOptions): AutowakeLoop;
140
+ /** Type guard-ish helper: the `SessionMailbox` of a runtime, when it has one. */
141
+ export declare function autowakeMailboxOf(mailbox: SessionMailbox | null | undefined): AutowakeMailbox | null;
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Host auto-wake (W769) — the TS counterpart of the legacy `autowake_loop`
3
+ * (`crates/../src/main.rs:1044-1187`, semantics in
4
+ * `docs/DEVELOPMENT.md` §2.6).
5
+ *
6
+ * The problem it solves: a worker's completion receipt is delivered into its
7
+ * HOST session's mailbox, and the host only drains that mailbox at a turn
8
+ * boundary — so without a loop the receipt lies in the queue until the user
9
+ * happens to type something. Auto-wake is "delivery wakes the agent".
10
+ *
11
+ * The shape here is the legacy one, adapted to this repo's per-session
12
+ * generations:
13
+ * - the loop PARKS on the mailbox notification ([SessionMailbox.onQueued]) and
14
+ * rebinds to whatever generation is in force on every pass, so a rebuilt
15
+ * instance (a config/grant epoch bump) is picked up without a stale
16
+ * subscription — `mailbox()` is a hook, never a captured object;
17
+ * - a BUSY host leaves the message QUEUED (it is not drained) and retries on
18
+ * the legacy cadence (250ms): nothing is lost and nothing is consumed twice;
19
+ * - on wake it drains the WHOLE queue FIFO into one input
20
+ * (`[from <label>] <content>`, blank-line separated) and asks the host to run
21
+ * ONE ordinary turn — the host's own turn path, so SSE/status/audit are the
22
+ * manual ones (legacy note: "SSE 与手动 turn 完全一致");
23
+ * - hard errors are logged with a small backoff (500ms); the loop never
24
+ * throws, never panics and never spins hot;
25
+ * - `stop()` unparks immediately and is what a shutdown hook calls.
26
+ *
27
+ * The loop knows nothing about sessions, HTTP or workers: the host supplies the
28
+ * three facts it needs (mailbox, busy, wake).
29
+ */
30
+ /** Env knob: `0/off/false/no` (case-insensitive) disables auto-wake. */
31
+ export const ENV_AUTOWAKE = "CELESTEA_AUTOWAKE";
32
+ /** Busy-host retry cadence (the message stays queued). */
33
+ export const AUTOWAKE_BUSY_RETRY_MS = 250;
34
+ /** Backoff after a hard error / an unresolvable mailbox. */
35
+ export const AUTOWAKE_ERROR_BACKOFF_MS = 500;
36
+ /**
37
+ * Safety net for a notification that cannot arrive: a mailbox that appeared
38
+ * while the loop was parked (an instance REBUILD hands the session a brand-new
39
+ * mailbox, and the loop only subscribes to it on its next pass), or a queue that
40
+ * was refilled by something that is not this mailbox. The notification path is
41
+ * the mechanism (it wakes in the same tick); this floor only bounds how long a
42
+ * generation swap can delay a receipt — it is the TS stand-in for the legacy
43
+ * loop's `gen_epoch` watch, at the same cadence as the busy-retry.
44
+ */
45
+ export const AUTOWAKE_POLL_MS = 250;
46
+ /** `CELESTEA_AUTOWAKE`: default ON; the four literals turn it off. */
47
+ export function autowakeEnabled(env = process.env) {
48
+ const raw = (env[ENV_AUTOWAKE] ?? "").trim().toLowerCase();
49
+ return raw !== "0" && raw !== "off" && raw !== "false" && raw !== "no";
50
+ }
51
+ /**
52
+ * The turn input of one wake: every drained message in FIFO order, labelled with
53
+ * its sender (the legacy loop joins with a blank line).
54
+ */
55
+ export function autowakeInput(messages) {
56
+ return messages.map((m) => (m.from_label === "" ? m.content : `[from ${m.from_label}] ${m.content}`)).join("\n\n");
57
+ }
58
+ const REAL_TIMING = {
59
+ setTimeout: (fn, ms) => {
60
+ const handle = setTimeout(fn, ms);
61
+ // The repo's timer rule: a PARKED loop must never keep the process alive
62
+ // (same as the watchdog's sweep and the idle reclaimer).
63
+ handle.unref?.();
64
+ return handle;
65
+ },
66
+ clearTimeout: (handle) => clearTimeout(handle),
67
+ };
68
+ /** One auto-wake loop, bound to ONE host conversation. */
69
+ export class AutowakeLoop {
70
+ hooks;
71
+ busyRetryMs;
72
+ errorBackoffMs;
73
+ pollMs;
74
+ timing;
75
+ stopped = false;
76
+ notified = false;
77
+ unpark = null;
78
+ unsubscribe = null;
79
+ bound = null;
80
+ running = null;
81
+ /** Consecutive passes that could not hand the queue to a host (log throttle). */
82
+ stalled = 0;
83
+ constructor(hooks, options = {}) {
84
+ this.hooks = hooks;
85
+ this.busyRetryMs = options.busyRetryMs ?? AUTOWAKE_BUSY_RETRY_MS;
86
+ this.errorBackoffMs = options.errorBackoffMs ?? AUTOWAKE_ERROR_BACKOFF_MS;
87
+ this.pollMs = options.pollMs ?? AUTOWAKE_POLL_MS;
88
+ this.timing = options.timing ?? REAL_TIMING;
89
+ }
90
+ get isStopped() {
91
+ return this.stopped;
92
+ }
93
+ /** Start the loop (idempotent). */
94
+ start() {
95
+ if (this.running !== null)
96
+ return;
97
+ this.running = this.loop();
98
+ }
99
+ /**
100
+ * Stop for good: unbind, unpark and wait for the pass in flight to return.
101
+ * Safe to call twice; never rejects.
102
+ */
103
+ async stop() {
104
+ this.stopped = true;
105
+ this.unsubscribe?.();
106
+ this.unsubscribe = null;
107
+ this.bound = null;
108
+ this.unpark?.();
109
+ const running = this.running;
110
+ this.running = null;
111
+ if (running !== null)
112
+ await running.catch(() => undefined);
113
+ }
114
+ /** Test/diagnostic handle: the pass currently in flight (null when idle). */
115
+ get pass() {
116
+ return this.running;
117
+ }
118
+ // --- the loop ----------------------------------------------------------
119
+ async loop() {
120
+ // Let the mounting host finish publishing the instance this loop belongs to
121
+ // (the studio mounts it from inside the generation's own build callback, so
122
+ // one microtask later the entry exists and the first bind succeeds).
123
+ await Promise.resolve();
124
+ while (!this.stopped) {
125
+ const mailbox = this.bind();
126
+ if (mailbox === null) {
127
+ this.report("no live generation to bind; retrying");
128
+ await this.pause(this.errorBackoffMs);
129
+ continue;
130
+ }
131
+ const userPending = this.hooks.userPending?.() ?? 0;
132
+ if (mailbox.pending(this.hooks.queueKey) === 0 && userPending === 0 && !this.notified) {
133
+ await this.waitForWork();
134
+ continue;
135
+ }
136
+ this.notified = false;
137
+ if (this.hooks.isBusy()) {
138
+ // Leave BOTH sources queued (the lane is not ours to drain) and retry.
139
+ await this.pause(this.busyRetryMs);
140
+ continue;
141
+ }
142
+ const drained = mailbox.poll(this.hooks.queueKey);
143
+ if (drained.length === 0 && userPending === 0)
144
+ continue;
145
+ // W855 (C8): the user lane is NEVER drained here — `drainPending` does it
146
+ // inside the turn. When only the lane has work the wake carries `null`
147
+ // ("no own input") and the drained lane message is the turn content.
148
+ const input = drained.length === 0 ? null : autowakeInput(drained);
149
+ let started = false;
150
+ try {
151
+ started = this.hooks.wake(input);
152
+ }
153
+ catch (error) {
154
+ // W855 (C8): a synchronous throw happens before the turn slot is
155
+ // claimed, so the drained messages were NOT consumed — hand them back
156
+ // instead of losing them (the old comment claimed the opposite).
157
+ this.stalled += 1;
158
+ this.report(`turn error: ${error instanceof Error ? error.message : String(error)}; ${drained.length} message(s) re-queued`);
159
+ this.requeue(drained);
160
+ await this.pause(this.errorBackoffMs);
161
+ continue;
162
+ }
163
+ if (started) {
164
+ this.stalled = 0;
165
+ continue;
166
+ }
167
+ // Lost the race for the turn slot: hand the SAME messages to whatever
168
+ // generation is in force NOW (never the one they were popped from) and
169
+ // retry — nothing is lost, nothing is consumed twice.
170
+ this.stalled += 1;
171
+ this.requeue(drained);
172
+ this.report(`host busy; ${drained.length} message(s) re-queued (attempt ${this.stalled})`);
173
+ await this.pause(this.busyRetryMs);
174
+ }
175
+ }
176
+ /** Resolve the current mailbox and (re)subscribe when the generation moved. */
177
+ bind() {
178
+ let mailbox = null;
179
+ try {
180
+ mailbox = this.hooks.mailbox();
181
+ }
182
+ catch (error) {
183
+ this.report(`mailbox lookup failed: ${error instanceof Error ? error.message : String(error)}`);
184
+ mailbox = null;
185
+ }
186
+ if (mailbox !== this.bound) {
187
+ this.unsubscribe?.();
188
+ this.unsubscribe = null;
189
+ this.bound = mailbox;
190
+ if (mailbox !== null) {
191
+ this.unsubscribe = mailbox.onQueued((to) => {
192
+ if (to === this.hooks.queueKey)
193
+ this.notify();
194
+ });
195
+ }
196
+ }
197
+ return mailbox;
198
+ }
199
+ requeue(messages) {
200
+ let mailbox = null;
201
+ try {
202
+ mailbox = this.hooks.mailbox();
203
+ }
204
+ catch {
205
+ mailbox = null;
206
+ }
207
+ if (mailbox === null) {
208
+ this.report("cannot re-queue: no live generation (messages stay consumed)");
209
+ return;
210
+ }
211
+ for (const message of messages) {
212
+ mailbox.send(this.hooks.queueKey, message.content, message.from_label, {
213
+ kind: message.kind,
214
+ source: message.source,
215
+ });
216
+ }
217
+ }
218
+ notify() {
219
+ this.notified = true;
220
+ this.unpark?.();
221
+ }
222
+ /** Park until the next notification or the safety-net timeout. */
223
+ waitForWork() {
224
+ if (this.notified) {
225
+ this.notified = false;
226
+ return Promise.resolve();
227
+ }
228
+ return this.park(this.pollMs);
229
+ }
230
+ pause(ms) {
231
+ if (this.stopped)
232
+ return Promise.resolve();
233
+ return this.park(ms);
234
+ }
235
+ park(ms) {
236
+ return new Promise((resolve) => {
237
+ const timer = this.timing.setTimeout(() => {
238
+ this.unpark = null;
239
+ resolve();
240
+ }, ms);
241
+ this.unpark = () => {
242
+ this.timing.clearTimeout(timer);
243
+ this.unpark = null;
244
+ resolve();
245
+ };
246
+ });
247
+ }
248
+ /** Log with the legacy loop's thrift: first attempt, then every 20th. */
249
+ report(line) {
250
+ if (this.stalled > 1 && this.stalled % 20 !== 0)
251
+ return;
252
+ this.hooks.log?.(line);
253
+ }
254
+ }
255
+ /** Factory form (ARCHITECTURE.md §6.1). */
256
+ export function createAutowakeLoop(hooks, options = {}) {
257
+ return new AutowakeLoop(hooks, options);
258
+ }
259
+ /** Type guard-ish helper: the `SessionMailbox` of a runtime, when it has one. */
260
+ export function autowakeMailboxOf(mailbox) {
261
+ return mailbox ?? null;
262
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `compact` — context compaction (`celestea_studio/src/compact.rs`).
3
+ *
4
+ * W259 semantics: only COMPLETE turns count, the newest K survive renumbered,
5
+ * and the pre-compaction log is always recoverable from the single-copy
6
+ * `cli-main.jsonl.precompact` backup. The summary itself is produced through
7
+ * the `Llm` seam (see ./summarize.ts), so this package needs no provider.
8
+ */
9
+ export * from "./plan.js";
10
+ export * from "./transcript.js";
11
+ export * from "./rewrite.js";
12
+ export * from "./summarize.js";
13
+ export * from "./run.js";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `compact` — context compaction (`celestea_studio/src/compact.rs`).
3
+ *
4
+ * W259 semantics: only COMPLETE turns count, the newest K survive renumbered,
5
+ * and the pre-compaction log is always recoverable from the single-copy
6
+ * `cli-main.jsonl.precompact` backup. The summary itself is produced through
7
+ * the `Llm` seam (see ./summarize.ts), so this package needs no provider.
8
+ */
9
+ export * from "./plan.js";
10
+ export * from "./transcript.js";
11
+ export * from "./rewrite.js";
12
+ export * from "./summarize.js";
13
+ export * from "./run.js";
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Compaction planning — port of `celestea_studio/src/compact.rs:60-190`.
3
+ *
4
+ * The plan is a pure function of (events, summary, keep):
5
+ * 1. split the log into COMPLETE turns (`turn_start ..= turn_end`); an
6
+ * unterminated tail and everything before the first `turn_start` are
7
+ * dropped — only a closed turn may enter the new log;
8
+ * 2. refuse to compact at or below [COMPACT_THRESHOLD] complete turns;
9
+ * 3. new log = one synthetic head turn (turn-1: the summary) + the last K
10
+ * complete turns, renumbered turn-2..turn-(K+1) but otherwise byte-identical
11
+ * (tool / thinking rows stay inside their turn, the outcome is preserved).
12
+ *
13
+ * The `turn-<n>` prefix is the engine-native turn id: `PersistentSessionLog`
14
+ * only recognises that prefix when it restores its counter, so renumbering is
15
+ * what keeps the next live turn id from colliding with what is on disk.
16
+ */
17
+ import type { SessionEvent } from "@celestea/core";
18
+ /** Complete turns at or below this count are "not enough history" to compact. */
19
+ export declare const COMPACT_THRESHOLD = 8;
20
+ /** How many most-recent complete turns survive a compaction. */
21
+ export declare const COMPACT_KEEP_TURNS = 4;
22
+ /** Head turn user message prefix (the summary is appended verbatim). */
23
+ export declare const COMPACT_HEAD_PREFIX = "\u3010\u4E0A\u4E0B\u6587\u538B\u7F29\u3011";
24
+ /** Head turn assistant message (fixed text, not model-generated). */
25
+ export declare const COMPACT_HEAD_ASSISTANT = "\u4E0A\u4E0B\u6587\u5DF2\u538B\u7F29\uFF0C\u4EE5\u4E0A\u4E3A\u5386\u53F2\u6458\u8981\u3002";
26
+ /** Note of the "nothing to do" branch. */
27
+ export declare const COMPACT_NOTE_SKIPPED = "\u5386\u53F2\u4E0D\u8DB3\uFF0C\u65E0\u9700\u538B\u7F29";
28
+ /** Note of the compacted branch (`已压缩:摘要轮 + 最近K轮`). */
29
+ export declare function compactNote(keep: number): string;
30
+ /** Engine-native turn id (`turn-<n>`). */
31
+ export declare function compactTurnId(n: number): string;
32
+ /**
33
+ * Cut the event stream into complete turns. A repeated/nested `turn_start`
34
+ * discards the previous unterminated fragment; rows before the first
35
+ * `turn_start` are dropped.
36
+ */
37
+ export declare function splitCompleteTurns(events: readonly SessionEvent[]): SessionEvent[][];
38
+ /** Number of complete turns (the threshold predicate). */
39
+ export declare function countCompleteTurns(events: readonly SessionEvent[]): number;
40
+ /**
41
+ * Replace a turn's boundary ids with `id`; every other row is copied verbatim,
42
+ * including the terminal outcome (renumbering is not a semantic rewrite).
43
+ */
44
+ export declare function renumberTurn(turn: readonly SessionEvent[], id: string): SessionEvent[];
45
+ /** The complete turns a compaction with `keep` preserves (never empty). */
46
+ export declare function keptTurns(events: readonly SessionEvent[], keep: number): SessionEvent[][];
47
+ /**
48
+ * The post-compaction event list, or null when the log is at/below the
49
+ * threshold (nothing to compact). `keep` is clamped to `[1, turn count]`.
50
+ */
51
+ export declare function planCompaction(events: readonly SessionEvent[], summary: string, keep: number): SessionEvent[] | null;