@downcity/agent 1.1.226 → 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.
Files changed (157) hide show
  1. package/bin/agent/local/Agent.d.ts +6 -1
  2. package/bin/agent/local/Agent.d.ts.map +1 -1
  3. package/bin/agent/local/Agent.js +30 -3
  4. package/bin/agent/local/Agent.js.map +1 -1
  5. package/bin/agent/local/services/AgentSessions.d.ts +23 -0
  6. package/bin/agent/local/services/AgentSessions.d.ts.map +1 -1
  7. package/bin/agent/local/services/AgentSessions.js +44 -0
  8. package/bin/agent/local/services/AgentSessions.js.map +1 -1
  9. package/bin/agent/remote/transports/HttpRemoteAgentTransport.d.ts.map +1 -1
  10. package/bin/agent/remote/transports/HttpRemoteAgentTransport.js +0 -7
  11. package/bin/agent/remote/transports/HttpRemoteAgentTransport.js.map +1 -1
  12. package/bin/executor/Executor.d.ts +25 -0
  13. package/bin/executor/Executor.d.ts.map +1 -1
  14. package/bin/executor/Executor.js +36 -0
  15. package/bin/executor/Executor.js.map +1 -1
  16. package/bin/executor/composer/compaction/SessionCompactionComposer.d.ts +4 -0
  17. package/bin/executor/composer/compaction/SessionCompactionComposer.d.ts.map +1 -1
  18. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.d.ts +0 -1
  19. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.d.ts.map +1 -1
  20. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.js +15 -11
  21. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.js.map +1 -1
  22. package/bin/executor/core-engine/CoreEngineRunner.d.ts +11 -0
  23. package/bin/executor/core-engine/CoreEngineRunner.d.ts.map +1 -1
  24. package/bin/executor/core-engine/CoreEngineRunner.js +12 -8
  25. package/bin/executor/core-engine/CoreEngineRunner.js.map +1 -1
  26. package/bin/executor/store/history/SessionHistoryStore.d.ts +3 -13
  27. package/bin/executor/store/history/SessionHistoryStore.d.ts.map +1 -1
  28. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js +1 -1
  29. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js.map +1 -1
  30. package/bin/executor/tools/plugin/PluginToolBridge.d.ts.map +1 -1
  31. package/bin/executor/tools/plugin/PluginToolBridge.js +4 -2
  32. package/bin/executor/tools/plugin/PluginToolBridge.js.map +1 -1
  33. package/bin/index.d.ts +2 -1
  34. package/bin/index.d.ts.map +1 -1
  35. package/bin/index.js +1 -1
  36. package/bin/index.js.map +1 -1
  37. package/bin/model/CityModelAdapter.d.ts +6 -0
  38. package/bin/model/CityModelAdapter.d.ts.map +1 -1
  39. package/bin/model/CityModelAdapter.js +13 -0
  40. package/bin/model/CityModelAdapter.js.map +1 -1
  41. package/bin/plugin/core/PluginRegistry.d.ts +22 -1
  42. package/bin/plugin/core/PluginRegistry.d.ts.map +1 -1
  43. package/bin/plugin/core/PluginRegistry.js +46 -4
  44. package/bin/plugin/core/PluginRegistry.js.map +1 -1
  45. package/bin/session/Session.d.ts +11 -1
  46. package/bin/session/Session.d.ts.map +1 -1
  47. package/bin/session/Session.js +76 -11
  48. package/bin/session/Session.js.map +1 -1
  49. package/bin/session/browse/Browse.d.ts +0 -8
  50. package/bin/session/browse/Browse.d.ts.map +1 -1
  51. package/bin/session/browse/Browse.js +72 -57
  52. package/bin/session/browse/Browse.js.map +1 -1
  53. package/bin/session/recorder/JsonlSessionMessageStore.d.ts +69 -16
  54. package/bin/session/recorder/JsonlSessionMessageStore.d.ts.map +1 -1
  55. package/bin/session/recorder/JsonlSessionMessageStore.js +283 -60
  56. package/bin/session/recorder/JsonlSessionMessageStore.js.map +1 -1
  57. package/bin/session/recorder/SessionRecorder.d.ts +30 -2
  58. package/bin/session/recorder/SessionRecorder.d.ts.map +1 -1
  59. package/bin/session/recorder/SessionRecorder.js +120 -49
  60. package/bin/session/recorder/SessionRecorder.js.map +1 -1
  61. package/bin/session/recorder/SessionRecorderCompaction.d.ts +3 -3
  62. package/bin/session/recorder/SessionRecorderCompaction.d.ts.map +1 -1
  63. package/bin/session/recorder/SessionRecorderCompaction.js +50 -62
  64. package/bin/session/recorder/SessionRecorderCompaction.js.map +1 -1
  65. package/bin/session/recorder/SessionRecorderHistoryStore.d.ts +2 -2
  66. package/bin/session/recorder/SessionRecorderHistoryStore.d.ts.map +1 -1
  67. package/bin/session/recorder/SessionRecorderHistoryStore.js +31 -36
  68. package/bin/session/recorder/SessionRecorderHistoryStore.js.map +1 -1
  69. package/bin/session/runtime/SessionPromptRuntime.d.ts +17 -1
  70. package/bin/session/runtime/SessionPromptRuntime.d.ts.map +1 -1
  71. package/bin/session/runtime/SessionPromptRuntime.js +67 -8
  72. package/bin/session/runtime/SessionPromptRuntime.js.map +1 -1
  73. package/bin/session/services/SessionStateService.d.ts +15 -1
  74. package/bin/session/services/SessionStateService.d.ts.map +1 -1
  75. package/bin/session/services/SessionStateService.js +60 -39
  76. package/bin/session/services/SessionStateService.js.map +1 -1
  77. package/bin/session/services/SessionTurnService.d.ts +5 -0
  78. package/bin/session/services/SessionTurnService.d.ts.map +1 -1
  79. package/bin/session/services/SessionTurnService.js +17 -3
  80. package/bin/session/services/SessionTurnService.js.map +1 -1
  81. package/bin/session/services/SessionViewService.d.ts +1 -5
  82. package/bin/session/services/SessionViewService.d.ts.map +1 -1
  83. package/bin/session/services/SessionViewService.js +4 -34
  84. package/bin/session/services/SessionViewService.js.map +1 -1
  85. package/bin/session/storage/Paths.d.ts +3 -15
  86. package/bin/session/storage/Paths.d.ts.map +1 -1
  87. package/bin/session/storage/Paths.js +5 -21
  88. package/bin/session/storage/Paths.js.map +1 -1
  89. package/bin/types/agent/SessionTypes.d.ts +2 -24
  90. package/bin/types/agent/SessionTypes.d.ts.map +1 -1
  91. package/bin/types/executor/SessionRunContext.d.ts +21 -0
  92. package/bin/types/executor/SessionRunContext.d.ts.map +1 -1
  93. package/bin/types/plugin/PluginRuntime.d.ts +25 -0
  94. package/bin/types/plugin/PluginRuntime.d.ts.map +1 -1
  95. package/bin/types/runtime/agent/AgentContext.d.ts +17 -5
  96. package/bin/types/runtime/agent/AgentContext.d.ts.map +1 -1
  97. package/bin/types/runtime/agent/AgentContext.js +29 -6
  98. package/bin/types/runtime/agent/AgentContext.js.map +1 -1
  99. package/bin/types/session/SessionConfigMutation.d.ts +68 -0
  100. package/bin/types/session/SessionConfigMutation.d.ts.map +1 -0
  101. package/bin/types/session/SessionConfigMutation.js +10 -0
  102. package/bin/types/session/SessionConfigMutation.js.map +1 -0
  103. package/bin/types/session/SessionLocalState.d.ts +8 -0
  104. package/bin/types/session/SessionLocalState.d.ts.map +1 -1
  105. package/bin/types/session/SessionMessage.d.ts +16 -12
  106. package/bin/types/session/SessionMessage.d.ts.map +1 -1
  107. package/bin/types/session/SessionMutation.d.ts +1 -1
  108. package/bin/types/session/SessionMutation.js +1 -1
  109. package/bin/types/session/SessionOptions.d.ts +16 -0
  110. package/bin/types/session/SessionOptions.d.ts.map +1 -1
  111. package/bin/types/session/SessionSegment.d.ts +57 -0
  112. package/bin/types/session/SessionSegment.d.ts.map +1 -0
  113. package/bin/types/session/SessionSegment.js +8 -0
  114. package/bin/types/session/SessionSegment.js.map +1 -0
  115. package/package.json +3 -3
  116. package/scripts/city-model-tool-loop.test.mjs +32 -0
  117. package/scripts/session-action-message.test.mjs +59 -13
  118. package/scripts/session-compaction-context-window.test.mjs +95 -0
  119. package/scripts/session-config-turn-boundary.test.mjs +224 -0
  120. package/scripts/session-list-title.test.mjs +21 -3
  121. package/scripts/session-prompt-runtime.test.mjs +85 -0
  122. package/scripts/session-recorder.test.mjs +379 -11
  123. package/src/agent/local/Agent.ts +34 -3
  124. package/src/agent/local/services/AgentSessions.ts +61 -0
  125. package/src/agent/remote/transports/HttpRemoteAgentTransport.ts +0 -5
  126. package/src/executor/Executor.ts +62 -0
  127. package/src/executor/composer/compaction/SessionCompactionComposer.ts +5 -0
  128. package/src/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.ts +16 -14
  129. package/src/executor/core-engine/CoreEngineRunner.ts +24 -8
  130. package/src/executor/store/history/SessionHistoryStore.ts +3 -14
  131. package/src/executor/store/history/jsonl/JsonlSessionHistoryStore.ts +1 -1
  132. package/src/executor/tools/plugin/PluginToolBridge.ts +4 -2
  133. package/src/index.ts +8 -0
  134. package/src/model/CityModelAdapter.ts +15 -0
  135. package/src/plugin/core/PluginRegistry.ts +72 -4
  136. package/src/session/Session.ts +89 -11
  137. package/src/session/browse/Browse.ts +73 -69
  138. package/src/session/recorder/JsonlSessionMessageStore.ts +378 -60
  139. package/src/session/recorder/SessionRecorder.ts +151 -52
  140. package/src/session/recorder/SessionRecorderCompaction.ts +55 -80
  141. package/src/session/recorder/SessionRecorderHistoryStore.ts +29 -49
  142. package/src/session/runtime/SessionPromptRuntime.ts +100 -9
  143. package/src/session/services/SessionStateService.ts +79 -44
  144. package/src/session/services/SessionTurnService.ts +19 -3
  145. package/src/session/services/SessionViewService.ts +4 -50
  146. package/src/session/storage/Paths.ts +5 -38
  147. package/src/types/agent/SessionTypes.ts +2 -24
  148. package/src/types/executor/SessionRunContext.ts +25 -0
  149. package/src/types/plugin/PluginRuntime.ts +27 -0
  150. package/src/types/runtime/agent/AgentContext.ts +32 -7
  151. package/src/types/session/SessionConfigMutation.ts +78 -0
  152. package/src/types/session/SessionLocalState.ts +9 -0
  153. package/src/types/session/SessionMessage.ts +16 -12
  154. package/src/types/session/SessionMutation.ts +1 -1
  155. package/src/types/session/SessionOptions.ts +21 -0
  156. package/src/types/session/SessionSegment.ts +62 -0
  157. package/tsconfig.tsbuildinfo +1 -1
