@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.
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/dist/agent-config.d.ts +18 -0
- package/dist/agent-config.js +31 -0
- package/dist/autowake.d.ts +141 -0
- package/dist/autowake.js +262 -0
- package/dist/compact/index.d.ts +13 -0
- package/dist/compact/index.js +13 -0
- package/dist/compact/plan.d.ts +51 -0
- package/dist/compact/plan.js +98 -0
- package/dist/compact/rewrite.d.ts +23 -0
- package/dist/compact/rewrite.js +79 -0
- package/dist/compact/run.d.ts +44 -0
- package/dist/compact/run.js +59 -0
- package/dist/compact/summarize.d.ts +30 -0
- package/dist/compact/summarize.js +70 -0
- package/dist/compact/transcript.d.ts +35 -0
- package/dist/compact/transcript.js +88 -0
- package/dist/compose.d.ts +117 -0
- package/dist/compose.js +191 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +34 -0
- package/dist/frames.d.ts +46 -0
- package/dist/frames.js +62 -0
- package/dist/gen.d.ts +86 -0
- package/dist/gen.js +129 -0
- package/dist/host/engine-session.d.ts +117 -0
- package/dist/host/engine-session.js +109 -0
- package/dist/host/index.d.ts +39 -0
- package/dist/host/index.js +39 -0
- package/dist/host/provider-target.d.ts +113 -0
- package/dist/host/provider-target.js +116 -0
- package/dist/inbox-checkpoint.d.ts +18 -0
- package/dist/inbox-checkpoint.js +37 -0
- package/dist/inbox.d.ts +94 -0
- package/dist/inbox.js +139 -0
- package/dist/index.d.ts +71 -0
- package/dist/index.js +71 -0
- package/dist/ledger-io.d.ts +27 -0
- package/dist/ledger-io.js +74 -0
- package/dist/ledger-llm.d.ts +48 -0
- package/dist/ledger-llm.js +115 -0
- package/dist/ledger-query.d.ts +91 -0
- package/dist/ledger-query.js +153 -0
- package/dist/ledger.d.ts +271 -0
- package/dist/ledger.js +444 -0
- package/dist/pricing.d.ts +100 -0
- package/dist/pricing.js +167 -0
- package/dist/profile.d.ts +26 -0
- package/dist/profile.js +39 -0
- package/dist/recovery.d.ts +56 -0
- package/dist/recovery.js +91 -0
- package/dist/retention.d.ts +49 -0
- package/dist/retention.js +119 -0
- package/dist/runtime.d.ts +197 -0
- package/dist/runtime.js +347 -0
- package/dist/sanitize.d.ts +35 -0
- package/dist/sanitize.js +36 -0
- package/dist/session-binding.d.ts +36 -0
- package/dist/session-binding.js +33 -0
- package/dist/session-registry.d.ts +238 -0
- package/dist/session-registry.js +388 -0
- package/dist/status.d.ts +279 -0
- package/dist/status.js +411 -0
- package/dist/tokens.d.ts +25 -0
- package/dist/tokens.js +25 -0
- package/dist/turn-runner.d.ts +169 -0
- package/dist/turn-runner.js +242 -0
- package/dist/usage.d.ts +64 -0
- package/dist/usage.js +88 -0
- package/dist/watchdog-mount.d.ts +79 -0
- package/dist/watchdog-mount.js +120 -0
- package/dist/worker-wiring.d.ts +74 -0
- package/dist/worker-wiring.js +107 -0
- package/package.json +31 -0
|
@@ -0,0 +1,98 @@
|
|
|
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 { clip, SUMMARY_KEEP_MAX_CHARS } from "./transcript.js";
|
|
18
|
+
/** Complete turns at or below this count are "not enough history" to compact. */
|
|
19
|
+
export const COMPACT_THRESHOLD = 8;
|
|
20
|
+
/** How many most-recent complete turns survive a compaction. */
|
|
21
|
+
export const COMPACT_KEEP_TURNS = 4;
|
|
22
|
+
/** Head turn user message prefix (the summary is appended verbatim). */
|
|
23
|
+
export const COMPACT_HEAD_PREFIX = "【上下文压缩】";
|
|
24
|
+
/** Head turn assistant message (fixed text, not model-generated). */
|
|
25
|
+
export const COMPACT_HEAD_ASSISTANT = "上下文已压缩,以上为历史摘要。";
|
|
26
|
+
/** Note of the "nothing to do" branch. */
|
|
27
|
+
export const COMPACT_NOTE_SKIPPED = "历史不足,无需压缩";
|
|
28
|
+
/** Note of the compacted branch (`已压缩:摘要轮 + 最近K轮`). */
|
|
29
|
+
export function compactNote(keep) {
|
|
30
|
+
return `已压缩:摘要轮 + 最近${keep}轮`;
|
|
31
|
+
}
|
|
32
|
+
/** Engine-native turn id (`turn-<n>`). */
|
|
33
|
+
export function compactTurnId(n) {
|
|
34
|
+
return `turn-${n}`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Cut the event stream into complete turns. A repeated/nested `turn_start`
|
|
38
|
+
* discards the previous unterminated fragment; rows before the first
|
|
39
|
+
* `turn_start` are dropped.
|
|
40
|
+
*/
|
|
41
|
+
export function splitCompleteTurns(events) {
|
|
42
|
+
const turns = [];
|
|
43
|
+
let current = null;
|
|
44
|
+
for (const ev of events) {
|
|
45
|
+
if (ev.type === "turn_start") {
|
|
46
|
+
current = [ev];
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (current === null)
|
|
50
|
+
continue; // orphan before the first turn_start
|
|
51
|
+
current.push(ev);
|
|
52
|
+
if (ev.type === "turn_end") {
|
|
53
|
+
turns.push(current);
|
|
54
|
+
current = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return turns;
|
|
58
|
+
}
|
|
59
|
+
/** Number of complete turns (the threshold predicate). */
|
|
60
|
+
export function countCompleteTurns(events) {
|
|
61
|
+
return splitCompleteTurns(events).length;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Replace a turn's boundary ids with `id`; every other row is copied verbatim,
|
|
65
|
+
* including the terminal outcome (renumbering is not a semantic rewrite).
|
|
66
|
+
*/
|
|
67
|
+
export function renumberTurn(turn, id) {
|
|
68
|
+
return turn.map((ev) => {
|
|
69
|
+
if (ev.type === "turn_start")
|
|
70
|
+
return { type: "turn_start", id };
|
|
71
|
+
if (ev.type === "turn_end")
|
|
72
|
+
return { type: "turn_end", id, ...(ev.outcome === undefined ? {} : { outcome: ev.outcome }) };
|
|
73
|
+
return ev;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** The complete turns a compaction with `keep` preserves (never empty). */
|
|
77
|
+
export function keptTurns(events, keep) {
|
|
78
|
+
const turns = splitCompleteTurns(events);
|
|
79
|
+
const k = Math.max(1, Math.min(keep, turns.length));
|
|
80
|
+
return turns.slice(turns.length - k);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The post-compaction event list, or null when the log is at/below the
|
|
84
|
+
* threshold (nothing to compact). `keep` is clamped to `[1, turn count]`.
|
|
85
|
+
*/
|
|
86
|
+
export function planCompaction(events, summary, keep) {
|
|
87
|
+
if (countCompleteTurns(events) <= COMPACT_THRESHOLD)
|
|
88
|
+
return null;
|
|
89
|
+
const head = compactTurnId(1);
|
|
90
|
+
const out = [
|
|
91
|
+
{ type: "turn_start", id: head },
|
|
92
|
+
{ type: "user_message", text: `${COMPACT_HEAD_PREFIX}${clip(summary.trim(), SUMMARY_KEEP_MAX_CHARS)}`, origin: "compact" },
|
|
93
|
+
{ type: "assistant_message", text: COMPACT_HEAD_ASSISTANT },
|
|
94
|
+
{ type: "turn_end", id: head, outcome: "completed" },
|
|
95
|
+
];
|
|
96
|
+
keptTurns(events, keep).forEach((turn, i) => out.push(...renumberTurn(turn, compactTurnId(i + 2))));
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic log rewrite (port of `celestea_studio/src/compact.rs:344-390`).
|
|
3
|
+
*
|
|
4
|
+
* The order is the durability contract:
|
|
5
|
+
* 1. the CURRENT file is copied to `cli-main.jsonl.precompact` (single copy,
|
|
6
|
+
* overwritten) — the pre-compaction history is always recoverable;
|
|
7
|
+
* 2. the new log is written to a same-directory `cli-main.jsonl.tmp-<pid>` and
|
|
8
|
+
* fsynced (a temp file on another device would make `rename` a copy);
|
|
9
|
+
* 3. `rename` replaces the log atomically: a concurrent reader sees either the
|
|
10
|
+
* whole old log or the whole new one, never a half-written file.
|
|
11
|
+
*
|
|
12
|
+
* A failed rename removes the temp file, so a retry cannot be poisoned by a
|
|
13
|
+
* stale partial write.
|
|
14
|
+
*/
|
|
15
|
+
import { type SessionEvent } from "@celestea/core";
|
|
16
|
+
/** Single-copy backup of the pre-compaction log. */
|
|
17
|
+
export declare const COMPACT_BACKUP_FILE = "cli-main.jsonl.precompact";
|
|
18
|
+
/** Same-directory temp prefix (rename must not cross devices). */
|
|
19
|
+
export declare const COMPACT_TMP_PREFIX = "cli-main.jsonl.tmp-";
|
|
20
|
+
/** The log as one JSONL record per event, every record newline-terminated. */
|
|
21
|
+
export declare function serializeEventLog(events: readonly SessionEvent[]): string;
|
|
22
|
+
/** Rewrite `path` with `events` (backup + fsync + atomic rename + dir fsync). */
|
|
23
|
+
export declare function rewriteAtomic(path: string, events: readonly SessionEvent[], pid?: number): void;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic log rewrite (port of `celestea_studio/src/compact.rs:344-390`).
|
|
3
|
+
*
|
|
4
|
+
* The order is the durability contract:
|
|
5
|
+
* 1. the CURRENT file is copied to `cli-main.jsonl.precompact` (single copy,
|
|
6
|
+
* overwritten) — the pre-compaction history is always recoverable;
|
|
7
|
+
* 2. the new log is written to a same-directory `cli-main.jsonl.tmp-<pid>` and
|
|
8
|
+
* fsynced (a temp file on another device would make `rename` a copy);
|
|
9
|
+
* 3. `rename` replaces the log atomically: a concurrent reader sees either the
|
|
10
|
+
* whole old log or the whole new one, never a half-written file.
|
|
11
|
+
*
|
|
12
|
+
* A failed rename removes the temp file, so a retry cannot be poisoned by a
|
|
13
|
+
* stale partial write.
|
|
14
|
+
*/
|
|
15
|
+
import { closeSync, copyFileSync, existsSync, fsyncSync, openSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { serializeSessionEvent } from "@celestea/core";
|
|
18
|
+
/** Single-copy backup of the pre-compaction log. */
|
|
19
|
+
export const COMPACT_BACKUP_FILE = "cli-main.jsonl.precompact";
|
|
20
|
+
/** Same-directory temp prefix (rename must not cross devices). */
|
|
21
|
+
export const COMPACT_TMP_PREFIX = "cli-main.jsonl.tmp-";
|
|
22
|
+
/** The log as one JSONL record per event, every record newline-terminated. */
|
|
23
|
+
export function serializeEventLog(events) {
|
|
24
|
+
let text = "";
|
|
25
|
+
for (const ev of events)
|
|
26
|
+
text += `${serializeSessionEvent(ev)}\n`;
|
|
27
|
+
return text;
|
|
28
|
+
}
|
|
29
|
+
/** Copy the current log to the backup path (only when it exists). */
|
|
30
|
+
function backupCurrent(path, backup) {
|
|
31
|
+
if (existsSync(path))
|
|
32
|
+
copyFileSync(path, backup);
|
|
33
|
+
}
|
|
34
|
+
/** Write `text` to `tmp` and fsync it before it can replace anything. */
|
|
35
|
+
function writeDurable(tmp, text) {
|
|
36
|
+
writeFileSync(tmp, text, "utf8");
|
|
37
|
+
const fd = openSync(tmp, "r+");
|
|
38
|
+
try {
|
|
39
|
+
fsyncSync(fd);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
closeSync(fd);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* fsync the PARENT directory so the rename itself is durable (P2-6, W836):
|
|
47
|
+
* the tmp file's own fsync orders its CONTENT, but only the directory fsync
|
|
48
|
+
* orders the directory ENTRY, which is what a power loss could otherwise undo.
|
|
49
|
+
*/
|
|
50
|
+
function fsyncDirectory(dir) {
|
|
51
|
+
try {
|
|
52
|
+
const fd = openSync(dir, "r");
|
|
53
|
+
try {
|
|
54
|
+
fsyncSync(fd);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
closeSync(fd);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Best effort: a filesystem that cannot fsync a directory must not fail a
|
|
62
|
+
// compaction (the content was already fsynced through the tmp file).
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Rewrite `path` with `events` (backup + fsync + atomic rename + dir fsync). */
|
|
66
|
+
export function rewriteAtomic(path, events, pid = process.pid) {
|
|
67
|
+
const dir = dirname(path);
|
|
68
|
+
const tmp = join(dir, `${COMPACT_TMP_PREFIX}${pid}`);
|
|
69
|
+
backupCurrent(path, join(dir, COMPACT_BACKUP_FILE));
|
|
70
|
+
writeDurable(tmp, serializeEventLog(events));
|
|
71
|
+
try {
|
|
72
|
+
renameSync(tmp, path);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
rmSync(tmp, { force: true });
|
|
76
|
+
throw e instanceof Error ? e : new Error(String(e));
|
|
77
|
+
}
|
|
78
|
+
fsyncDirectory(dir);
|
|
79
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction orchestration (port of `celestea_studio/src/compact.rs:410-500`).
|
|
3
|
+
*
|
|
4
|
+
* One call is: read -> parse -> threshold -> summarize -> plan -> atomic rewrite.
|
|
5
|
+
* Every branch is explicit and observable, because the HTTP layer has to answer
|
|
6
|
+
* three different ways:
|
|
7
|
+
* - `compacted:false` (at/below [COMPACT_THRESHOLD] complete turns) is a
|
|
8
|
+
* NORMAL 200 with the frozen "历史不足,无需压缩" note;
|
|
9
|
+
* - `compacted:true` carries `kept_turns` and the "已压缩…" note;
|
|
10
|
+
* - a read/summary/write failure throws — the caller turns it into a 500 with
|
|
11
|
+
* the message (never a silent no-op that left the log untouched).
|
|
12
|
+
*
|
|
13
|
+
* Parsing mirrors the host's `parse_session_jsonl`: blank lines are padding and
|
|
14
|
+
* parsing STOPS at the first unparsable record (a torn tail is not content).
|
|
15
|
+
*/
|
|
16
|
+
import { type SessionEvent } from "@celestea/core";
|
|
17
|
+
import type { Summarizer } from "./summarize.js";
|
|
18
|
+
export interface CompactionInput {
|
|
19
|
+
/** Absolute path of the session log (`<session dir>/cli-main.jsonl`). */
|
|
20
|
+
logPath: string;
|
|
21
|
+
summarize: Summarizer;
|
|
22
|
+
/** Surviving complete turns (default [COMPACT_KEEP_TURNS]). */
|
|
23
|
+
keep?: number;
|
|
24
|
+
/** Injection seams for tests (defaults: real fs). */
|
|
25
|
+
readText?: (path: string) => string;
|
|
26
|
+
write?: (path: string, events: readonly SessionEvent[]) => void;
|
|
27
|
+
}
|
|
28
|
+
export interface CompactionResult {
|
|
29
|
+
compacted: boolean;
|
|
30
|
+
/** Present (as a number) only when `compacted === true`. */
|
|
31
|
+
kept_turns: number | null;
|
|
32
|
+
note: string;
|
|
33
|
+
/** Complete turns found in the log BEFORE the decision. */
|
|
34
|
+
turns_before: number;
|
|
35
|
+
/** The new event list, or null when nothing was compacted. */
|
|
36
|
+
events: SessionEvent[] | null;
|
|
37
|
+
}
|
|
38
|
+
/** JSONL text -> events; blank lines are padding, a torn tail stops parsing. */
|
|
39
|
+
export declare function parseEventLog(text: string): SessionEvent[];
|
|
40
|
+
/**
|
|
41
|
+
* Run one compaction. Throws on read/summarize/write failure; returns the
|
|
42
|
+
* skipped branch when the history is too short to be worth a summary request.
|
|
43
|
+
*/
|
|
44
|
+
export declare function runCompaction(input: CompactionInput): Promise<CompactionResult>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction orchestration (port of `celestea_studio/src/compact.rs:410-500`).
|
|
3
|
+
*
|
|
4
|
+
* One call is: read -> parse -> threshold -> summarize -> plan -> atomic rewrite.
|
|
5
|
+
* Every branch is explicit and observable, because the HTTP layer has to answer
|
|
6
|
+
* three different ways:
|
|
7
|
+
* - `compacted:false` (at/below [COMPACT_THRESHOLD] complete turns) is a
|
|
8
|
+
* NORMAL 200 with the frozen "历史不足,无需压缩" note;
|
|
9
|
+
* - `compacted:true` carries `kept_turns` and the "已压缩…" note;
|
|
10
|
+
* - a read/summary/write failure throws — the caller turns it into a 500 with
|
|
11
|
+
* the message (never a silent no-op that left the log untouched).
|
|
12
|
+
*
|
|
13
|
+
* Parsing mirrors the host's `parse_session_jsonl`: blank lines are padding and
|
|
14
|
+
* parsing STOPS at the first unparsable record (a torn tail is not content).
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { parseSessionEvent } from "@celestea/core";
|
|
18
|
+
import { COMPACT_KEEP_TURNS, COMPACT_NOTE_SKIPPED, compactNote, countCompleteTurns, planCompaction } from "./plan.js";
|
|
19
|
+
import { rewriteAtomic } from "./rewrite.js";
|
|
20
|
+
import { renderTranscript } from "./transcript.js";
|
|
21
|
+
/** JSONL text -> events; blank lines are padding, a torn tail stops parsing. */
|
|
22
|
+
export function parseEventLog(text) {
|
|
23
|
+
const events = [];
|
|
24
|
+
for (const line of text.split("\n")) {
|
|
25
|
+
if (line.trim() === "")
|
|
26
|
+
continue;
|
|
27
|
+
const parsed = parseSessionEvent(line.trim());
|
|
28
|
+
if (!parsed.ok)
|
|
29
|
+
break;
|
|
30
|
+
events.push(parsed.event);
|
|
31
|
+
}
|
|
32
|
+
return events;
|
|
33
|
+
}
|
|
34
|
+
function readLog(input) {
|
|
35
|
+
try {
|
|
36
|
+
return (input.readText ?? ((p) => readFileSync(p, "utf8")))(input.logPath);
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
throw new Error(`读取会话日志失败:${e instanceof Error ? e.message : String(e)}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Run one compaction. Throws on read/summarize/write failure; returns the
|
|
44
|
+
* skipped branch when the history is too short to be worth a summary request.
|
|
45
|
+
*/
|
|
46
|
+
export async function runCompaction(input) {
|
|
47
|
+
const keep = input.keep ?? COMPACT_KEEP_TURNS;
|
|
48
|
+
const events = parseEventLog(readLog(input));
|
|
49
|
+
const turns = countCompleteTurns(events);
|
|
50
|
+
if (turns <= 0 || planCompaction(events, "", keep) === null) {
|
|
51
|
+
return { compacted: false, kept_turns: null, note: COMPACT_NOTE_SKIPPED, turns_before: turns, events: null };
|
|
52
|
+
}
|
|
53
|
+
const summary = await input.summarize(renderTranscript(events));
|
|
54
|
+
const planned = planCompaction(events, summary, keep);
|
|
55
|
+
if (planned === null)
|
|
56
|
+
throw new Error("内部错误:压缩计划为空");
|
|
57
|
+
(input.write ?? rewriteAtomic)(input.logPath, planned);
|
|
58
|
+
return { compacted: true, kept_turns: Math.min(keep, turns), note: compactNote(keep), turns_before: turns, events: planned };
|
|
59
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Summary generation for compaction, over the `Llm` SEAM
|
|
3
|
+
* (port of `celestea_studio/src/compact.rs:270-340`).
|
|
4
|
+
*
|
|
5
|
+
* The host does not talk HTTP here: it asks the same `Llm` the engine uses, so
|
|
6
|
+
* compose owns base_url / key / request format and this module stays free of
|
|
7
|
+
* provider knowledge (and of any sibling-package import).
|
|
8
|
+
*
|
|
9
|
+
* Failure model: a broken stream, a provider failure or an empty answer is an
|
|
10
|
+
* ERROR (never an empty summary), because a compaction that silently loses the
|
|
11
|
+
* history is worse than no compaction at all. A total timeout bounds the call.
|
|
12
|
+
*/
|
|
13
|
+
import { type Llm, type LlmStream, type ModelRequest } from "@celestea/core";
|
|
14
|
+
/** Turns a transcript into a summary; throws on failure. */
|
|
15
|
+
export type Summarizer = (transcript: string) => Promise<string>;
|
|
16
|
+
export interface LlmSummarizerOptions {
|
|
17
|
+
llm: Llm;
|
|
18
|
+
model: string;
|
|
19
|
+
/** System prompt override (default: the frozen four-section contract). */
|
|
20
|
+
system?: string;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
}
|
|
23
|
+
/** The compaction request for one transcript. */
|
|
24
|
+
export declare function summaryRequest(model: string, transcript: string, system?: string): ModelRequest;
|
|
25
|
+
/** Reject when `promise` does not settle within `ms` (whole-call timeout). */
|
|
26
|
+
export declare function withTimeout<T>(promise: Promise<T>, ms: number, what: string): Promise<T>;
|
|
27
|
+
/** Concatenate the text deltas; `failed` / `interrupted` / empty are errors. */
|
|
28
|
+
export declare function collectSummaryText(stream: LlmStream): Promise<string>;
|
|
29
|
+
/** A [Summarizer] backed by any `Llm` implementation. */
|
|
30
|
+
export declare function llmSummarizer(opts: LlmSummarizerOptions): Summarizer;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Summary generation for compaction, over the `Llm` SEAM
|
|
3
|
+
* (port of `celestea_studio/src/compact.rs:270-340`).
|
|
4
|
+
*
|
|
5
|
+
* The host does not talk HTTP here: it asks the same `Llm` the engine uses, so
|
|
6
|
+
* compose owns base_url / key / request format and this module stays free of
|
|
7
|
+
* provider knowledge (and of any sibling-package import).
|
|
8
|
+
*
|
|
9
|
+
* Failure model: a broken stream, a provider failure or an empty answer is an
|
|
10
|
+
* ERROR (never an empty summary), because a compaction that silently loses the
|
|
11
|
+
* history is worse than no compaction at all. A total timeout bounds the call.
|
|
12
|
+
*/
|
|
13
|
+
import { userMessage } from "@celestea/core";
|
|
14
|
+
import { COMPACT_SYSTEM_PROMPT, SUMMARY_MAX_TOKENS, SUMMARY_TIMEOUT_MS } from "./transcript.js";
|
|
15
|
+
/** The compaction request for one transcript. */
|
|
16
|
+
export function summaryRequest(model, transcript, system = COMPACT_SYSTEM_PROMPT) {
|
|
17
|
+
return { model, system, messages: [userMessage(transcript)], tools: [], max_tokens: SUMMARY_MAX_TOKENS, temperature: null };
|
|
18
|
+
}
|
|
19
|
+
/** Reject when `promise` does not settle within `ms` (whole-call timeout). */
|
|
20
|
+
export async function withTimeout(promise, ms, what) {
|
|
21
|
+
let timer;
|
|
22
|
+
const timeout = new Promise((_, reject) => {
|
|
23
|
+
timer = setTimeout(() => reject(new Error(`${what} timeout after ${ms}ms`)), ms);
|
|
24
|
+
});
|
|
25
|
+
try {
|
|
26
|
+
return await Promise.race([promise, timeout]);
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
if (timer !== undefined)
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Concatenate the text deltas; `failed` / `interrupted` / empty are errors. */
|
|
34
|
+
export async function collectSummaryText(stream) {
|
|
35
|
+
let text = "";
|
|
36
|
+
for await (const ev of stream) {
|
|
37
|
+
if (ev.kind === "text") {
|
|
38
|
+
text += ev.text;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (ev.kind === "failed")
|
|
42
|
+
throw new Error(`摘要请求失败:${ev.message}`);
|
|
43
|
+
if (ev.kind === "interrupted")
|
|
44
|
+
throw new Error("摘要请求中断(流未给出终态)");
|
|
45
|
+
if (ev.kind === "done")
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
if (text.trim() === "")
|
|
49
|
+
throw new Error("摘要响应为空");
|
|
50
|
+
return text;
|
|
51
|
+
}
|
|
52
|
+
/** A [Summarizer] backed by any `Llm` implementation. */
|
|
53
|
+
export function llmSummarizer(opts) {
|
|
54
|
+
const timeoutMs = opts.timeoutMs ?? SUMMARY_TIMEOUT_MS;
|
|
55
|
+
return async (transcript) => {
|
|
56
|
+
const req = summaryRequest(opts.model, transcript, opts.system ?? COMPACT_SYSTEM_PROMPT);
|
|
57
|
+
let stream;
|
|
58
|
+
try {
|
|
59
|
+
stream = await withTimeout(opts.llm.generate(req), timeoutMs, "摘要请求");
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
throw new Error(`摘要请求失败:${e instanceof Error ? e.message : String(e)}`);
|
|
63
|
+
}
|
|
64
|
+
const text = await withTimeout(collectSummaryText(stream), timeoutMs, "摘要流读取");
|
|
65
|
+
const trimmed = text.trim();
|
|
66
|
+
if (trimmed === "")
|
|
67
|
+
throw new Error("摘要响应缺少正文");
|
|
68
|
+
return trimmed;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Summary-input rendering + the compaction prompt
|
|
3
|
+
* (port of `celestea_studio/src/compact.rs:34-54,180-262`).
|
|
4
|
+
*
|
|
5
|
+
* The transcript handed to the summarising model is a flat, human-readable
|
|
6
|
+
* rendering of the event stream (turn headings + per-role lines), clipped twice:
|
|
7
|
+
* per event (so one huge tool result cannot eat the budget) and as a whole,
|
|
8
|
+
* keeping the TAIL (recent history matters more than the opening pleasantries).
|
|
9
|
+
*
|
|
10
|
+
* All clipping is CHARACTER-wise, never byte-wise: a CJK transcript would
|
|
11
|
+
* otherwise be cut mid-code-point and the summary request would be invalid UTF-8.
|
|
12
|
+
*/
|
|
13
|
+
import { type ImageRef, type SessionEvent } from "@celestea/core";
|
|
14
|
+
/** Per-event clip inside the transcript (tool results / texts). */
|
|
15
|
+
export declare const TRANSCRIPT_EVENT_MAX_CHARS = 4000;
|
|
16
|
+
/** Whole-transcript clip (~60k chars) before it is sent to the model. */
|
|
17
|
+
export declare const SUMMARY_INPUT_MAX_CHARS = 60000;
|
|
18
|
+
/** How much summary text is kept in the synthetic head turn. */
|
|
19
|
+
export declare const SUMMARY_KEEP_MAX_CHARS = 20000;
|
|
20
|
+
/** `max_tokens` of the summarising request. */
|
|
21
|
+
export declare const SUMMARY_MAX_TOKENS = 4096;
|
|
22
|
+
/** Whole-request timeout of the summarising call. */
|
|
23
|
+
export declare const SUMMARY_TIMEOUT_MS = 90000;
|
|
24
|
+
/** The four-section structured summary prompt (verbatim contract text). */
|
|
25
|
+
export declare const COMPACT_SYSTEM_PROMPT: string;
|
|
26
|
+
/** Character-wise clip with a truncation marker (`clip`). */
|
|
27
|
+
export declare function clip(s: string, max: number): string;
|
|
28
|
+
/** Keep the TAIL of a text, with a leading marker when it was cut (`clip_tail`). */
|
|
29
|
+
export declare function clipTail(s: string, max: number): string;
|
|
30
|
+
/** One event's transcript line (`render_transcript`'s match arms). */
|
|
31
|
+
export declare function transcriptLine(ev: SessionEvent): string;
|
|
32
|
+
/** W804: a byte-free placeholder for each attachment (summary input only). */
|
|
33
|
+
export declare function attachmentNote(refs: readonly ImageRef[] | undefined): string;
|
|
34
|
+
/** The whole summary input: every event line, then one tail clip. */
|
|
35
|
+
export declare function renderTranscript(events: readonly SessionEvent[], max?: number): string;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Summary-input rendering + the compaction prompt
|
|
3
|
+
* (port of `celestea_studio/src/compact.rs:34-54,180-262`).
|
|
4
|
+
*
|
|
5
|
+
* The transcript handed to the summarising model is a flat, human-readable
|
|
6
|
+
* rendering of the event stream (turn headings + per-role lines), clipped twice:
|
|
7
|
+
* per event (so one huge tool result cannot eat the budget) and as a whole,
|
|
8
|
+
* keeping the TAIL (recent history matters more than the opening pleasantries).
|
|
9
|
+
*
|
|
10
|
+
* All clipping is CHARACTER-wise, never byte-wise: a CJK transcript would
|
|
11
|
+
* otherwise be cut mid-code-point and the summary request would be invalid UTF-8.
|
|
12
|
+
*/
|
|
13
|
+
import { serdeJsonString } from "@celestea/core";
|
|
14
|
+
/** Per-event clip inside the transcript (tool results / texts). */
|
|
15
|
+
export const TRANSCRIPT_EVENT_MAX_CHARS = 4_000;
|
|
16
|
+
/** Whole-transcript clip (~60k chars) before it is sent to the model. */
|
|
17
|
+
export const SUMMARY_INPUT_MAX_CHARS = 60_000;
|
|
18
|
+
/** How much summary text is kept in the synthetic head turn. */
|
|
19
|
+
export const SUMMARY_KEEP_MAX_CHARS = 20_000;
|
|
20
|
+
/** `max_tokens` of the summarising request. */
|
|
21
|
+
export const SUMMARY_MAX_TOKENS = 4_096;
|
|
22
|
+
/** Whole-request timeout of the summarising call. */
|
|
23
|
+
export const SUMMARY_TIMEOUT_MS = 90_000;
|
|
24
|
+
/** The four-section structured summary prompt (verbatim contract text). */
|
|
25
|
+
export const COMPACT_SYSTEM_PROMPT = "你是上下文压缩器。把用户提供的会话记录压缩成一份中文结构化摘要," +
|
|
26
|
+
"必须且只需包含以下四个小节(保留小节标题):\n" +
|
|
27
|
+
"1) 正在进行的任务:当前目标、所处阶段、尚未完成的部分。\n" +
|
|
28
|
+
"2) 已做的决策:已经确定的技术/方案选择及其理由,包括被否决的方案。\n" +
|
|
29
|
+
"3) 关键事实与文件改动:涉及的文件路径、函数/接口名、配置项、数据结论、报错信息等可复用的硬事实。\n" +
|
|
30
|
+
"4) 待办:接下来要做的事,按优先级排列。\n" +
|
|
31
|
+
"要求:忠于原始记录,不得编造;保留路径、标识符、数字、命令原样;压缩冗余寒暄与重复内容;直接输出摘要正文,不要任何前言、结语或解释。";
|
|
32
|
+
/** Character-wise clip with a truncation marker (`clip`). */
|
|
33
|
+
export function clip(s, max) {
|
|
34
|
+
const chars = [...s];
|
|
35
|
+
if (chars.length <= max)
|
|
36
|
+
return s;
|
|
37
|
+
return `${chars.slice(0, max).join("")}…(截断)`;
|
|
38
|
+
}
|
|
39
|
+
/** Keep the TAIL of a text, with a leading marker when it was cut (`clip_tail`). */
|
|
40
|
+
export function clipTail(s, max) {
|
|
41
|
+
const chars = [...s];
|
|
42
|
+
if (chars.length <= max)
|
|
43
|
+
return s;
|
|
44
|
+
return `(更早内容已截断,仅保留最近 ${max} 字符)\n${chars.slice(chars.length - max).join("")}`;
|
|
45
|
+
}
|
|
46
|
+
/** One event's transcript line (`render_transcript`'s match arms). */
|
|
47
|
+
export function transcriptLine(ev) {
|
|
48
|
+
const quarter = TRANSCRIPT_EVENT_MAX_CHARS / 4;
|
|
49
|
+
switch (ev.type) {
|
|
50
|
+
case "turn_start":
|
|
51
|
+
return `\n--- 轮次 ${ev.id} ---\n`;
|
|
52
|
+
case "turn_end":
|
|
53
|
+
return "";
|
|
54
|
+
case "user_message":
|
|
55
|
+
// W804: attachments enter the summary as placeholders, NEVER as bytes.
|
|
56
|
+
return `【用户】${clip(ev.text, TRANSCRIPT_EVENT_MAX_CHARS)}${attachmentNote(ev.attachments)}\n`;
|
|
57
|
+
case "assistant_message":
|
|
58
|
+
return `【助手】${clip(ev.text, TRANSCRIPT_EVENT_MAX_CHARS)}\n`;
|
|
59
|
+
case "thinking_delta":
|
|
60
|
+
return `【思考】${clip(ev.text, quarter)}\n`;
|
|
61
|
+
case "tool_call":
|
|
62
|
+
return `【工具调用】${ev.name}(${clip(serdeJsonString(ev.args ?? null), quarter)})\n`;
|
|
63
|
+
case "tool_result":
|
|
64
|
+
return ev.error === null
|
|
65
|
+
? `【工具结果】${clip(serdeJsonString(ev.value ?? null), quarter)}\n`
|
|
66
|
+
: `【工具结果】错误:${clip(ev.error, quarter)}\n`;
|
|
67
|
+
// W783: a question and its answer are host-side audit rows about a PAUSED
|
|
68
|
+
// turn. The transcript already carries what the model saw — the ordinary
|
|
69
|
+
// `tool_result` of `ask_user_question` — so projecting them too would
|
|
70
|
+
// summarise the same decision twice.
|
|
71
|
+
case "user_question":
|
|
72
|
+
case "user_answer":
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** W804: a byte-free placeholder for each attachment (summary input only). */
|
|
77
|
+
export function attachmentNote(refs) {
|
|
78
|
+
if (refs === undefined || refs.length === 0)
|
|
79
|
+
return "";
|
|
80
|
+
return refs.map((ref) => `【图:${ref.media_type} ${ref.width}x${ref.height}】`).join("");
|
|
81
|
+
}
|
|
82
|
+
/** The whole summary input: every event line, then one tail clip. */
|
|
83
|
+
export function renderTranscript(events, max = SUMMARY_INPUT_MAX_CHARS) {
|
|
84
|
+
let out = "";
|
|
85
|
+
for (const ev of events)
|
|
86
|
+
out += transcriptLine(ev);
|
|
87
|
+
return clipTail(out, max);
|
|
88
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compose — the composition root of `packages/runtime`
|
|
3
|
+
* (`crates/runtime/src/compose.rs:74-234`).
|
|
4
|
+
*
|
|
5
|
+
* Assembly order is SEMANTICS, not taste (ARCHITECTURE.md §3.2), so it is
|
|
6
|
+
* explicit and tested:
|
|
7
|
+
*
|
|
8
|
+
* 1. runtime services event bus, usage accounting, status tracker;
|
|
9
|
+
* 2. session binding `sessionBinding` (if given) opens the host log;
|
|
10
|
+
* 3. host plugins `config.plugins` in order — a later `provide` of a
|
|
11
|
+
* token REPLACES an earlier one (patch semantics, so
|
|
12
|
+
* a test can mount a fake over a real implementation);
|
|
13
|
+
* 4. worker wiring mount the default workers plugin only when the host
|
|
14
|
+
* did not provide a registry (worker tools must land
|
|
15
|
+
* in the tool registry, hence last);
|
|
16
|
+
* 4b. watchdog W740: mount the liveness watchdog over the resolved
|
|
17
|
+
* worker registry and keep its stop handle, so the
|
|
18
|
+
* sweep timer dies with `shutdown`/`release`;
|
|
19
|
+
* 5. seam resolution session (required) + llm / tools / agentLoop
|
|
20
|
+
* (optional, and `null` when no plugin provides them);
|
|
21
|
+
* 6. driver attach hand Llm/ToolRegistry/AgentLoop to the worker
|
|
22
|
+
* registry so `spawn_worker` is driven, not merely
|
|
23
|
+
* registered, and register the host conversation so
|
|
24
|
+
* receipts have an address;
|
|
25
|
+
* 7. turn runner bind the per-turn loop factory, sink mapper, usage
|
|
26
|
+
* accounting and receipt drain into one driver.
|
|
27
|
+
*
|
|
28
|
+
* Everything the runtime needs beyond `core` is injected: the concrete agent
|
|
29
|
+
* loop arrives as a `loopFactory`, the frame mapper as `frameMapper`, the worker
|
|
30
|
+
* log factory as `workers.logFactory`. That is what keeps this layer free of
|
|
31
|
+
* L1 implementation imports (and lets P3 tests drive it with fakes).
|
|
32
|
+
*/
|
|
33
|
+
import { type AgentConfig, type Plugin } from "@celestea/core";
|
|
34
|
+
import { type ToolResultRetention } from "@celestea/agent-loop";
|
|
35
|
+
import { type FrameMapper } from "./frames.js";
|
|
36
|
+
import type { Profile } from "./profile.js";
|
|
37
|
+
import { Runtime, type ShutdownHook } from "./runtime.js";
|
|
38
|
+
import { type SessionBinding } from "./session-binding.js";
|
|
39
|
+
import { type StatusTracker } from "./status.js";
|
|
40
|
+
import type { TurnLedgerHooks } from "./ledger.js";
|
|
41
|
+
import type { TurnContextRow } from "./turn-runner.js";
|
|
42
|
+
import { type LoopFactory } from "./turn-runner.js";
|
|
43
|
+
import { type UsageAccounting } from "./usage.js";
|
|
44
|
+
import type { PendingInjection } from "@celestea/core";
|
|
45
|
+
import { type SessionInbox } from "./inbox.js";
|
|
46
|
+
import { type WorkerHost, type WorkerWiring } from "./worker-wiring.js";
|
|
47
|
+
import { type MountedWatchdog, type WatchdogMountSettings } from "./watchdog-mount.js";
|
|
48
|
+
export interface ComposeConfig {
|
|
49
|
+
profile: Profile;
|
|
50
|
+
/** Seam providers, mounted in order (later wins). */
|
|
51
|
+
plugins?: readonly Plugin[];
|
|
52
|
+
/** Host conversation binding (dir + log opener); a rebind reuses it. */
|
|
53
|
+
sessionBinding?: SessionBinding;
|
|
54
|
+
/** Loop budget overrides (defaults derive from the profile). */
|
|
55
|
+
agentConfig?: Partial<AgentConfig>;
|
|
56
|
+
/** Concrete agent loop per turn; absent = `AGENT_LOOP_SERVICE` from the Context. */
|
|
57
|
+
loopFactory?: LoopFactory;
|
|
58
|
+
/** LoopEvent -> SSE frame mapping; defaults to the contract mapping. */
|
|
59
|
+
frameMapper?: FrameMapper;
|
|
60
|
+
/** Shared usage accounting (pass the loop's own tracker to share one object). */
|
|
61
|
+
usage?: UsageAccounting;
|
|
62
|
+
/** Shared statusline tracker (steps + rate window). */
|
|
63
|
+
status?: StatusTracker;
|
|
64
|
+
/**
|
|
65
|
+
* Usage ledger turn hooks (W728 §3 P0): pass the session's `UsageLedger` so
|
|
66
|
+
* every turn books a `turn_total` row. Absent = no ledgering in this
|
|
67
|
+
* generation (the default; the studio host wires one).
|
|
68
|
+
*/
|
|
69
|
+
ledger?: TurnLedgerHooks;
|
|
70
|
+
/** Worker orchestration wiring; `false` disables it. */
|
|
71
|
+
workers?: WorkerWiring | false;
|
|
72
|
+
/**
|
|
73
|
+
* W740: the liveness watchdog over this generation's worker registry.
|
|
74
|
+
* `false` never mounts it; a partial object overrides the resolved settings
|
|
75
|
+
* (`autostart: false` mounts the sweep but leaves the cadence to the caller,
|
|
76
|
+
* which is how tests drive `tick()` by hand); omitted = the environment
|
|
77
|
+
* (`celesteaWatchdogSettings(config.env)`, on by default).
|
|
78
|
+
*/
|
|
79
|
+
watchdog?: Partial<WatchdogMountSettings> | false;
|
|
80
|
+
/** Process environment the watchdog settings are read from. */
|
|
81
|
+
env?: NodeJS.ProcessEnv;
|
|
82
|
+
/** Mid-turn injection queue (default: a fresh one per generation). */
|
|
83
|
+
inbox?: SessionInbox;
|
|
84
|
+
/**
|
|
85
|
+
* W515 §2: every message that LEAVES a lane (or the host mailbox) is reported
|
|
86
|
+
* with the boundary that consumed it, so the host can publish
|
|
87
|
+
* `placement: "context"` (the message is now model-visible) over SSE.
|
|
88
|
+
*/
|
|
89
|
+
onInjected?: (messages: readonly PendingInjection[], boundary: "turn-start" | "step") => void;
|
|
90
|
+
/**
|
|
91
|
+
* W884: durable, engine-owned turn context (the skill catalog, name +
|
|
92
|
+
* description only). Called once per turn start; each row is appended to the
|
|
93
|
+
* log as user-role history BEFORE the receipts and the input. `[]` = nothing
|
|
94
|
+
* (a workspace without skills pays nothing). The provider is the HOST's,
|
|
95
|
+
* because only the host knows the session's workspace (W768).
|
|
96
|
+
*/
|
|
97
|
+
turnContext?: () => readonly TurnContextRow[];
|
|
98
|
+
/** Host teardown hooks (process kills) — run once, in order, by `shutdown`. */
|
|
99
|
+
shutdownHooks?: readonly ShutdownHook[];
|
|
100
|
+
/** Injectable clock (status tracker rate window). */
|
|
101
|
+
now?: () => number;
|
|
102
|
+
/**
|
|
103
|
+
* W855: tool-result retention policy. Absent = built from the environment
|
|
104
|
+
* and the session directory (`<dir>/spills/`), so the host gets the default
|
|
105
|
+
* without wiring anything; `null` explicitly disables it.
|
|
106
|
+
*/
|
|
107
|
+
retention?: ToolResultRetention | null;
|
|
108
|
+
}
|
|
109
|
+
/** Compose one engine generation. Throws [ComposeError] on a missing seam. */
|
|
110
|
+
export declare function compose(config: ComposeConfig): Runtime;
|
|
111
|
+
/**
|
|
112
|
+
* The plugin set that was mounted, in mount order (order is contract): the host
|
|
113
|
+
* plugins, then the workers plugin (when this root mounted it), then the W740
|
|
114
|
+
* watchdog — which is always LAST, because it may only adjudicate rows a fully
|
|
115
|
+
* mounted worker registry already owns.
|
|
116
|
+
*/
|
|
117
|
+
export declare function pluginNamesOf(plugins: readonly Plugin[], workerHost: WorkerHost | null, mounted?: MountedWatchdog | null): string[];
|