@downcity/agent 1.1.228 → 1.1.232
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/bin/agent/local/Agent.d.ts +6 -1
- package/bin/agent/local/Agent.d.ts.map +1 -1
- package/bin/agent/local/Agent.js +30 -3
- package/bin/agent/local/Agent.js.map +1 -1
- package/bin/agent/local/services/AgentSessions.d.ts +23 -0
- package/bin/agent/local/services/AgentSessions.d.ts.map +1 -1
- package/bin/agent/local/services/AgentSessions.js +44 -0
- package/bin/agent/local/services/AgentSessions.js.map +1 -1
- package/bin/agent/remote/transports/HttpRemoteAgentTransport.d.ts.map +1 -1
- package/bin/agent/remote/transports/HttpRemoteAgentTransport.js +0 -7
- package/bin/agent/remote/transports/HttpRemoteAgentTransport.js.map +1 -1
- package/bin/executor/Executor.d.ts +25 -0
- package/bin/executor/Executor.d.ts.map +1 -1
- package/bin/executor/Executor.js +36 -0
- package/bin/executor/Executor.js.map +1 -1
- package/bin/executor/composer/compaction/SessionCompactionComposer.d.ts +4 -0
- package/bin/executor/composer/compaction/SessionCompactionComposer.d.ts.map +1 -1
- package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.d.ts +0 -1
- package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.d.ts.map +1 -1
- package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.js +15 -11
- package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.js.map +1 -1
- package/bin/executor/core-engine/CoreEngineRunner.d.ts +11 -0
- package/bin/executor/core-engine/CoreEngineRunner.d.ts.map +1 -1
- package/bin/executor/core-engine/CoreEngineRunner.js +12 -8
- package/bin/executor/core-engine/CoreEngineRunner.js.map +1 -1
- package/bin/executor/store/history/SessionHistoryStore.d.ts +3 -13
- package/bin/executor/store/history/SessionHistoryStore.d.ts.map +1 -1
- package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js +1 -1
- package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js.map +1 -1
- package/bin/executor/tools/plugin/PluginToolBridge.d.ts.map +1 -1
- package/bin/executor/tools/plugin/PluginToolBridge.js +4 -2
- package/bin/executor/tools/plugin/PluginToolBridge.js.map +1 -1
- package/bin/index.d.ts +2 -1
- package/bin/index.d.ts.map +1 -1
- package/bin/index.js +1 -1
- package/bin/index.js.map +1 -1
- package/bin/model/CityModelAdapter.d.ts +6 -0
- package/bin/model/CityModelAdapter.d.ts.map +1 -1
- package/bin/model/CityModelAdapter.js +13 -0
- package/bin/model/CityModelAdapter.js.map +1 -1
- package/bin/plugin/core/PluginRegistry.d.ts +22 -1
- package/bin/plugin/core/PluginRegistry.d.ts.map +1 -1
- package/bin/plugin/core/PluginRegistry.js +46 -4
- package/bin/plugin/core/PluginRegistry.js.map +1 -1
- package/bin/session/Session.d.ts +11 -1
- package/bin/session/Session.d.ts.map +1 -1
- package/bin/session/Session.js +76 -11
- package/bin/session/Session.js.map +1 -1
- package/bin/session/browse/Browse.d.ts +0 -8
- package/bin/session/browse/Browse.d.ts.map +1 -1
- package/bin/session/browse/Browse.js +72 -57
- package/bin/session/browse/Browse.js.map +1 -1
- package/bin/session/recorder/JsonlSessionMessageStore.d.ts +69 -16
- package/bin/session/recorder/JsonlSessionMessageStore.d.ts.map +1 -1
- package/bin/session/recorder/JsonlSessionMessageStore.js +283 -60
- package/bin/session/recorder/JsonlSessionMessageStore.js.map +1 -1
- package/bin/session/recorder/SessionRecorder.d.ts +20 -2
- package/bin/session/recorder/SessionRecorder.d.ts.map +1 -1
- package/bin/session/recorder/SessionRecorder.js +91 -46
- package/bin/session/recorder/SessionRecorder.js.map +1 -1
- package/bin/session/recorder/SessionRecorderCompaction.d.ts +3 -3
- package/bin/session/recorder/SessionRecorderCompaction.d.ts.map +1 -1
- package/bin/session/recorder/SessionRecorderCompaction.js +50 -62
- package/bin/session/recorder/SessionRecorderCompaction.js.map +1 -1
- package/bin/session/recorder/SessionRecorderHistoryStore.d.ts +2 -2
- package/bin/session/recorder/SessionRecorderHistoryStore.d.ts.map +1 -1
- package/bin/session/recorder/SessionRecorderHistoryStore.js +31 -36
- package/bin/session/recorder/SessionRecorderHistoryStore.js.map +1 -1
- package/bin/session/runtime/SessionPromptRuntime.d.ts +17 -1
- package/bin/session/runtime/SessionPromptRuntime.d.ts.map +1 -1
- package/bin/session/runtime/SessionPromptRuntime.js +67 -8
- package/bin/session/runtime/SessionPromptRuntime.js.map +1 -1
- package/bin/session/services/SessionStateService.d.ts +15 -1
- package/bin/session/services/SessionStateService.d.ts.map +1 -1
- package/bin/session/services/SessionStateService.js +60 -39
- package/bin/session/services/SessionStateService.js.map +1 -1
- package/bin/session/services/SessionTurnService.d.ts +5 -0
- package/bin/session/services/SessionTurnService.d.ts.map +1 -1
- package/bin/session/services/SessionTurnService.js +17 -3
- package/bin/session/services/SessionTurnService.js.map +1 -1
- package/bin/session/services/SessionViewService.d.ts +1 -5
- package/bin/session/services/SessionViewService.d.ts.map +1 -1
- package/bin/session/services/SessionViewService.js +4 -34
- package/bin/session/services/SessionViewService.js.map +1 -1
- package/bin/session/storage/Paths.d.ts +3 -15
- package/bin/session/storage/Paths.d.ts.map +1 -1
- package/bin/session/storage/Paths.js +5 -21
- package/bin/session/storage/Paths.js.map +1 -1
- package/bin/types/agent/SessionTypes.d.ts +2 -24
- package/bin/types/agent/SessionTypes.d.ts.map +1 -1
- package/bin/types/executor/SessionRunContext.d.ts +21 -0
- package/bin/types/executor/SessionRunContext.d.ts.map +1 -1
- package/bin/types/plugin/PluginRuntime.d.ts +25 -0
- package/bin/types/plugin/PluginRuntime.d.ts.map +1 -1
- package/bin/types/runtime/agent/AgentContext.d.ts +17 -5
- package/bin/types/runtime/agent/AgentContext.d.ts.map +1 -1
- package/bin/types/runtime/agent/AgentContext.js +29 -6
- package/bin/types/runtime/agent/AgentContext.js.map +1 -1
- package/bin/types/session/SessionConfigMutation.d.ts +68 -0
- package/bin/types/session/SessionConfigMutation.d.ts.map +1 -0
- package/bin/types/session/SessionConfigMutation.js +10 -0
- package/bin/types/session/SessionConfigMutation.js.map +1 -0
- package/bin/types/session/SessionLocalState.d.ts +8 -0
- package/bin/types/session/SessionLocalState.d.ts.map +1 -1
- package/bin/types/session/SessionMessage.d.ts +16 -12
- package/bin/types/session/SessionMessage.d.ts.map +1 -1
- package/bin/types/session/SessionMutation.d.ts +1 -1
- package/bin/types/session/SessionMutation.js +1 -1
- package/bin/types/session/SessionOptions.d.ts +16 -0
- package/bin/types/session/SessionOptions.d.ts.map +1 -1
- package/bin/types/session/SessionSegment.d.ts +57 -0
- package/bin/types/session/SessionSegment.d.ts.map +1 -0
- package/bin/types/session/SessionSegment.js +8 -0
- package/bin/types/session/SessionSegment.js.map +1 -0
- package/package.json +3 -3
- package/scripts/city-model-tool-loop.test.mjs +32 -0
- package/scripts/session-action-message.test.mjs +59 -13
- package/scripts/session-compaction-context-window.test.mjs +95 -0
- package/scripts/session-config-turn-boundary.test.mjs +224 -0
- package/scripts/session-list-title.test.mjs +21 -3
- package/scripts/session-prompt-runtime.test.mjs +85 -0
- package/scripts/session-recorder.test.mjs +340 -11
- package/src/agent/local/Agent.ts +34 -3
- package/src/agent/local/services/AgentSessions.ts +61 -0
- package/src/agent/remote/transports/HttpRemoteAgentTransport.ts +0 -5
- package/src/executor/Executor.ts +62 -0
- package/src/executor/composer/compaction/SessionCompactionComposer.ts +5 -0
- package/src/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.ts +16 -14
- package/src/executor/core-engine/CoreEngineRunner.ts +24 -8
- package/src/executor/store/history/SessionHistoryStore.ts +3 -14
- package/src/executor/store/history/jsonl/JsonlSessionHistoryStore.ts +1 -1
- package/src/executor/tools/plugin/PluginToolBridge.ts +4 -2
- package/src/index.ts +8 -0
- package/src/model/CityModelAdapter.ts +15 -0
- package/src/plugin/core/PluginRegistry.ts +72 -4
- package/src/session/Session.ts +89 -11
- package/src/session/browse/Browse.ts +73 -69
- package/src/session/recorder/JsonlSessionMessageStore.ts +378 -60
- package/src/session/recorder/SessionRecorder.ts +116 -49
- package/src/session/recorder/SessionRecorderCompaction.ts +55 -80
- package/src/session/recorder/SessionRecorderHistoryStore.ts +29 -49
- package/src/session/runtime/SessionPromptRuntime.ts +100 -9
- package/src/session/services/SessionStateService.ts +79 -44
- package/src/session/services/SessionTurnService.ts +19 -3
- package/src/session/services/SessionViewService.ts +4 -50
- package/src/session/storage/Paths.ts +5 -38
- package/src/types/agent/SessionTypes.ts +2 -24
- package/src/types/executor/SessionRunContext.ts +25 -0
- package/src/types/plugin/PluginRuntime.ts +27 -0
- package/src/types/runtime/agent/AgentContext.ts +32 -7
- package/src/types/session/SessionConfigMutation.ts +78 -0
- package/src/types/session/SessionLocalState.ts +9 -0
- package/src/types/session/SessionMessage.ts +16 -12
- package/src/types/session/SessionMutation.ts +1 -1
- package/src/types/session/SessionOptions.ts +21 -0
- package/src/types/session/SessionSegment.ts +62 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Session Message 的 Active + Segment JSONL 存储。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Active 保存上次 Compact 后产生的真实 Message;Compact 把 Active 前缀写入按
|
|
5
|
+
* sequence 范围命名的不可变 Segment,并在 Segment 末尾追加累计 Summary footer。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import fs from "fs-extra";
|
|
@@ -10,80 +10,195 @@ import { open, readFile, stat } from "node:fs/promises";
|
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import { generateId } from "@/utils/Id.js";
|
|
12
12
|
import type { SessionAssistantMessage, SessionMessage } from "@/types/session/SessionMessage.js";
|
|
13
|
+
import type {
|
|
14
|
+
SessionMessageStorageStats,
|
|
15
|
+
SessionSegmentRange,
|
|
16
|
+
SessionSegmentSnapshot,
|
|
17
|
+
SessionSegmentSummary,
|
|
18
|
+
} from "@/types/session/SessionSegment.js";
|
|
13
19
|
|
|
14
|
-
/** JSONL
|
|
20
|
+
/** Active JSONL 存储构造参数。 */
|
|
15
21
|
export interface JsonlSessionMessageStoreOptions {
|
|
16
22
|
/** 当前 Session 标识。 */
|
|
17
23
|
session_id: string;
|
|
18
|
-
/**
|
|
24
|
+
/** active.jsonl 的绝对或相对路径。 */
|
|
19
25
|
file_path: string;
|
|
20
|
-
/** Assistant 运行中快照路径,默认与
|
|
26
|
+
/** Assistant 运行中快照路径,默认与 active.jsonl 同目录。 */
|
|
21
27
|
assistant_message_file_path?: string;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
/** 在写锁内计算新 Message 所需的状态。 */
|
|
25
31
|
export interface SessionMessageCommitState {
|
|
26
|
-
/**
|
|
32
|
+
/** 下一条真实 Message 的全局线性顺序。 */
|
|
27
33
|
message_sequence: number;
|
|
28
|
-
/**
|
|
34
|
+
/** 当前 Active 与 Assistant 草稿组成的运行态 Message。 */
|
|
29
35
|
messages: SessionMessage[];
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
/**
|
|
38
|
+
/** Compact 提交参数。 */
|
|
39
|
+
export interface CompactActiveMessagesInput {
|
|
40
|
+
/** 移入新 Segment 的最后一条真实 Message sequence。 */
|
|
41
|
+
through_sequence: number;
|
|
42
|
+
/** 写在新 Segment 文件末尾的累计 Summary。 */
|
|
43
|
+
summary: SessionSegmentSummary;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Compact 提交结果。 */
|
|
47
|
+
export interface CompactActiveMessagesResult {
|
|
48
|
+
/** 新创建的不可变 Segment。 */
|
|
49
|
+
segment: SessionSegmentSnapshot;
|
|
50
|
+
/** Compact 后继续留在 Active 的真实 Message。 */
|
|
51
|
+
active_messages: SessionMessage[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const SEGMENT_FILE_PATTERN = /^(\d+)-(\d+)\.jsonl$/;
|
|
55
|
+
const SEQUENCE_FILE_WIDTH = 12;
|
|
56
|
+
|
|
57
|
+
/** Active + Segment JSONL 存储。 */
|
|
33
58
|
export class JsonlSessionMessageStore {
|
|
34
59
|
readonly session_id: string;
|
|
35
|
-
readonly
|
|
60
|
+
readonly active_file_path: string;
|
|
36
61
|
readonly assistant_message_file_path: string;
|
|
62
|
+
readonly segments_dir_path: string;
|
|
63
|
+
|
|
37
64
|
private readonly lock_file_path: string;
|
|
38
65
|
|
|
39
66
|
constructor(options: JsonlSessionMessageStoreOptions) {
|
|
40
67
|
this.session_id = String(options.session_id || "").trim();
|
|
41
|
-
this.
|
|
68
|
+
this.active_file_path = path.resolve(options.file_path);
|
|
42
69
|
this.assistant_message_file_path = path.resolve(
|
|
43
70
|
options.assistant_message_file_path ||
|
|
44
|
-
path.join(path.dirname(this.
|
|
71
|
+
path.join(path.dirname(this.active_file_path), "assistant_message.json"),
|
|
45
72
|
);
|
|
46
|
-
this.
|
|
73
|
+
this.segments_dir_path = path.join(path.dirname(this.active_file_path), "segments");
|
|
74
|
+
this.lock_file_path = `${this.active_file_path}.lock`;
|
|
47
75
|
if (!this.session_id) throw new Error("JsonlSessionMessageStore requires session_id");
|
|
48
76
|
}
|
|
49
77
|
|
|
50
|
-
/**
|
|
78
|
+
/** 创建 Active 与 Segment 布局,并清理已经完成的遗留草稿。 */
|
|
51
79
|
async initialize(): Promise<void> {
|
|
52
|
-
await this.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
80
|
+
await this.with_write_lock(async () => {
|
|
81
|
+
const ranges = await this.list_segment_ranges();
|
|
82
|
+
const folded_messages = await this.read_folded_active_messages_unsafe();
|
|
83
|
+
const latest_segment_end = ranges.at(-1)?.end_sequence || 0;
|
|
84
|
+
const messages = folded_messages.filter(
|
|
85
|
+
(message) => message.sequence > latest_segment_end,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Compact 先落 Segment 再覆盖 Active。若进程在两步之间退出,重启时按
|
|
89
|
+
// Segment sequence 边界清除 Active 重叠前缀,保证不丢消息且上下文不重复。
|
|
90
|
+
if (messages.length !== folded_messages.length) {
|
|
91
|
+
await this.write_active_messages_unsafe(messages);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const draft = await this.read_assistant_message();
|
|
95
|
+
if (draft && draft.session_id !== this.session_id) {
|
|
96
|
+
throw new Error("assistant_message.json session_id mismatch");
|
|
97
|
+
}
|
|
98
|
+
const finalized = draft && messages.find(
|
|
99
|
+
(message) => message.message_id === draft.message_id,
|
|
100
|
+
);
|
|
101
|
+
const effective_draft = draft && finalized?.type === "assistant" &&
|
|
102
|
+
finalized.revision >= draft.revision &&
|
|
103
|
+
finalized.status !== "streaming"
|
|
104
|
+
? null
|
|
105
|
+
: draft;
|
|
106
|
+
this.validate_runtime_sequence(
|
|
107
|
+
effective_draft ? [...messages, effective_draft] : messages,
|
|
108
|
+
latest_segment_end,
|
|
109
|
+
);
|
|
110
|
+
if (
|
|
111
|
+
draft &&
|
|
112
|
+
finalized?.type === "assistant" &&
|
|
113
|
+
finalized.revision >= draft.revision &&
|
|
114
|
+
finalized.status !== "streaming"
|
|
115
|
+
) {
|
|
116
|
+
await fs.remove(this.assistant_message_file_path);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
62
119
|
}
|
|
63
120
|
|
|
64
|
-
/** 读取
|
|
121
|
+
/** 读取 Active 和当前 Assistant 草稿,并按 revision 折叠。 */
|
|
65
122
|
async list_messages(): Promise<SessionMessage[]> {
|
|
66
123
|
await this.ensure_layout();
|
|
67
|
-
const by_id = new Map
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const value = line.trim();
|
|
71
|
-
if (!value) continue;
|
|
72
|
-
try {
|
|
73
|
-
const message = JSON.parse(value) as SessionMessage;
|
|
74
|
-
this.validate_message(message);
|
|
75
|
-
const previous = by_id.get(message.message_id);
|
|
76
|
-
if (!previous || message.revision > previous.revision) by_id.set(message.message_id, message);
|
|
77
|
-
} catch (error) {
|
|
78
|
-
throw new Error(`Invalid session message at line ${String(index + 1)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
124
|
+
const by_id = new Map(
|
|
125
|
+
(await this.read_active_messages_unsafe()).map((message) => [message.message_id, message]),
|
|
126
|
+
);
|
|
81
127
|
const draft = await this.read_assistant_message();
|
|
82
128
|
if (draft) {
|
|
83
129
|
const previous = by_id.get(draft.message_id);
|
|
84
130
|
if (!previous || draft.revision > previous.revision) by_id.set(draft.message_id, draft);
|
|
85
131
|
}
|
|
86
|
-
|
|
132
|
+
const messages = [...by_id.values()].sort(compare_message_sequence);
|
|
133
|
+
const ranges = await this.list_segment_ranges();
|
|
134
|
+
this.validate_runtime_sequence(messages, ranges.at(-1)?.end_sequence || 0);
|
|
135
|
+
return messages;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 读取指定 sequence 之前最近的完整 Segment。 */
|
|
139
|
+
async read_segment_before(before_sequence: number): Promise<SessionSegmentSnapshot | null> {
|
|
140
|
+
const boundary = Number(before_sequence);
|
|
141
|
+
if (!Number.isFinite(boundary)) return null;
|
|
142
|
+
const ranges = await this.list_segment_ranges();
|
|
143
|
+
const range = [...ranges].reverse().find((item) => item.end_sequence < boundary);
|
|
144
|
+
return range ? await this.read_segment(range) : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 读取最新已关闭 Segment 的累计 Summary。 */
|
|
148
|
+
async read_latest_summary(): Promise<SessionSegmentSummary | null> {
|
|
149
|
+
const ranges = await this.list_segment_ranges();
|
|
150
|
+
const latest = ranges[ranges.length - 1];
|
|
151
|
+
return latest ? (await this.read_segment(latest)).summary : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 读取全部真实历史,供 Fork 等明确需要完整复制的操作使用。 */
|
|
155
|
+
async list_history_messages(): Promise<SessionMessage[]> {
|
|
156
|
+
const ranges = await this.list_segment_ranges();
|
|
157
|
+
const messages: SessionMessage[] = [];
|
|
158
|
+
for (const range of ranges) {
|
|
159
|
+
messages.push(...(await this.read_segment(range)).messages);
|
|
160
|
+
}
|
|
161
|
+
messages.push(...(await this.list_messages()));
|
|
162
|
+
return messages.sort(compare_message_sequence);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 读取当前存储统计,不解析与当前操作无关的旧 Segment 正文。 */
|
|
166
|
+
async stats(): Promise<SessionMessageStorageStats> {
|
|
167
|
+
const ranges = await this.list_segment_ranges();
|
|
168
|
+
const active_messages = await this.list_messages();
|
|
169
|
+
const latest_range = ranges[ranges.length - 1];
|
|
170
|
+
const latest_message = active_messages[active_messages.length - 1] ||
|
|
171
|
+
(latest_range ? (await this.read_segment(latest_range)).messages.at(-1) : null) ||
|
|
172
|
+
null;
|
|
173
|
+
const paths = [this.active_file_path, ...ranges.map((range) => range.file_path)];
|
|
174
|
+
const sizes = await Promise.all(
|
|
175
|
+
paths.map(async (file_path) => await stat(file_path).then((value) => value.size).catch(() => 0)),
|
|
176
|
+
);
|
|
177
|
+
const latest_sequence = Math.max(
|
|
178
|
+
latest_range?.end_sequence || 0,
|
|
179
|
+
active_messages.reduce((max, message) => Math.max(max, message.sequence), 0),
|
|
180
|
+
);
|
|
181
|
+
return {
|
|
182
|
+
message_count: latest_sequence,
|
|
183
|
+
history_bytes: sizes.reduce((total, size) => total + size, 0),
|
|
184
|
+
latest_message,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 判断给定边界之前是否仍存在历史 Segment。 */
|
|
189
|
+
async has_segment_before(before_sequence: number): Promise<boolean> {
|
|
190
|
+
return (await this.list_segment_ranges()).some(
|
|
191
|
+
(range) => range.end_sequence < before_sequence,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 返回当前 Active 之前的读取边界。 */
|
|
196
|
+
async active_before_sequence(messages: SessionMessage[]): Promise<number | undefined> {
|
|
197
|
+
const first = messages[0];
|
|
198
|
+
if (first) return first.sequence;
|
|
199
|
+
const ranges = await this.list_segment_ranges();
|
|
200
|
+
const latest = ranges[ranges.length - 1];
|
|
201
|
+
return latest ? latest.end_sequence + 1 : undefined;
|
|
87
202
|
}
|
|
88
203
|
|
|
89
204
|
/** 读取当前运行中的 Assistant 草稿。 */
|
|
@@ -91,11 +206,9 @@ export class JsonlSessionMessageStore {
|
|
|
91
206
|
try {
|
|
92
207
|
const value = await fs.readJson(this.assistant_message_file_path) as SessionAssistantMessage;
|
|
93
208
|
this.validate_message(value);
|
|
94
|
-
|
|
95
|
-
return value;
|
|
209
|
+
return value.type === "assistant" && value.status === "streaming" ? value : null;
|
|
96
210
|
} catch (error) {
|
|
97
211
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
98
|
-
if (error instanceof SyntaxError) throw error;
|
|
99
212
|
throw error;
|
|
100
213
|
}
|
|
101
214
|
}
|
|
@@ -116,16 +229,18 @@ export class JsonlSessionMessageStore {
|
|
|
116
229
|
});
|
|
117
230
|
}
|
|
118
231
|
|
|
119
|
-
/**
|
|
232
|
+
/** 在写锁内分配全局 sequence 并创建唯一 Assistant 草稿。 */
|
|
120
233
|
async create_assistant_message(
|
|
121
234
|
build_message: (state: SessionMessageCommitState) => SessionAssistantMessage,
|
|
122
235
|
): Promise<SessionAssistantMessage> {
|
|
123
236
|
return await this.with_write_lock(async () => {
|
|
124
237
|
const current = await this.read_assistant_message();
|
|
125
238
|
if (current) throw new Error(`Assistant draft already exists: ${current.message_id}`);
|
|
126
|
-
const messages = await this.
|
|
239
|
+
const messages = await this.read_active_messages_unsafe();
|
|
240
|
+
const ranges = await this.list_segment_ranges();
|
|
241
|
+
this.validate_runtime_sequence(messages, ranges.at(-1)?.end_sequence || 0);
|
|
127
242
|
const message = build_message({
|
|
128
|
-
message_sequence:
|
|
243
|
+
message_sequence: await this.next_sequence_unsafe(messages),
|
|
129
244
|
messages,
|
|
130
245
|
});
|
|
131
246
|
this.validate_message(message);
|
|
@@ -137,25 +252,30 @@ export class JsonlSessionMessageStore {
|
|
|
137
252
|
});
|
|
138
253
|
}
|
|
139
254
|
|
|
140
|
-
/** 创建新 Message
|
|
255
|
+
/** 创建新 Message 或向 Active 追加已有 Message revision。 */
|
|
141
256
|
async append_message(
|
|
142
257
|
build_message: (state: SessionMessageCommitState) => SessionMessage,
|
|
143
258
|
): Promise<SessionMessage> {
|
|
144
259
|
return await this.with_write_lock(async () => {
|
|
145
|
-
const messages = await this.
|
|
260
|
+
const messages = await this.read_active_messages_unsafe();
|
|
146
261
|
const draft = await this.read_assistant_message();
|
|
147
262
|
const current_messages = draft ? [...messages, draft] : messages;
|
|
263
|
+
const ranges = await this.list_segment_ranges();
|
|
264
|
+
this.validate_runtime_sequence(
|
|
265
|
+
current_messages,
|
|
266
|
+
ranges.at(-1)?.end_sequence || 0,
|
|
267
|
+
);
|
|
148
268
|
const message = build_message({
|
|
149
|
-
message_sequence:
|
|
269
|
+
message_sequence: await this.next_sequence_unsafe(current_messages),
|
|
150
270
|
messages: current_messages,
|
|
151
271
|
});
|
|
152
272
|
this.validate_message(message);
|
|
153
|
-
await fs.appendFile(this.
|
|
273
|
+
await fs.appendFile(this.active_file_path, `${JSON.stringify(message)}\n`, "utf8");
|
|
154
274
|
return message;
|
|
155
275
|
});
|
|
156
276
|
}
|
|
157
277
|
|
|
158
|
-
/** 将 Assistant
|
|
278
|
+
/** 将 Assistant 草稿最终追加到 Active,并删除草稿文件。 */
|
|
159
279
|
async finalize_assistant_message(message: SessionAssistantMessage): Promise<void> {
|
|
160
280
|
this.validate_message(message);
|
|
161
281
|
if (message.status === "streaming") throw new Error("Final Assistant message cannot be streaming");
|
|
@@ -167,13 +287,128 @@ export class JsonlSessionMessageStore {
|
|
|
167
287
|
if (message.revision !== current.revision + 1) {
|
|
168
288
|
throw new Error(`Invalid final Assistant revision: ${message.message_id}`);
|
|
169
289
|
}
|
|
170
|
-
await fs.appendFile(this.
|
|
290
|
+
await fs.appendFile(this.active_file_path, `${JSON.stringify(message)}\n`, "utf8");
|
|
171
291
|
await fs.remove(this.assistant_message_file_path);
|
|
172
292
|
});
|
|
173
293
|
}
|
|
174
294
|
|
|
175
|
-
|
|
176
|
-
|
|
295
|
+
/** 把 Active 前缀与累计 Summary 提交为不可变 Segment。 */
|
|
296
|
+
async compact_active(
|
|
297
|
+
input: CompactActiveMessagesInput,
|
|
298
|
+
): Promise<CompactActiveMessagesResult> {
|
|
299
|
+
return await this.with_write_lock(async () => {
|
|
300
|
+
const draft = await this.read_assistant_message();
|
|
301
|
+
if (draft) throw new Error("Cannot compact while Assistant is streaming");
|
|
302
|
+
const active_messages = await this.read_active_messages_unsafe();
|
|
303
|
+
const ranges = await this.list_segment_ranges();
|
|
304
|
+
this.validate_runtime_sequence(
|
|
305
|
+
active_messages,
|
|
306
|
+
ranges.at(-1)?.end_sequence || 0,
|
|
307
|
+
);
|
|
308
|
+
const segment_messages = active_messages.filter(
|
|
309
|
+
(message) => message.sequence <= input.through_sequence,
|
|
310
|
+
);
|
|
311
|
+
const retained_messages = active_messages.filter(
|
|
312
|
+
(message) => message.sequence > input.through_sequence,
|
|
313
|
+
);
|
|
314
|
+
if (segment_messages.length === 0) {
|
|
315
|
+
throw new Error("Compact boundary does not include Active messages");
|
|
316
|
+
}
|
|
317
|
+
const start_sequence = segment_messages[0].sequence;
|
|
318
|
+
const end_sequence = segment_messages.at(-1)?.sequence || 0;
|
|
319
|
+
if (input.summary.through_sequence !== end_sequence) {
|
|
320
|
+
throw new Error("Compact Summary boundary does not match Segment end");
|
|
321
|
+
}
|
|
322
|
+
const range: SessionSegmentRange = {
|
|
323
|
+
start_sequence,
|
|
324
|
+
end_sequence,
|
|
325
|
+
file_path: this.segment_file_path(start_sequence, end_sequence),
|
|
326
|
+
};
|
|
327
|
+
if (await fs.pathExists(range.file_path)) {
|
|
328
|
+
throw new Error(`Session Segment already exists: ${range.file_path}`);
|
|
329
|
+
}
|
|
330
|
+
await this.write_segment_unsafe(range, segment_messages, input.summary);
|
|
331
|
+
await this.write_active_messages_unsafe(retained_messages);
|
|
332
|
+
return {
|
|
333
|
+
segment: { range, messages: segment_messages, summary: input.summary },
|
|
334
|
+
active_messages: retained_messages,
|
|
335
|
+
};
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** 扫描 Segment 文件名并返回 sequence 升序索引。 */
|
|
340
|
+
async list_segment_ranges(): Promise<SessionSegmentRange[]> {
|
|
341
|
+
await this.ensure_layout();
|
|
342
|
+
const entries = await fs.readdir(this.segments_dir_path, { withFileTypes: true });
|
|
343
|
+
const ranges = entries
|
|
344
|
+
.flatMap<SessionSegmentRange>((entry) => {
|
|
345
|
+
if (!entry.isFile()) return [];
|
|
346
|
+
const match = SEGMENT_FILE_PATTERN.exec(entry.name);
|
|
347
|
+
if (!match) return [];
|
|
348
|
+
const start_sequence = Number(match[1]);
|
|
349
|
+
const end_sequence = Number(match[2]);
|
|
350
|
+
if (!Number.isInteger(start_sequence) || !Number.isInteger(end_sequence)) return [];
|
|
351
|
+
return [{
|
|
352
|
+
start_sequence,
|
|
353
|
+
end_sequence,
|
|
354
|
+
file_path: path.join(this.segments_dir_path, entry.name),
|
|
355
|
+
}];
|
|
356
|
+
})
|
|
357
|
+
.sort((left, right) => left.start_sequence - right.start_sequence);
|
|
358
|
+
for (let index = 0; index < ranges.length; index += 1) {
|
|
359
|
+
const range = ranges[index];
|
|
360
|
+
const previous = ranges[index - 1];
|
|
361
|
+
if (!range || range.start_sequence > range.end_sequence) {
|
|
362
|
+
throw new Error("Session Segment filename contains an invalid sequence range");
|
|
363
|
+
}
|
|
364
|
+
if (index === 0 && range.start_sequence !== 1) {
|
|
365
|
+
throw new Error("The first Session Segment must start at sequence 1");
|
|
366
|
+
}
|
|
367
|
+
if (previous && range.start_sequence !== previous.end_sequence + 1) {
|
|
368
|
+
throw new Error("Session Segment sequence ranges must be contiguous and non-overlapping");
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return ranges;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** 读取并验证一个不可变 Segment。 */
|
|
375
|
+
private async read_segment(range: SessionSegmentRange): Promise<SessionSegmentSnapshot> {
|
|
376
|
+
const raw = await fs.readFile(range.file_path, "utf8");
|
|
377
|
+
const lines = raw.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
378
|
+
const summary_value = JSON.parse(lines.pop() || "null") as SessionSegmentSummary | null;
|
|
379
|
+
this.validate_summary(summary_value, range.end_sequence);
|
|
380
|
+
const messages = lines.map((line) => {
|
|
381
|
+
const message = JSON.parse(line) as SessionMessage;
|
|
382
|
+
this.validate_message(message);
|
|
383
|
+
return message;
|
|
384
|
+
}).sort(compare_message_sequence);
|
|
385
|
+
if (
|
|
386
|
+
messages.length !== range.end_sequence - range.start_sequence + 1 ||
|
|
387
|
+
messages[0]?.sequence !== range.start_sequence ||
|
|
388
|
+
messages.at(-1)?.sequence !== range.end_sequence ||
|
|
389
|
+
messages.some(
|
|
390
|
+
(message, index) => message.sequence !== range.start_sequence + index,
|
|
391
|
+
)
|
|
392
|
+
) {
|
|
393
|
+
throw new Error(`Session Segment range mismatch: ${range.file_path}`);
|
|
394
|
+
}
|
|
395
|
+
return { range, messages, summary: summary_value as SessionSegmentSummary };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** 读取 Active 中每个 Message 的最高 revision。 */
|
|
399
|
+
private async read_active_messages_unsafe(): Promise<SessionMessage[]> {
|
|
400
|
+
const messages = await this.read_folded_active_messages_unsafe();
|
|
401
|
+
const ranges = await this.list_segment_ranges();
|
|
402
|
+
const latest_segment_end = ranges.at(-1)?.end_sequence || 0;
|
|
403
|
+
const active_messages = messages.filter(
|
|
404
|
+
(message) => message.sequence > latest_segment_end,
|
|
405
|
+
);
|
|
406
|
+
return active_messages;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** 读取 Active 原始行,并按 Message ID 保留最高 revision。 */
|
|
410
|
+
private async read_folded_active_messages_unsafe(): Promise<SessionMessage[]> {
|
|
411
|
+
const raw = await fs.readFile(this.active_file_path, "utf8").catch(() => "");
|
|
177
412
|
const by_id = new Map<string, SessionMessage>();
|
|
178
413
|
for (const line of raw.split("\n")) {
|
|
179
414
|
if (!line.trim()) continue;
|
|
@@ -182,16 +417,58 @@ export class JsonlSessionMessageStore {
|
|
|
182
417
|
const previous = by_id.get(message.message_id);
|
|
183
418
|
if (!previous || message.revision > previous.revision) by_id.set(message.message_id, message);
|
|
184
419
|
}
|
|
185
|
-
return [...by_id.values()].sort(
|
|
420
|
+
return [...by_id.values()].sort(compare_message_sequence);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** 计算下一条真实 Message sequence。 */
|
|
424
|
+
private async next_sequence_unsafe(messages: SessionMessage[]): Promise<number> {
|
|
425
|
+
const ranges = await this.list_segment_ranges();
|
|
426
|
+
return Math.max(
|
|
427
|
+
ranges.at(-1)?.end_sequence || 0,
|
|
428
|
+
messages.reduce((max, message) => Math.max(max, message.sequence), 0),
|
|
429
|
+
) + 1;
|
|
186
430
|
}
|
|
187
431
|
|
|
432
|
+
/** 原子覆盖 Assistant 草稿。 */
|
|
188
433
|
private async write_assistant_message_unsafe(message: SessionAssistantMessage): Promise<void> {
|
|
189
434
|
const temporary_path = `${this.assistant_message_file_path}.${process.pid}.${Date.now()}.tmp`;
|
|
190
|
-
await fs.ensureDir(path.dirname(this.assistant_message_file_path));
|
|
191
435
|
await fs.writeJson(temporary_path, message, { spaces: 2 });
|
|
192
436
|
await fs.move(temporary_path, this.assistant_message_file_path, { overwrite: true });
|
|
193
437
|
}
|
|
194
438
|
|
|
439
|
+
/** 原子覆盖 Active,只写入每个 Message 的最终 revision。 */
|
|
440
|
+
private async write_active_messages_unsafe(messages: SessionMessage[]): Promise<void> {
|
|
441
|
+
const temporary_path = `${this.active_file_path}.${process.pid}.${Date.now()}.tmp`;
|
|
442
|
+
const content = messages.length > 0
|
|
443
|
+
? `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`
|
|
444
|
+
: "";
|
|
445
|
+
await fs.writeFile(temporary_path, content, "utf8");
|
|
446
|
+
await fs.move(temporary_path, this.active_file_path, { overwrite: true });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** 原子创建一个带累计 Summary footer 的 Segment。 */
|
|
450
|
+
private async write_segment_unsafe(
|
|
451
|
+
range: SessionSegmentRange,
|
|
452
|
+
messages: SessionMessage[],
|
|
453
|
+
summary: SessionSegmentSummary,
|
|
454
|
+
): Promise<void> {
|
|
455
|
+
const temporary_path = `${range.file_path}.${process.pid}.${Date.now()}.tmp`;
|
|
456
|
+
const content = `${[
|
|
457
|
+
...messages.map((message) => JSON.stringify(message)),
|
|
458
|
+
JSON.stringify(summary),
|
|
459
|
+
].join("\n")}\n`;
|
|
460
|
+
await fs.writeFile(temporary_path, content, "utf8");
|
|
461
|
+
await fs.move(temporary_path, range.file_path, { overwrite: false });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** 生成稳定的 Segment sequence 范围文件名。 */
|
|
465
|
+
private segment_file_path(start_sequence: number, end_sequence: number): string {
|
|
466
|
+
const start = String(start_sequence).padStart(SEQUENCE_FILE_WIDTH, "0");
|
|
467
|
+
const end = String(end_sequence).padStart(SEQUENCE_FILE_WIDTH, "0");
|
|
468
|
+
return path.join(this.segments_dir_path, `${start}-${end}.jsonl`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** 验证真实 Session Message。 */
|
|
195
472
|
private validate_message(message: SessionMessage): void {
|
|
196
473
|
if (!message || typeof message !== "object") throw new Error("message must be an object");
|
|
197
474
|
if (message.session_id !== this.session_id) throw new Error("session_id mismatch");
|
|
@@ -209,11 +486,43 @@ export class JsonlSessionMessageStore {
|
|
|
209
486
|
}
|
|
210
487
|
}
|
|
211
488
|
|
|
489
|
+
/** 验证 Segment footer Summary。 */
|
|
490
|
+
private validate_summary(
|
|
491
|
+
summary: SessionSegmentSummary | null,
|
|
492
|
+
expected_sequence: number,
|
|
493
|
+
): asserts summary is SessionSegmentSummary {
|
|
494
|
+
if (!summary || summary.record_type !== "summary") {
|
|
495
|
+
throw new Error("Session Segment summary footer is required");
|
|
496
|
+
}
|
|
497
|
+
if (summary.session_id !== this.session_id || !summary.summary_id) {
|
|
498
|
+
throw new Error("Session Segment summary identity mismatch");
|
|
499
|
+
}
|
|
500
|
+
if (summary.through_sequence !== expected_sequence || !String(summary.text || "").trim()) {
|
|
501
|
+
throw new Error("Session Segment summary boundary is invalid");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** 验证 Active 与草稿合并后紧接最新 Segment,且真实 Message sequence 连续。 */
|
|
506
|
+
private validate_runtime_sequence(
|
|
507
|
+
messages: SessionMessage[],
|
|
508
|
+
latest_segment_end: number,
|
|
509
|
+
): void {
|
|
510
|
+
const ordered_messages = [...messages].sort(compare_message_sequence);
|
|
511
|
+
if (ordered_messages.some(
|
|
512
|
+
(message, index) => message.sequence !== latest_segment_end + index + 1,
|
|
513
|
+
)) {
|
|
514
|
+
throw new Error("Active and draft Session Message sequences must be contiguous");
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** 创建 Active、Segment 与锁所需目录。 */
|
|
212
519
|
private async ensure_layout(): Promise<void> {
|
|
213
|
-
await fs.ensureDir(path.dirname(this.
|
|
214
|
-
await fs.
|
|
520
|
+
await fs.ensureDir(path.dirname(this.active_file_path));
|
|
521
|
+
await fs.ensureDir(this.segments_dir_path);
|
|
522
|
+
await fs.ensureFile(this.active_file_path);
|
|
215
523
|
}
|
|
216
524
|
|
|
525
|
+
/** 使用同目录锁串行化 Active、草稿与 Segment 写入。 */
|
|
217
526
|
private async with_write_lock<T>(callback: () => Promise<T>): Promise<T> {
|
|
218
527
|
await this.ensure_layout();
|
|
219
528
|
const token = `${process.pid}:${Date.now()}:${generateId()}`;
|
|
@@ -231,7 +540,9 @@ export class JsonlSessionMessageStore {
|
|
|
231
540
|
const lock_stat = await stat(this.lock_file_path);
|
|
232
541
|
if (Date.now() - lock_stat.mtimeMs > stale_ms) await fs.remove(this.lock_file_path);
|
|
233
542
|
} catch { /* 锁文件可能刚被释放。 */ }
|
|
234
|
-
if (Date.now() - started_at > stale_ms * 2)
|
|
543
|
+
if (Date.now() - started_at > stale_ms * 2) {
|
|
544
|
+
throw new Error(`Session message lock timeout: ${this.lock_file_path}`);
|
|
545
|
+
}
|
|
235
546
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
236
547
|
}
|
|
237
548
|
}
|
|
@@ -239,8 +550,15 @@ export class JsonlSessionMessageStore {
|
|
|
239
550
|
return await callback();
|
|
240
551
|
} finally {
|
|
241
552
|
try {
|
|
242
|
-
if ((await readFile(this.lock_file_path, "utf8")).trim() === token)
|
|
553
|
+
if ((await readFile(this.lock_file_path, "utf8")).trim() === token) {
|
|
554
|
+
await fs.remove(this.lock_file_path);
|
|
555
|
+
}
|
|
243
556
|
} catch { /* 锁已被清理。 */ }
|
|
244
557
|
}
|
|
245
558
|
}
|
|
246
559
|
}
|
|
560
|
+
|
|
561
|
+
/** 按全局 Message sequence 升序排序。 */
|
|
562
|
+
function compare_message_sequence(left: SessionMessage, right: SessionMessage): number {
|
|
563
|
+
return left.sequence - right.sequence;
|
|
564
|
+
}
|