@@ -11,6 +11,7 @@ import type { JsonObject, JsonValue } from "@/types/common/Json.js";
11
11
  import type {
12
12
  ListSessionMessagesInput,
13
13
  SessionActionMessage,
14
+ SessionAssistantFilePart,
14
15
  SessionAssistantMessage,
15
16
  SessionAssistantMessagePart,
16
17
  SessionAssistantToolPart,
@@ -24,6 +25,11 @@ import type {
24
25
  SessionMutation,
25
26
  SessionMessageMutation as SessionMessageSnapshotMutation,
26
27
  } from "@/types/session/SessionMutation.js";
28
+ import type {
29
+ SessionContextSnapshot,
30
+ SessionMessageStorageStats,
31
+ SessionSegmentSummary,
32
+ } from "@/types/session/SessionSegment.js";
27
33
 
28
34
  /** SessionRecorder 构造参数。 */
29
35
  export interface SessionRecorderOptions {
@@ -289,38 +295,88 @@ export class SessionRecorder {
289
295
  }))) as SessionErrorMessage;
290
296
  }
291
297
 
292
- /** 读取折叠后的 Message snapshot。 */
298
+ /** 读取 Active 或指定边界之前最近的完整 Segment。 */
293
299
  async list_messages(
294
300
  input?: ListSessionMessagesInput,
295
301
  ): Promise<SessionMessagePage> {
296
302
  await this.ensure_initialized();
297
- const include_internal = input?.include_internal === true;
298
- const through_sequence = Number.isFinite(input?.through_sequence)
299
- ? Number(input?.through_sequence)
300
- : Number.POSITIVE_INFINITY;
301
- const before_sequence = Number.isFinite(input?.before_sequence)
302
- ? Number(input?.before_sequence)
303
- : Number.POSITIVE_INFINITY;
304
- const all = [...this.messages_by_id.values()]
305
- .filter(
306
- (message) =>
307
- (include_internal || message.visibility === "visible") &&
308
- message.sequence <= through_sequence &&
309
- message.sequence < before_sequence,
310
- )
311
- .sort((left, right) => left.sequence - right.sequence);
312
- const offset = decode_cursor(input?.cursor);
313
- const limit = normalize_limit(input?.limit, 100, 500);
314
- const items = all.slice(offset, offset + limit).map((item) => structuredClone(item));
315
- const next_offset = offset + items.length;
303
+ const requests_segment = input?.before_sequence !== undefined;
304
+ const before_sequence = input?.before_sequence;
305
+ if (
306
+ requests_segment &&
307
+ (!Number.isInteger(before_sequence) || Number(before_sequence) <= 0)
308
+ ) {
309
+ throw new Error("before_sequence must be a positive integer");
310
+ }
311
+ const segment = requests_segment
312
+ ? await this.store.read_segment_before(Number(before_sequence))
313
+ : null;
314
+ const source = requests_segment ? "segment" as const : "active" as const;
315
+ const messages = requests_segment
316
+ ? segment?.messages || []
317
+ : [...this.messages_by_id.values()].sort(compare_message_sequence);
318
+ const start_sequence = messages[0]?.sequence;
319
+ const end_sequence = messages.at(-1)?.sequence;
320
+ const history_boundary = source === "segment"
321
+ ? segment?.range.start_sequence
322
+ : await this.store.active_before_sequence(messages);
323
+ const has_more = history_boundary !== undefined &&
324
+ await this.store.has_segment_before(history_boundary);
325
+ const stats = await this.store.stats();
326
+ const items = messages
327
+ .filter((message) => input?.include_internal === true || message.visibility === "visible")
328
+ .map((message) => structuredClone(message));
316
329
  return {
317
330
  items,
318
- total: all.length,
319
- ...(next_offset < all.length ? { next_cursor: encode_cursor(next_offset) } : {}),
320
- has_more: next_offset < all.length,
331
+ total: stats.message_count,
332
+ source,
333
+ ...(start_sequence !== undefined ? { start_sequence } : {}),
334
+ ...(end_sequence !== undefined ? { end_sequence } : {}),
335
+ ...(has_more && history_boundary !== undefined
336
+ ? { next_before_sequence: history_boundary }
337
+ : {}),
338
+ has_more,
339
+ };
340
+ }
341
+
342
+ /** 读取最新累计 Summary 与全部 Active Message,供模型上下文使用。 */
343
+ async context_snapshot(): Promise<SessionContextSnapshot> {
344
+ await this.ensure_initialized();
345
+ return {
346
+ summary: await this.store.read_latest_summary(),
347
+ messages: [...this.messages_by_id.values()]
348
+ .sort(compare_message_sequence)
349
+ .map((message) => structuredClone(message)),
321
350
  };
322
351
  }
323
352
 
353
+ /** 读取全部真实历史,供 Fork 等明确的全量复制操作使用。 */
354
+ async list_history_messages(): Promise<SessionMessage[]> {
355
+ await this.ensure_initialized();
356
+ return await this.store.list_history_messages();
357
+ }
358
+
359
+ /** 读取当前 Session 的存储统计。 */
360
+ async storage_stats(): Promise<SessionMessageStorageStats> {
361
+ await this.ensure_initialized();
362
+ return await this.store.stats();
363
+ }
364
+
365
+ /** 把 Active 前缀和累计 Summary 提交为不可变 Segment。 */
366
+ async compact_active(input: {
367
+ /** 移入 Segment 的最后一条真实 Message sequence。 */
368
+ through_sequence: number;
369
+ /** 写入 Segment footer 的累计 Summary。 */
370
+ summary: SessionSegmentSummary;
371
+ }): Promise<void> {
372
+ await this.ensure_initialized();
373
+ const result = await this.store.compact_active(input);
374
+ this.messages_by_id.clear();
375
+ for (const message of result.active_messages) {
376
+ this.messages_by_id.set(message.message_id, structuredClone(message));
377
+ }
378
+ }
379
+
324
380
  /** 向当前 Session 导入 fork 来源 Message,并重新分配全部身份和顺序。 */
325
381
  async import_messages(messages: SessionMessage[]): Promise<void> {
326
382
  const turn_ids = new Map<string, string>();
@@ -551,6 +607,7 @@ export class SessionRecorder {
551
607
  private async ensure_initialized(): Promise<void> {
552
608
  if (!this.initialized) await this.initialize();
553
609
  }
610
+
554
611
  }
555
612
 
556
613
  /** 单个 Assistant segment 的流式 writer。 */
@@ -558,6 +615,7 @@ export class SessionAssistantMessageWriter {
558
615
  readonly message_id: string;
559
616
  private readonly recorder: SessionRecorder;
560
617
  private readonly pending_text_parts = new Map<string, "text" | "reasoning">();
618
+ private readonly active_text_part_ids = new Map<string, string>();
561
619
  private closed = false;
562
620
 
563
621
  constructor(recorder: SessionRecorder, message_id: string) {
@@ -573,7 +631,7 @@ export class SessionAssistantMessageWriter {
573
631
  case "text-start":
574
632
  case "reasoning-start": {
575
633
  const type = chunk.type === "text-start" ? "text" : "reasoning";
576
- const part_id = `${type}:${chunk.id}`;
634
+ const part_id = this.resolve_text_part_id(type, chunk.id);
577
635
  if (!current.parts.some((part) => part.part_id === part_id)) {
578
636
  this.pending_text_parts.set(part_id, type);
579
637
  }
@@ -582,7 +640,7 @@ export class SessionAssistantMessageWriter {
582
640
  case "text-delta":
583
641
  case "reasoning-delta": {
584
642
  const type = chunk.type === "text-delta" ? "text" : "reasoning";
585
- const part_id = `${type}:${chunk.id}`;
643
+ const part_id = this.resolve_text_part_id(type, chunk.id);
586
644
  await this.ensure_text_part(part_id, type);
587
645
  await this.recorder.append_assistant_delta(
588
646
  this.message_id,
@@ -594,13 +652,17 @@ export class SessionAssistantMessageWriter {
594
652
  }
595
653
  case "text-end":
596
654
  case "reasoning-end": {
597
- const part_id = `${chunk.type === "text-end" ? "text" : "reasoning"}:${chunk.id}`;
655
+ const type = chunk.type === "text-end" ? "text" : "reasoning";
656
+ const source_part_id = this.source_text_part_id(type, chunk.id);
657
+ const part_id = this.active_text_part_ids.get(source_part_id);
658
+ if (!part_id) return;
598
659
  const part = current.parts.find((item) => item.part_id === part_id);
599
660
  if (part?.type === "text" || part?.type === "reasoning") {
600
661
  await this.upsert_part({ ...part, state: "done" });
601
662
  } else {
602
663
  this.pending_text_parts.delete(part_id);
603
664
  }
665
+ this.active_text_part_ids.delete(source_part_id);
604
666
  return;
605
667
  }
606
668
  case "tool-input-start":
@@ -666,10 +728,7 @@ export class SessionAssistantMessageWriter {
666
728
  return;
667
729
  }
668
730
  case "file":
669
- await this.upsert_part({
670
- part_id: `file:${generateId()}`,
671
- sequence: this.next_part_sequence(),
672
- type: "file",
731
+ await this.append_file_part({
673
732
  media_type: chunk.mediaType,
674
733
  url: chunk.url,
675
734
  });
@@ -684,6 +743,36 @@ export class SessionAssistantMessageWriter {
684
743
  await this.recorder.update_assistant_part(this.message_id, part);
685
744
  }
686
745
 
746
+ /**
747
+ * 把最终结果中的文件补入当前 Assistant,并对流式已写入文件去重。
748
+ */
749
+ async append_file_part(
750
+ input: Pick<SessionAssistantFilePart, "filename" | "media_type" | "url">,
751
+ ): Promise<void> {
752
+ const filename = String(input.filename || "").trim();
753
+ const current = this.current_message();
754
+ const existing = current.parts.find(
755
+ (part) =>
756
+ part.type === "file" &&
757
+ part.url === input.url &&
758
+ part.media_type === input.media_type,
759
+ );
760
+ if (existing?.type === "file") {
761
+ if (filename && String(existing.filename || "").trim() !== filename) {
762
+ await this.upsert_part({ ...existing, filename });
763
+ }
764
+ return;
765
+ }
766
+ await this.upsert_part({
767
+ part_id: `file:${generateId()}`,
768
+ sequence: this.next_part_sequence(),
769
+ type: "file",
770
+ media_type: input.media_type,
771
+ url: input.url,
772
+ ...(filename ? { filename } : {}),
773
+ });
774
+ }
775
+
687
776
  /** 当前实现逐 chunk 等待落盘,因此 flush 在返回时天然完成。 */
688
777
  async flush(): Promise<void> {}
689
778
 
@@ -724,6 +813,32 @@ export class SessionAssistantMessageWriter {
724
813
  );
725
814
  }
726
815
 
816
+ /**
817
+ * 把 AI SDK 当前 stream 内的临时 chunk ID 映射为 Message 内唯一 Part ID。
818
+ *
819
+ * AI SDK 会在不同 `streamText()` 调用中重复使用 `txt-0`、`reasoning-0`
820
+ * 等 ID,因此这些 ID 只能用于关联当前尚未结束的文本片段。
821
+ */
822
+ private resolve_text_part_id(
823
+ type: "text" | "reasoning",
824
+ chunk_id: string,
825
+ ): string {
826
+ const source_part_id = this.source_text_part_id(type, chunk_id);
827
+ const active_part_id = this.active_text_part_ids.get(source_part_id);
828
+ if (active_part_id) return active_part_id;
829
+ const part_id = `${type}:${generateId()}`;
830
+ this.active_text_part_ids.set(source_part_id, part_id);
831
+ return part_id;
832
+ }
833
+
834
+ /** 构造当前流片段使用的临时关联键。 */
835
+ private source_text_part_id(
836
+ type: "text" | "reasoning",
837
+ chunk_id: string,
838
+ ): string {
839
+ return `${type}:${chunk_id}`;
840
+ }
841
+
727
842
  /** 在首个有效 Delta 到达时才固定文本 Part 的真实顺序。 */
728
843
  private async ensure_text_part(
729
844
  part_id: string,
@@ -771,6 +886,7 @@ export class SessionAssistantMessageWriter {
771
886
  private async close(status: "completed" | "stopped" | "failed"): Promise<void> {
772
887
  if (this.closed) return;
773
888
  this.pending_text_parts.clear();
889
+ this.active_text_part_ids.clear();
774
890
  await this.recorder.complete_assistant_message(this.message_id, status);
775
891
  this.closed = true;
776
892
  }
@@ -875,28 +991,6 @@ function to_json_value(input: unknown): JsonValue {
875
991
  }
876
992
  }
877
993
 
878
- function normalize_limit(value: number | undefined, fallback: number, maximum: number): number {
879
- if (!Number.isFinite(value)) return fallback;
880
- return Math.min(maximum, Math.max(1, Math.floor(Number(value))));
881
- }
882
-
883
- function encode_cursor(offset: number): string {
884
- return Buffer.from(JSON.stringify({ offset }), "utf8").toString("base64url");
885
- }
886
-
887
- function decode_cursor(cursor: string | undefined): number {
888
- const value = String(cursor || "").trim();
889
- if (!value) return 0;
890
- try {
891
- const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as { offset?: unknown };
892
- return typeof parsed.offset === "number" && parsed.offset >= 0
893
- ? Math.floor(parsed.offset)
894
- : 0;
895
- } catch {
896
- throw new Error("Invalid Session Message cursor");
897
- }
898
- }
899
-
900
994
  function resolve_import_id(map: Map<string, string>, source_id: string, prefix: string): string {
901
995
  const existing = map.get(source_id);
902
996
  if (existing) return existing;
@@ -904,3 +998,8 @@ function resolve_import_id(map: Map<string, string>, source_id: string, prefix:
904
998
  map.set(source_id, created);
905
999
  return created;
906
1000
  }
1001
+
1002
+ /** 按真实 Message sequence 升序排序。 */
1003
+ function compare_message_sequence(left: SessionMessage, right: SessionMessage): number {
1004
+ return left.sequence - right.sequence;
1005
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * SessionRecorder canonical 上下文压缩。
2
+ * SessionRecorder Active/Segment 上下文压缩。
3
3
  *
4
- * 压缩只追加一条 internal summary Message,不删除、不归档、不重写 Mutation 日志。
4
+ * Compact 把 Active 前缀写入不可变 Segment,并在 footer 保存累计 Summary。
5
5
  */
6
6
 
7
7
  import { generateText } from "ai";
@@ -15,20 +15,19 @@ import {
15
15
  } from "@executor/composer/compaction/jsonl/JsonlSessionCompactionPrompts.js";
16
16
  import { SessionRecorder } from "@/session/recorder/SessionRecorder.js";
17
17
  import { to_executor_ui_message } from "@/session/recorder/SessionMessageCodec.js";
18
- import type { SessionAssistantMessage, SessionMessage } from "@/types/session/SessionMessage.js";
18
+ import type { SessionMessage } from "@/types/session/SessionMessage.js";
19
19
 
20
- /** 必要时把较早上下文追加为 canonical internal summary。 */
20
+ /** 必要时把 Active 前缀关闭为带累计 Summary 的 Segment。 */
21
21
  export async function compact_session_recorder_messages(input: {
22
22
  /** 当前 Session Recorder。 */
23
23
  recorder: SessionRecorder;
24
24
  /** 压缩策略和模型。 */
25
25
  compact_input: SessionHistoryCompactInput;
26
26
  }): Promise<{ compacted: boolean; reason?: string }> {
27
- const page = await input.recorder.list_messages({
28
- limit: 500,
29
- include_internal: true,
30
- });
31
- const context_messages = project_current_context(page.items);
27
+ const snapshot = await input.recorder.context_snapshot();
28
+ const context_messages = snapshot.messages.filter(
29
+ (message) => message.type === "user" || message.type === "assistant",
30
+ );
32
31
  if (context_messages.length < 4) {
33
32
  return { compacted: false, reason: "small_messages" };
34
33
  }
@@ -36,21 +35,24 @@ export async function compact_session_recorder_messages(input: {
36
35
  .map((message) => String(message.content || ""))
37
36
  .join("\n\n");
38
37
  const estimated_tokens = Math.ceil(
39
- `${system_text}\n${JSON.stringify(context_messages)}`.length / 3,
38
+ `${system_text}\n${snapshot.summary?.text || ""}\n${JSON.stringify(context_messages)}`.length / 3,
40
39
  );
41
40
  if (estimated_tokens <= input.compact_input.maxInputTokensApprox) {
42
41
  return { compacted: false, reason: "under_budget" };
43
42
  }
44
43
 
45
44
  const ratio = Math.max(0.1, Math.min(0.9, input.compact_input.compactRatio));
46
- const compact_count = Math.max(
47
- 2,
48
- Math.min(context_messages.length - 1, Math.floor(context_messages.length * ratio)),
45
+ const keep_last_messages = Math.max(1, input.compact_input.keepLastMessages);
46
+ const max_compact_count = context_messages.length - keep_last_messages;
47
+ if (max_compact_count < 2) {
48
+ return { compacted: false, reason: "keep_last_messages" };
49
+ }
50
+ const compact_count = Math.min(
51
+ max_compact_count,
52
+ Math.max(2, Math.floor(context_messages.length * ratio)),
49
53
  );
50
54
  const older = context_messages.slice(0, compact_count);
51
- const boundary = [...older]
52
- .reverse()
53
- .find((message) => message.type !== "assistant" || message.kind !== "summary");
55
+ const boundary = older.at(-1);
54
56
  if (!boundary) return { compacted: false, reason: "nothing_to_compact" };
55
57
 
56
58
  const action_id = `compacting:${input.recorder.session_id}:${generateId()}`;
@@ -61,19 +63,11 @@ export async function compact_session_recorder_messages(input: {
61
63
  state: "running",
62
64
  });
63
65
 
64
- const previous_summary = older.find(
65
- (message): message is SessionAssistantMessage =>
66
- message.type === "assistant" && message.kind === "summary",
67
- );
68
66
  const conversation_text = older
69
- .filter((message) => message !== previous_summary)
70
67
  .map((message) => message_to_compaction_text(message))
71
68
  .filter(Boolean)
72
- .join("\n")
73
- .slice(-24_000);
74
- const previous_summary_text = previous_summary
75
- ? assistant_text(previous_summary)
76
- : "";
69
+ .join("\n");
70
+ const previous_summary_text = String(snapshot.summary?.text || "").trim();
77
71
  const prompt = previous_summary_text
78
72
  ? build_update_session_compaction_prompt({
79
73
  previous_summary: previous_summary_text,
@@ -83,7 +77,7 @@ export async function compact_session_recorder_messages(input: {
83
77
  conversation_text: conversation_text || "(none)",
84
78
  });
85
79
 
86
- let summary = "";
80
+ let summary: string;
87
81
  try {
88
82
  const result = await generateText({
89
83
  model: input.compact_input.model,
@@ -91,74 +85,55 @@ export async function compact_session_recorder_messages(input: {
91
85
  prompt,
92
86
  });
93
87
  summary = String(result.text || "").trim();
94
- } catch {
95
- summary = "Earlier conversation was compacted because the context window was exceeded.";
88
+ } catch (error) {
89
+ await publish_action(input.compact_input.onAction, {
90
+ id: action_id,
91
+ session_id: input.recorder.session_id,
92
+ title: "Session message compaction failed",
93
+ description: error instanceof Error ? error.message : String(error),
94
+ state: "failed",
95
+ });
96
+ return { compacted: false, reason: "summary_failed" };
97
+ }
98
+ if (!summary) {
99
+ await publish_action(input.compact_input.onAction, {
100
+ id: action_id,
101
+ session_id: input.recorder.session_id,
102
+ title: "Session message compaction failed",
103
+ description: "The compaction model returned an empty Summary.",
104
+ state: "failed",
105
+ });
106
+ return { compacted: false, reason: "summary_failed" };
96
107
  }
97
108
 
98
- await input.recorder.append_completed_assistant_message({
99
- turn_id: `compact:${input.recorder.session_id}:${generateId()}`,
100
- kind: "summary",
101
- visibility: "internal",
102
- summary_through_message_id: boundary.message_id,
103
- parts: [{
104
- part_id: `summary-text:${generateId()}`,
105
- sequence: 1,
106
- type: "text",
109
+ await input.recorder.compact_active({
110
+ through_sequence: boundary.sequence,
111
+ summary: {
112
+ record_type: "summary",
113
+ session_id: input.recorder.session_id,
114
+ summary_id: `summary:${input.recorder.session_id}:${generateId()}`,
115
+ through_sequence: boundary.sequence,
107
116
  text: summary,
108
- state: "done",
109
- }],
117
+ created_at: Date.now(),
118
+ },
110
119
  });
111
120
  await publish_action(input.compact_input.onAction, {
112
121
  id: action_id,
113
122
  session_id: input.recorder.session_id,
114
123
  title: "Session messages compacted",
115
- description: `Compacted through Message ${boundary.message_id}.`,
124
+ description: `Closed Active through Message ${boundary.message_id}.`,
116
125
  state: "completed",
117
126
  });
118
127
  return { compacted: true };
119
128
  }
120
129
 
121
- function project_current_context(messages: SessionMessage[]): SessionMessage[] {
122
- const latest_summary = [...messages].reverse().find(
123
- (message): message is SessionAssistantMessage =>
124
- message.type === "assistant" &&
125
- message.kind === "summary" &&
126
- message.visibility === "internal",
127
- );
128
- if (!latest_summary?.summary_through_message_id) {
129
- return messages.filter(
130
- (message) => message.type === "user" || message.type === "assistant",
131
- );
132
- }
133
- const boundary_index = messages.findIndex(
134
- (message) => message.message_id === latest_summary.summary_through_message_id,
135
- );
136
- return [
137
- latest_summary,
138
- ...messages.slice(boundary_index + 1).filter(
139
- (message) =>
140
- message !== latest_summary &&
141
- (message.type === "user" ||
142
- message.type === "assistant" && message.kind !== "summary"),
143
- ),
144
- ];
145
- }
146
-
147
130
  function message_to_compaction_text(message: SessionMessage): string {
148
131
  const projected = to_executor_ui_message(message);
149
132
  if (!projected) return "";
150
- const text = projected.parts
151
- .flatMap((part) => part.type === "text" ? [String(part.text || "")] : [])
152
- .join("\n")
153
- .trim();
154
- return text ? `${message.type}: ${text}` : "";
155
- }
156
-
157
- function assistant_text(message: SessionAssistantMessage): string {
158
- return message.parts
159
- .flatMap((part) => part.type === "text" ? [part.text] : [])
160
- .join("\n")
161
- .trim();
133
+ return JSON.stringify({
134
+ role: projected.role,
135
+ parts: projected.parts,
136
+ });
162
137
  }
163
138
 
164
139
  async function publish_action(
@@ -168,7 +143,7 @@ async function publish_action(
168
143
  session_id: string;
169
144
  title: string;
170
145
  description?: string;
171
- state: "running" | "completed";
146
+ state: "running" | "completed" | "failed";
172
147
  },
173
148
  ): Promise<void> {
174
149
  if (!callback) return;
@@ -2,7 +2,7 @@
2
2
  * SessionRecorder 到 Executor SessionHistoryStore 的内部投影适配器。
3
3
  *
4
4
  * 关键点(中文)
5
- * - Recorder 的 messages.jsonl 与 assistant_message.json 共同组成持久化事实源。
5
+ * - Recorder 的 active.jsonl、segments 与 assistant_message.json 共同组成持久化事实源。
6
6
  * - Executor 继续读取临时 UIMessage 投影,不直接拥有文件写入权。
7
7
  * - 流式草稿写入由 AssistantMessageWriter 负责,Executor 的旧接口只提供投影兼容。
8
8
  */
@@ -111,11 +111,8 @@ export class SessionRecorderHistoryStore implements SessionHistoryStore {
111
111
 
112
112
  /** 从 Recorder 当前快照投影运行中的 Assistant。 */
113
113
  async read_inflight(): Promise<SessionRecordV1 | null> {
114
- const page = await this.recorder.list_messages({
115
- limit: 500,
116
- include_internal: true,
117
- });
118
- const assistant = [...page.items]
114
+ const snapshot = await this.recorder.context_snapshot();
115
+ const assistant = [...snapshot.messages]
119
116
  .reverse()
120
117
  .find(
121
118
  (message) =>
@@ -132,15 +129,32 @@ export class SessionRecorderHistoryStore implements SessionHistoryStore {
132
129
 
133
130
  /** 返回 Context Composer 使用的 UIMessage 投影。 */
134
131
  async list_records(): Promise<SessionRecordV1[]> {
135
- const page = await this.recorder.list_messages({
136
- limit: 500,
137
- include_internal: true,
138
- });
139
- const messages = project_context_messages(page.items);
140
- return messages.flatMap((message) => {
132
+ const snapshot = await this.recorder.context_snapshot();
133
+ const records: SessionRecordV1[] = [];
134
+ if (snapshot.summary) {
135
+ records.push({
136
+ id: snapshot.summary.summary_id,
137
+ role: "assistant",
138
+ metadata: {
139
+ v: 1,
140
+ ts: snapshot.summary.created_at,
141
+ sessionId: this.sessionId,
142
+ source: "compact",
143
+ kind: "summary",
144
+ extra: {
145
+ visibility: "internal",
146
+ summaryThroughSequence: snapshot.summary.through_sequence,
147
+ },
148
+ },
149
+ parts: [{ type: "text", text: snapshot.summary.text }],
150
+ });
151
+ }
152
+ for (const message of snapshot.messages) {
153
+ if (message.type !== "user" && message.type !== "assistant") continue;
141
154
  const projected = to_executor_ui_message(message);
142
- return projected ? [projected] : [];
143
- });
155
+ if (projected) records.push(projected);
156
+ }
157
+ return records;
144
158
  }
145
159
 
146
160
  /** 返回 Context Message 区间。 */
@@ -158,7 +172,7 @@ export class SessionRecorderHistoryStore implements SessionHistoryStore {
158
172
  return {};
159
173
  }
160
174
 
161
- /** 通过 Recorder 追加 internal summary,不重写 Mutation 历史。 */
175
+ /** 通过 Recorder 把 Active 前缀关闭为带累计 Summary 的 Segment。 */
162
176
  async compact(input: SessionHistoryCompactInput): Promise<{
163
177
  compacted: boolean;
164
178
  reason?: string;
@@ -237,40 +251,6 @@ export class SessionRecorderHistoryStore implements SessionHistoryStore {
237
251
  }
238
252
  }
239
253
 
240
- function project_context_messages(messages: import("@/types/session/SessionMessage.js").SessionMessage[]) {
241
- const latest_summary = [...messages]
242
- .reverse()
243
- .find(
244
- (message) =>
245
- message.type === "assistant" &&
246
- message.kind === "summary" &&
247
- message.visibility === "internal",
248
- );
249
- if (
250
- !latest_summary ||
251
- latest_summary.type !== "assistant" ||
252
- !latest_summary.summary_through_message_id
253
- ) {
254
- return messages.filter(
255
- (message) => message.type === "user" || message.type === "assistant",
256
- );
257
- }
258
- const boundary_index = messages.findIndex(
259
- (message) => message.message_id === latest_summary.summary_through_message_id,
260
- );
261
- return [
262
- latest_summary,
263
- ...messages
264
- .slice(boundary_index + 1)
265
- .filter(
266
- (message) =>
267
- message !== latest_summary &&
268
- message.type === "user" ||
269
- (message.type === "assistant" && message.kind !== "summary"),
270
- ),
271
- ];
272
- }
273
-
274
254
  function infer_action_type(message_id: string): string {
275
255
  return String(message_id || "").split(":")[0] || "action";
276
256
  }