@co0ontty/wand 4.100.0 → 4.101.0

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 (145) hide show
  1. package/dist/ai-team-runner.d.ts +1 -1
  2. package/dist/ai-team-runner.js +3 -3
  3. package/dist/build-info.json +3 -3
  4. package/dist/conversation-service.d.ts +11 -1
  5. package/dist/conversation-service.js +56 -9
  6. package/dist/conversation-sync-contract.d.ts +27 -0
  7. package/dist/conversation-sync-contract.js +23 -0
  8. package/dist/conversation-sync.d.ts +11 -0
  9. package/dist/conversation-sync.js +85 -0
  10. package/dist/conversation-types.d.ts +1 -0
  11. package/dist/server-conversation-routes.js +17 -0
  12. package/dist/server-session-context.js +1 -1
  13. package/dist/server-session-list-routes.js +2 -2
  14. package/dist/server.js +4 -1
  15. package/dist/session-registry.d.ts +6 -1
  16. package/dist/session-registry.js +29 -0
  17. package/dist/web-ui/content/ai-teams.js +11 -11
  18. package/dist/web-ui/content/module-manifest.json +1 -1
  19. package/dist/web-ui/content/modules/{5d4915f6e1661d23 → 08c7b047195f138d}/chunk.js +1 -1
  20. package/dist/web-ui/content/modules/{621b2383696b9d7d → 09ef1bb1d195d727}/host.js +1 -1
  21. package/dist/web-ui/content/modules/{d1185edafb2a3fc6 → 119874145414688d}/shell-file-panel-drawer.js +1 -1
  22. package/dist/web-ui/content/modules/{e2263673e53a298c → 15bcc550ef9fd5b7}/host.js +1 -1
  23. package/dist/web-ui/content/modules/{b9b78d6b75945e66 → 1a7303b87763fd43}/employee-profile.js +1 -1
  24. package/dist/web-ui/content/modules/{bbe7c6f6eda87002 → 1d35739f634745f6}/task-board-host.js +1 -1
  25. package/dist/web-ui/content/modules/2099d37d57856cbd/brand-identity.js +1 -0
  26. package/dist/web-ui/content/modules/{bc56ae7841b21ca6 → 222f48948517167e}/chunk.js +1 -1
  27. package/dist/web-ui/content/modules/{51fc769946593cfa → 2981483671797b92}/chunk.js +1 -1
  28. package/dist/web-ui/content/modules/2d6339b2f6c2e2d0/host.js +1 -0
  29. package/dist/web-ui/content/modules/{a04036215e751a32 → 2edc19639ac1bd4b}/chunk.js +1 -1
  30. package/dist/web-ui/content/modules/2f88fc37c6105f7e/sidebar-peek.js +1 -0
  31. package/dist/web-ui/content/modules/31124fb7ee6939df/host.js +1 -0
  32. package/dist/web-ui/content/modules/31b28c5d0019c363/host-bridge.js +1 -0
  33. package/dist/web-ui/content/modules/{784eef1d52221e7d → 321bd9c755bc30fc}/chunk.js +1 -1
  34. package/dist/web-ui/content/modules/{e7780cd2afeb29d9 → 32c461164a1b364c}/chunk.js +1 -1
  35. package/dist/web-ui/content/modules/{3b975071451386a4 → 35a8fd2cfd31d7a0}/chunk.js +1 -1
  36. package/dist/web-ui/content/modules/{ca37cc312813dd4f → 3601736ae2d678ef}/chunk.js +1 -1
  37. package/dist/web-ui/content/modules/{dfb9876d670c5365 → 36bd608366c56b72}/chunk.js +1 -1
  38. package/dist/web-ui/content/modules/{a919e64032fe3acc → 36ce8123a2079807}/chunk.js +1 -1
  39. package/dist/web-ui/content/modules/{371e120f3773f783 → 36e5c3a6d6705358}/employee-card.js +1 -1
  40. package/dist/web-ui/content/modules/3700d99c54d3c400/host.js +1 -0
  41. package/dist/web-ui/content/modules/{3d0ec18974ad2b11 → 38917c19e2cf1702}/chunk.js +1 -1
  42. package/dist/web-ui/content/modules/3ae29086b227f9d9/markdown.js +1 -0
  43. package/dist/web-ui/content/modules/{07b7b195f2e59a16 → 42c439d3acfe22bd}/chunk.js +1 -1
  44. package/dist/web-ui/content/modules/{258f0487e8621dcc → 42db858e4417c702}/chunk.js +1 -1
  45. package/dist/web-ui/content/modules/443dc81eac29f233/host.js +1 -0
  46. package/dist/web-ui/content/modules/{48b00eccb3067884 → 456634525d44777d}/chunk.js +1 -1
  47. package/dist/web-ui/content/modules/{96f4a041d5cf046b → 47419445e0aaa3cc}/chunk.js +1 -1
  48. package/dist/web-ui/content/modules/{2409b362681d91d6 → 476ff7911bce1a38}/chunk.js +1 -1
  49. package/dist/web-ui/content/modules/{950a2f28bc128f94 → 4a9a3e1f87b2c32b}/dialog-modal.js +1 -1
  50. package/dist/web-ui/content/modules/{405a98ab11edb2c1 → 4af10e1f8a789204}/runtime.js +54 -54
  51. package/dist/web-ui/content/modules/{0cb64ca968a6d25d → 4b9deeeb77570db3}/chunk.js +1 -1
  52. package/dist/web-ui/content/modules/{280457daf6b6fe61 → 4d4870f20eb1c52d}/chunk.js +1 -1
  53. package/dist/web-ui/content/modules/{3d050c621f552e20 → 4f30a913cc5b3f89}/chunk.js +1 -1
  54. package/dist/web-ui/content/modules/{a1e9344b3fbd11ae → 5395fa57a47e9b96}/chunk.js +1 -1
  55. package/dist/web-ui/content/modules/{0a04baa27e4fc9e3 → 544dc812abdfbbb8}/chunk.js +1 -1
  56. package/dist/web-ui/content/modules/{9638c7ba97db8755 → 5513bcbacdf44588}/notice-view.js +1 -1
  57. package/dist/web-ui/content/modules/{b3c8d461cf58ea32 → 55b75ebcf6924001}/host.js +1 -1
  58. package/dist/web-ui/content/modules/{9a12d5f9e5fef729 → 58836e616e6d12ff}/workspaces-panel.js +1 -1
  59. package/dist/web-ui/content/modules/{f555ceb3720822c5 → 5c94fb2b7abcd1a5}/chunk.js +1 -1
  60. package/dist/web-ui/content/modules/{a763fe780816708f → 5df1b522f5d3c99d}/host.js +1 -1
  61. package/dist/web-ui/content/modules/{73a96d56bc1484de → 622a8d12c4c622bf}/host.js +1 -1
  62. package/dist/web-ui/content/modules/626c7ce0aa1714b0/host.js +1 -0
  63. package/dist/web-ui/content/modules/{b0b078bcfc9085a6 → 665e81fe210f1b7a}/chunk.js +1 -1
  64. package/dist/web-ui/content/modules/{26b6ff004618750f → 6f341fe3e8aee844}/chunk.js +1 -1
  65. package/dist/web-ui/content/modules/{3d1f076ddfc09771 → 72b13a7486b6d437}/chunk.js +1 -1
  66. package/dist/web-ui/content/modules/{a9114b269e25b3f5 → 7375574a2ea42740}/chunk.js +1 -1
  67. package/dist/web-ui/content/modules/76adde4019731df1/chunk.js +1 -0
  68. package/dist/web-ui/content/modules/{8676143ae232d8b6 → 78a46adbf9807f89}/chunk.js +1 -1
  69. package/dist/web-ui/content/modules/{0bd3cebfdbc60d19 → 7925b65867b7aa4a}/queue-view-adapter.js +1 -1
  70. package/dist/web-ui/content/modules/{2837772e09c2b62d → 8198bfd269c0caca}/chunk.js +1 -1
  71. package/dist/web-ui/content/modules/{ea7cd328cb5cf7e0 → 82e40fdf0abfb8cd}/chunk.js +1 -1
  72. package/dist/web-ui/content/modules/{5e50a3c3c546d824 → 84b79a33c64e146c}/chunk.js +1 -1
  73. package/dist/web-ui/content/modules/8a4f0ef2c0812af6/chunk.js +4 -0
  74. package/dist/web-ui/content/modules/{8c4258e7c35e9eca → 8a6844a4251a1271}/chunk.js +1 -1
  75. package/dist/web-ui/content/modules/{da8314e267c164ed → 8bd214570df4a7a0}/host.js +1 -1
  76. package/dist/web-ui/content/modules/{29c912ef59696172 → 8bff85e108320aab}/panel.js +1 -1
  77. package/dist/web-ui/content/modules/{5444c4f9260fcb2f → 8ce71665ae55ccf1}/toast-region.js +1 -1
  78. package/dist/web-ui/content/modules/{ad896e0a52316ead → 8e29056fb56439f7}/chunk.js +1 -1
  79. package/dist/web-ui/content/modules/{d0a0f9214f3bbf71 → 905f8bc3f8a3efb8}/chunk.js +1 -1
  80. package/dist/web-ui/content/modules/{f248ee3b31df4034 → 96dd661559b67ebf}/chunk.js +1 -1
  81. package/dist/web-ui/content/modules/988e01b67e6f1883/composer-surface-adapter.js +1 -0
  82. package/dist/web-ui/content/modules/{c4705a6af423d824 → 98c14fc0474455a5}/chunk.js +1 -1
  83. package/dist/web-ui/content/modules/{a2263c03ce67440a → 9d48d1ea3c9228bd}/library-bridge.js +1 -1
  84. package/dist/web-ui/content/modules/{23eea6861975478b → 9e754b01fa4207fb}/chunk.js +1 -1
  85. package/dist/web-ui/content/modules/{ddcaaa62f1b7f049 → 9f33acc1c4284e03}/chunk.js +1 -1
  86. package/dist/web-ui/content/modules/{b9445d806edca559 → a098c7b1aa4e2f6b}/sha256.js +1 -1
  87. package/dist/web-ui/content/modules/{abe3642ec4dba601 → a4af270960a39ea6}/chunk.js +1 -1
  88. package/dist/web-ui/content/modules/{91f4ec7a3631129c → a4dca2ffaf656d3c}/chunk.js +1 -1
  89. package/dist/web-ui/content/modules/{97faa837aeaaff64 → a975ff38c3d43ba8}/workspace-window.js +1 -1
  90. package/dist/web-ui/content/modules/{2575e08b75b1a192 → aab92f2990a7185f}/chunk.js +1 -1
  91. package/dist/web-ui/content/modules/{e8f9f6d46ef4bb53 → acb21c7e7fd6cb67}/page.js +1 -1
  92. package/dist/web-ui/content/modules/{23178d02e631d065 → ad1136919a65fc6e}/host.js +1 -1
  93. package/dist/web-ui/content/modules/{007197d061c2a565 → b0254a2d3652a925}/chunk.js +1 -1
  94. package/dist/web-ui/content/modules/{4ca8f7b400b83127 → b0c4eed46c1b6ad4}/chat-render.js +1 -1
  95. package/dist/web-ui/content/modules/{7aab1455fc7348e1 → b1d43ed06a5d1a57}/chunk.js +1 -1
  96. package/dist/web-ui/content/modules/{4718bec8ce29956e → b38924f45bfa71d6}/chunk.js +1 -1
  97. package/dist/web-ui/content/modules/b550260fc9c5363a/dropdown-surface.js +1 -0
  98. package/dist/web-ui/content/modules/{c2e0b78e4a6b1a18 → b62294e53dcb3147}/host.js +1 -1
  99. package/dist/web-ui/content/modules/{9a4f713719354002 → b937f1a939618be9}/host.js +1 -1
  100. package/dist/web-ui/content/modules/{65e98a2bcd16cd79 → bce467c603299460}/chunk.js +1 -1
  101. package/dist/web-ui/content/modules/c138b442c1682189/panel-host.js +1 -0
  102. package/dist/web-ui/content/modules/{93b428761a02ca66 → c152782274c04fad}/chunk.js +1 -1
  103. package/dist/web-ui/content/modules/c32d14632487364f/group-editor.js +1 -0
  104. package/dist/web-ui/content/modules/{0b00a661913662d4 → c422f47fd13dc4bc}/chunk.js +1 -1
  105. package/dist/web-ui/content/modules/{0500b57b86317246 → c6d0a1b2753a4883}/host.js +1 -1
  106. package/dist/web-ui/content/modules/{28795c8bb7f4e2e8 → c72c4f1d47c3726d}/chunk.js +1 -1
  107. package/dist/web-ui/content/modules/{2aa0bc0eba53a5c9 → c81a3d95b14cdcd4}/chunk.js +1 -1
  108. package/dist/web-ui/content/modules/{0128a9a2c361da59 → cd22aa55f6cc5276}/chunk.js +1 -1
  109. package/dist/web-ui/content/modules/cdff6e7f72879c88/progress.js +1 -0
  110. package/dist/web-ui/content/modules/{0206d2425a3eb375 → cf715621edb1d12e}/chunk.js +1 -1
  111. package/dist/web-ui/content/modules/cfee7b0d79587d59/home.js +6 -0
  112. package/dist/web-ui/content/modules/{82f9fed7fd7ffd9f → e33f6d66a02ec26c}/chunk.js +1 -1
  113. package/dist/web-ui/content/modules/{fd2a62ffb5f81f09 → e5e93fe5304838a1}/chunk.js +1 -1
  114. package/dist/web-ui/content/modules/{cdb1909af2179a41 → e61136c22675ec7d}/chunk.js +1 -1
  115. package/dist/web-ui/content/modules/{f41d42a01dc09373 → e95b60ab18a79499}/login-visual.js +1 -1
  116. package/dist/web-ui/content/modules/{a120182be2ef9632 → eecaebc5482ee203}/chunk.js +1 -1
  117. package/dist/web-ui/content/modules/{b0d4d1f8ff0fae38 → eece3c2d2374e8f7}/chunk.js +1 -1
  118. package/dist/web-ui/content/modules/{4dd52ad71d002eab → ef7f76b551fdc619}/chunk.js +1 -1
  119. package/dist/web-ui/content/modules/{45e8d613816cdec5 → f55031a02775c89e}/chunk.js +1 -1
  120. package/dist/web-ui/content/modules/{9a0704236531e38e → f6f86d43c12b0864}/chunk.js +1 -1
  121. package/dist/web-ui/content/modules/{37767c3c41b8d8a8 → f90f80d7207dff1f}/host.js +1 -1
  122. package/dist/web-ui/content/modules/fbe35e7ddd7384fa/host.js +1 -0
  123. package/dist/web-ui/embedded-assets.d.ts +407 -407
  124. package/dist/web-ui/embedded-assets.js +4 -4
  125. package/dist/ws-broadcast.d.ts +2 -1
  126. package/dist/ws-broadcast.js +1 -1
  127. package/package.json +1 -1
  128. package/dist/web-ui/content/modules/0261c50fee52b437/host.js +0 -1
  129. package/dist/web-ui/content/modules/05889845b99c95dd/host.js +0 -1
  130. package/dist/web-ui/content/modules/19a7b932484a64ca/host-bridge.js +0 -1
  131. package/dist/web-ui/content/modules/1f88294162059296/home.js +0 -6
  132. package/dist/web-ui/content/modules/2be38bfbed614f65/host.js +0 -1
  133. package/dist/web-ui/content/modules/360db1263b60f857/composer-surface-adapter.js +0 -1
  134. package/dist/web-ui/content/modules/364b10aceecf7ab9/panel-host.js +0 -1
  135. package/dist/web-ui/content/modules/5f4ea7ad706b19f7/progress.js +0 -1
  136. package/dist/web-ui/content/modules/71205ec0b1cf554d/host.js +0 -1
  137. package/dist/web-ui/content/modules/76b953fe1f34c421/dropdown-surface.js +0 -1
  138. package/dist/web-ui/content/modules/8492b5592532e563/chunk.js +0 -1
  139. package/dist/web-ui/content/modules/890cdc256b49e26c/host.js +0 -1
  140. package/dist/web-ui/content/modules/9d7b92a76b2d54ab/sidebar-peek.js +0 -1
  141. package/dist/web-ui/content/modules/a48f540d6a228012/chunk.js +0 -4
  142. package/dist/web-ui/content/modules/c8ee697d1dd6e302/group-editor.js +0 -1
  143. package/dist/web-ui/content/modules/d948ac122dc34acc/markdown.js +0 -1
  144. package/dist/web-ui/content/modules/edb31a89f667eddf/brand-identity.js +0 -1
  145. package/dist/web-ui/content/modules/f9111c80a2c26147/host.js +0 -1
@@ -189,7 +189,7 @@ export declare class AiTeamRunner {
189
189
  chatInput(sessionId: string, text: string): Promise<void>;
190
190
  /** Explicit task target; ordinary group talk never implicitly approves/stops/starts a run. */
191
191
  inputForRun(runId: string, text: string): Promise<AiTeamRunDetail>;
192
- detail(runId: string): AiTeamRunDetail;
192
+ detail(runId: string, relayTurns?: ConversationTurn[]): AiTeamRunDetail;
193
193
  listForTask(taskId: string): AiTeamRun[];
194
194
  /**
195
195
  * 此刻正在干活的步骤的实时文本(§4.9)。只覆盖 running 且有会话的步骤,按 seq 升序;
@@ -531,7 +531,7 @@ export class AiTeamRunner {
531
531
  this.saveRun(current);
532
532
  });
533
533
  }
534
- detail(runId) {
534
+ detail(runId, relayTurns) {
535
535
  const run = this.storage.getAiTeamRun(runId);
536
536
  if (!run)
537
537
  throw new Error("团队运行不存在。");
@@ -547,8 +547,8 @@ export class AiTeamRunner {
547
547
  // 群聊回合只给最近这一段(§4.4);没有群聊会话的旧运行给空数组,前端不用判 null。
548
548
  // 有对话归属时 ConversationService 已投影用户署名;旧运行只回放 relay 快照,在这里补同一口径。
549
549
  const self = this.selfAuthorOf;
550
- const messages = (run.conversationId && this.chatHistoryTurns ? this.chatHistoryTurns(run.conversationId)
551
- : run.chatSessionId ? this.ops.snapshot(run.chatSessionId)?.messages ?? [] : [])
550
+ const messages = (relayTurns ?? (run.conversationId && this.chatHistoryTurns ? this.chatHistoryTurns(run.conversationId)
551
+ : run.chatSessionId ? this.ops.snapshot(run.chatSessionId)?.messages ?? [] : []))
552
552
  .map((turn) => (turn.role === "user" && !turn.author && self ? { ...turn, author: self() } : turn));
553
553
  const currentTeam = this.storage.getAiTeam(run.teamId);
554
554
  // A relay belongs to a conversation, not its newest task. Always use the run's task.
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "b7d273f3dbcfed836b48f5321b9b1645910731f5",
3
- "builtAt": "2026-10-21T06:25:16.318Z",
4
- "version": "4.100.0",
2
+ "commit": "47bb7f819edbd778640501e0c80eeec6b6f3a8cd",
3
+ "builtAt": "2026-10-21T13:30:56.402Z",
4
+ "version": "4.101.0",
5
5
  "channel": "stable"
6
6
  }
@@ -1,3 +1,4 @@
1
+ import { type ConversationSyncQuery } from "./conversation-sync.js";
1
2
  import { type AiTeamRun } from "./ai-team-types.js";
2
3
  import { type AiTeamRunner } from "./ai-team-runner.js";
3
4
  import { type ConversationDetail, type ConversationInstance, type ConversationReceipt, type ConversationSummary, type ConversationTarget, type ConversationSessionUpdate } from "./conversation-types.js";
@@ -37,11 +38,19 @@ export declare class ConversationService {
37
38
  config: WandConfig;
38
39
  deleteSession?(id: string): void;
39
40
  notifySession?(update: ConversationSessionUpdate): void;
41
+ notifyChanged?(): void;
40
42
  });
41
43
  private readonly removals;
42
44
  private readonly pendingOperations;
43
45
  private readonly previewTimers;
44
46
  private readonly legacyChannels;
47
+ private liveRevision;
48
+ private noticePending;
49
+ private changed;
50
+ private readSnapshots;
51
+ private readonly projections;
52
+ private readonly synchronizer;
53
+ sync(id: string, query: ConversationSyncQuery): import("./conversation-sync-contract.js").ConversationSync;
45
54
  /** Reuse session events; send only a bounded IM projection, never a second execution protocol. */
46
55
  ingestSessionEvent(event: ProcessEvent): void;
47
56
  dispose(): void;
@@ -73,7 +82,8 @@ export declare class ConversationService {
73
82
  remove(id: string): Promise<void>;
74
83
  private removeOwnedResources;
75
84
  history(id: string): ConversationTurn[];
76
- detail(id: string): ConversationDetail;
85
+ detail(id: string, select?: (turns: ConversationTurn[]) => ConversationTurn[]): ConversationDetail;
86
+ private readDetail;
77
87
  /** Read-only, bounded projection of the exact accepted run. Never follow a group's newest task. */
78
88
  private taskPreview;
79
89
  private groupTeam;
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { ConversationSynchronizer } from "./conversation-sync.js";
2
3
  import { statSync, rmSync, existsSync, realpathSync } from "node:fs";
3
4
  import { isAbsolute, resolve, sep } from "node:path";
4
5
  import { AI_TEAM_ACTIVE_RUN_STATUSES, AI_TEAM_DEFAULT_MAX_STEPS, AI_TEAM_MAX_MEMBERS, memberAgents } from "./ai-team-types.js";
@@ -56,8 +57,21 @@ export class ConversationService {
56
57
  pendingOperations = new Map();
57
58
  previewTimers = new Map();
58
59
  legacyChannels = new Set();
60
+ liveRevision = 0;
61
+ noticePending = false;
62
+ changed() {
63
+ if (this.noticePending)
64
+ return;
65
+ this.noticePending = true;
66
+ queueMicrotask(() => { this.noticePending = false; this.deps.notifyChanged?.(); });
67
+ }
68
+ readSnapshots = null;
69
+ projections = new Map();
70
+ synchronizer = new ConversationSynchronizer((id, select) => this.detail(id, select));
71
+ sync(id, query) { return this.synchronizer.sync(id, query); }
59
72
  /** Reuse session events; send only a bounded IM projection, never a second execution protocol. */
60
73
  ingestSessionEvent(event) {
74
+ this.liveRevision++;
61
75
  const session = this.deps.structured.get(event.sessionId);
62
76
  if (session)
63
77
  this.importEmployeeSession(session);
@@ -85,10 +99,17 @@ export class ConversationService {
85
99
  for (const timer of this.previewTimers.values())
86
100
  clearTimeout(timer);
87
101
  this.previewTimers.clear();
102
+ this.projections.clear();
88
103
  }
89
104
  get storage() { return this.deps.storage; }
90
105
  snapshot(id) {
91
- return id ? this.deps.structured.get(id) ?? this.storage.getSession(id) : null;
106
+ if (!id)
107
+ return null;
108
+ if (this.readSnapshots?.has(id))
109
+ return this.readSnapshots.get(id);
110
+ const snapshot = this.deps.structured.get(id) ?? this.storage.getSession(id);
111
+ this.readSnapshots?.set(id, snapshot);
112
+ return snapshot;
92
113
  }
93
114
  /** Explicit startup/event migration, never a GET repair and never an invented group. */
94
115
  importRun(run) {
@@ -308,6 +329,7 @@ export class ConversationService {
308
329
  if (patch.dissolved !== undefined && instance.kind !== "group")
309
330
  throw new Error("只有群聊可以解散或恢复。");
310
331
  this.storage.updateConversationListState(id, { ...patch, dissolvedBy: this.selfName() });
332
+ this.changed();
311
333
  return this.summary(instance);
312
334
  }
313
335
  /** Delete only this conversation's owned channels and, for groups, its linked task resources. */
@@ -318,7 +340,7 @@ export class ConversationService {
318
340
  const removal = this.removeOwnedResources(id);
319
341
  this.removals.set(id, removal);
320
342
  void removal.finally(() => { if (this.removals.get(id) === removal)
321
- this.removals.delete(id); }).catch(() => { });
343
+ this.removals.delete(id); this.changed(); }).catch(() => { });
322
344
  return removal;
323
345
  }
324
346
  async removeOwnedResources(id) {
@@ -383,17 +405,39 @@ export class ConversationService {
383
405
  this.storage.deleteConversation(id);
384
406
  }
385
407
  history(id) { return this.turns(this.requireInstance(id)); }
386
- detail(id) {
387
- const instance = this.requireInstance(id);
388
- const turns = this.turns(instance);
389
- const summary = this.summary(instance, turns);
390
- return { ...summary, messages: turns.map(turn => turn.sessionLink
408
+ detail(id, select = turns => turns) {
409
+ const previous = this.readSnapshots;
410
+ this.readSnapshots = previous ?? new Map();
411
+ try {
412
+ return this.readDetail(id, select);
413
+ }
414
+ finally {
415
+ this.readSnapshots = previous;
416
+ }
417
+ }
418
+ readDetail(id, select) {
419
+ const revision = `${this.storage.projectionRevision()}:${this.liveRevision}:${JSON.stringify(this.deps.config.userProfile ?? {})}`;
420
+ let projection = this.projections.get(id);
421
+ if (projection?.revision !== revision) {
422
+ const instance = this.requireInstance(id);
423
+ const turns = this.turns(instance);
424
+ const summary = this.summary(instance, turns);
425
+ projection = { revision, instance, turns, summary, bytes: Buffer.byteLength(JSON.stringify(turns)) };
426
+ this.projections.delete(id);
427
+ if (projection.bytes <= 8 * 1024 * 1024)
428
+ this.projections.set(id, projection);
429
+ while (this.projections.size > 12 || [...this.projections.values()].reduce((n, p) => n + p.bytes, 0) > 16 * 1024 * 1024) {
430
+ this.projections.delete(this.projections.keys().next().value);
431
+ }
432
+ }
433
+ const { summary, turns } = projection;
434
+ return { ...summary, messages: select(turns).map(turn => turn.sessionLink
391
435
  ? { ...turn, sessionPreview: conversationSessionPreview(this.snapshot(turn.sessionLink.sessionId), turn.requestId ? this.receipt(turn.requestId) ?? undefined : undefined),
392
436
  // Older clients can still open the exact source session without understanding sessionLink.
393
437
  author: turn.author ? { ...turn.author, sessionId: turn.sessionLink.sessionId } : turn.author,
394
438
  content: turn.content.length ? turn.content : [{ type: "text", text: "消息已接收,打开对应会话查看实时回复。" }] }
395
439
  : this.taskPreview(turn)),
396
- runDetails: summary.tasks.flatMap((task) => task.runs.map((run) => this.deps.runner.detail(run.id))) };
440
+ runDetails: projection.runDetails ??= summary.tasks.flatMap((task) => task.runs.map((run) => this.deps.runner.detail(run.id, turns))) };
397
441
  }
398
442
  /** Read-only, bounded projection of the exact accepted run. Never follow a group's newest task. */
399
443
  taskPreview(turn) {
@@ -497,7 +541,10 @@ export class ConversationService {
497
541
  return existing.receipt; // Pending survives restart: never implicitly execute twice.
498
542
  }
499
543
  const receipt = { requestId, state: "pending", conversationId: id };
500
- const commit = () => this.storage.saveConversationRequest({ id: requestId, fingerprint, receipt, updatedAt: now() });
544
+ const commit = () => {
545
+ this.storage.saveConversationRequest({ id: requestId, fingerprint, receipt, updatedAt: now() });
546
+ this.changed();
547
+ };
501
548
  commit();
502
549
  try {
503
550
  await operation(receipt, commit);
@@ -0,0 +1,27 @@
1
+ import type { ConversationDetail } from "./conversation-types.js";
2
+ import type { ConversationTurn } from "./types.js";
3
+ export interface ConversationHistory {
4
+ revision: string;
5
+ from: string | null;
6
+ hasEarlier: boolean;
7
+ total: number;
8
+ }
9
+ export interface ConversationSync {
10
+ id: string;
11
+ history: ConversationHistory;
12
+ unchanged?: boolean;
13
+ reset?: boolean;
14
+ detail?: Omit<ConversationDetail, "messages">;
15
+ messages?: ConversationTurn[];
16
+ messageOrder?: string[];
17
+ }
18
+ export interface ConversationSyncQuery {
19
+ since?: string;
20
+ from?: string;
21
+ older?: boolean;
22
+ limit?: number;
23
+ }
24
+ /** Shared client reducer. The server's order also removes deleted or moved rows. */
25
+ export declare function mergeConversationSync(previous: ConversationDetail | null, update: ConversationSync): ConversationDetail & {
26
+ history: ConversationHistory;
27
+ };
@@ -0,0 +1,23 @@
1
+ /** Shared client reducer. The server's order also removes deleted or moved rows. */
2
+ export function mergeConversationSync(previous, update) {
3
+ if (previous && previous.id !== update.id)
4
+ previous = null;
5
+ if (update.unchanged) {
6
+ if (!previous)
7
+ throw new Error("缺少对话同步基线,请重新读取。");
8
+ return { ...previous, history: update.history };
9
+ }
10
+ const messages = new Map((update.reset ? [] : previous?.messages ?? []).map(turn => [turn.messageId, turn]));
11
+ for (const turn of update.messages ?? [])
12
+ messages.set(turn.messageId, turn);
13
+ const ordered = (update.messageOrder ?? []).map(id => {
14
+ const turn = messages.get(id);
15
+ if (!turn)
16
+ throw new Error("对话同步缺少消息,请重新读取。");
17
+ return turn;
18
+ });
19
+ const detail = update.detail ?? previous;
20
+ if (!detail || detail.id !== update.id)
21
+ throw new Error("对话同步身份不一致。");
22
+ return { ...detail, messages: ordered, history: update.history };
23
+ }
@@ -0,0 +1,11 @@
1
+ import type { ConversationDetail } from "./conversation-types.js";
2
+ import type { ConversationTurn } from "./types.js";
3
+ import type { ConversationSync, ConversationSyncQuery } from "./conversation-sync-contract.js";
4
+ export type { ConversationSyncQuery } from "./conversation-sync-contract.js";
5
+ /** Owner-scoped, bounded diff fingerprints only: no transcript copy or persistent client state. */
6
+ export declare class ConversationSynchronizer {
7
+ private readonly read;
8
+ private readonly revisions;
9
+ constructor(read: (id: string, select: (turns: ConversationTurn[]) => ConversationTurn[]) => ConversationDetail);
10
+ sync(id: string, query?: ConversationSyncQuery): ConversationSync;
11
+ }
@@ -0,0 +1,85 @@
1
+ import { createHash } from "node:crypto";
2
+ const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
3
+ // Anchors survive deletion of the first loaded row. Never use an array offset as a durable cursor.
4
+ function key(turn) { return [turn.createdAt ?? "", turn.role === "user" ? "0" : "1", turn.messageId]; }
5
+ function compare(a, b) {
6
+ for (let i = 0; i < a.length; i++) {
7
+ const d = a[i].localeCompare(b[i]);
8
+ if (d)
9
+ return d;
10
+ }
11
+ return 0;
12
+ }
13
+ function anchor(turn) { return Buffer.from(JSON.stringify(key(turn))).toString("base64url"); }
14
+ function decode(value) {
15
+ if (!value || value.length > 2048)
16
+ return null;
17
+ try {
18
+ const parsed = JSON.parse(Buffer.from(value, "base64url").toString());
19
+ return Array.isArray(parsed) && parsed.length === 3 && parsed.every(v => typeof v === "string") ? parsed : null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /** Owner-scoped, bounded diff fingerprints only: no transcript copy or persistent client state. */
26
+ export class ConversationSynchronizer {
27
+ read;
28
+ revisions = new Map();
29
+ constructor(read) {
30
+ this.read = read;
31
+ }
32
+ sync(id, query = {}) {
33
+ let from = null, hasEarlier = false, total = 0;
34
+ const limit = Math.min(80, Math.max(1, query.limit ?? 40));
35
+ const requestedAnchor = decode(query.from);
36
+ const detail = this.read(id, raw => {
37
+ const all = raw.map((turn, i) => turn.messageId ? turn : { ...turn, messageId: `${id}:legacy:${i}` })
38
+ .sort((a, b) => compare(key(a), key(b)));
39
+ total = all.length;
40
+ let start = requestedAnchor ? all.findIndex(turn => compare(key(turn), requestedAnchor) >= 0) : Math.max(0, total - limit);
41
+ if (start < 0)
42
+ start = total;
43
+ if (query.older && requestedAnchor)
44
+ start = Math.max(0, start - limit);
45
+ // Bound the first page by bytes too. One indivisible turn may exceed this soft budget.
46
+ if (!requestedAnchor) {
47
+ let bytes = 0;
48
+ for (let i = total - 1; i >= start; i--) {
49
+ const size = Buffer.byteLength(JSON.stringify(all[i]));
50
+ if (bytes + size > 96 * 1024 && i < total - 1) {
51
+ start = i + 1;
52
+ break;
53
+ }
54
+ bytes += size;
55
+ }
56
+ }
57
+ hasEarlier = start > 0;
58
+ from = all[start] ? anchor(all[start]) : query.from ?? null;
59
+ return all.slice(start);
60
+ });
61
+ const { messages, ...metadata } = detail;
62
+ const metadataHash = hash(metadata);
63
+ const fingerprints = new Map(messages.map(turn => [turn.messageId, hash(turn)]));
64
+ const order = [...fingerprints.keys()];
65
+ if (order.length !== messages.length)
66
+ throw new Error("对话消息身份重复,无法安全同步。");
67
+ const revision = hash([id, metadataHash, from, hasEarlier, total, [...fingerprints]]);
68
+ const history = { revision, from, hasEarlier, total };
69
+ const previous = query.since ? this.revisions.get(query.since) : undefined;
70
+ this.revisions.delete(revision);
71
+ // Avoid a large reader retaining unbounded fingerprint maps; cache miss resets the
72
+ // SAME anchored window, never silently drops already loaded history.
73
+ if (order.length <= 10_000)
74
+ this.revisions.set(revision, { id, metadata: metadataHash, messages: fingerprints });
75
+ while (this.revisions.size > 64)
76
+ this.revisions.delete(this.revisions.keys().next().value);
77
+ if (query.since === revision)
78
+ return { id, history, unchanged: true };
79
+ const valid = previous?.id === id ? previous : undefined;
80
+ return { id, history, reset: !valid,
81
+ ...(valid?.metadata === metadataHash ? {} : { detail: metadata }),
82
+ messages: valid ? messages.filter(turn => valid.messages.get(turn.messageId) !== fingerprints.get(turn.messageId)) : messages,
83
+ messageOrder: order };
84
+ }
85
+ }
@@ -53,6 +53,7 @@ export interface ConversationSummary extends ConversationInstance {
53
53
  export interface ConversationDetail extends ConversationSummary {
54
54
  messages: ConversationTurn[];
55
55
  runDetails: AiTeamRunDetail[];
56
+ history?: import("./conversation-sync-contract.js").ConversationHistory;
56
57
  }
57
58
  export interface ConversationReceipt {
58
59
  requestId: string;
@@ -48,6 +48,23 @@ export function registerConversationRoutes(app, service) {
48
48
  else
49
49
  res.json(receipt);
50
50
  });
51
+ app.get("/api/conversations/:id/sync", (req, res) => {
52
+ try {
53
+ for (const key of ["since", "from"])
54
+ if (typeof req.query[key] === "string" && String(req.query[key]).length > 2048) {
55
+ throw badRequest("对话同步游标无效。");
56
+ }
57
+ res.setHeader("Cache-Control", "private, no-store");
58
+ res.json(service.sync(req.params.id, {
59
+ since: typeof req.query.since === "string" ? req.query.since : undefined,
60
+ from: typeof req.query.from === "string" ? req.query.from : undefined,
61
+ older: req.query.older === "1",
62
+ }));
63
+ }
64
+ catch (error) {
65
+ sendRouteError(res, error, "对话同步失败。", 404);
66
+ }
67
+ });
51
68
  app.get("/api/conversations/:id", (req, res) => {
52
69
  try {
53
70
  res.json(service.detail(req.params.id));
@@ -11,7 +11,7 @@ export function createSessionRouteContext(deps) {
11
11
  const { structured, storage, sessions, config, decisions } = deps;
12
12
  const wantsCompactTools = (req) => req.get("X-Wand-Tool-Projection") === "compact" || req.query.compactTools === "1";
13
13
  const sessionResponseDTO = (snapshot, req) => {
14
- const enriched = enrichStructuredMessages(snapshot.messages ?? [], snapshot.id);
14
+ const enriched = sessions.transportMessages?.(snapshot.id, snapshot.messages ?? []) ?? enrichStructuredMessages(snapshot.messages ?? [], snapshot.id);
15
15
  const windowed = windowMessagesForTransport(wantsCompactTools(req) ? compactToolMessagesForTransport(enriched) : enriched, config.cardDefaults ?? {});
16
16
  return toSessionDetailDTO(snapshot, {
17
17
  messages: windowed.messages,
@@ -219,7 +219,7 @@ export function registerSessionListRoutes(app, ctx) {
219
219
  const transcriptOutput = (snapshot.sessionKind ?? "pty") === "pty"
220
220
  ? processes.getPtyTranscript(snapshot.id) ?? snapshot.output
221
221
  : req.query.format === "chat" && req.query.output === "omit" ? "" : snapshot.output;
222
- const enriched = enrichStructuredMessages(snapshot.messages ?? [], snapshot.id);
222
+ const enriched = sessions.transportMessages?.(snapshot.id, snapshot.messages ?? []) ?? enrichStructuredMessages(snapshot.messages ?? [], snapshot.id);
223
223
  const messages = wantsCompactTools(req) ? compactToolMessagesForTransport(enriched) : enriched;
224
224
  if (req.query.format === "chat") {
225
225
  // 客户端带 blockBudget(Web/iOS)走块级窗口:只回最近 N 个块(必要时切掉最旧 turn 的头部),
@@ -260,7 +260,7 @@ export function registerSessionListRoutes(app, ctx) {
260
260
  res.status(404).json({ error: "未找到该会话,可能已被删除。" });
261
261
  return;
262
262
  }
263
- const enriched = enrichStructuredMessages(snapshot.messages ?? [], snapshot.id);
263
+ const enriched = sessions.transportMessages?.(snapshot.id, snapshot.messages ?? []) ?? enrichStructuredMessages(snapshot.messages ?? [], snapshot.id);
264
264
  const all = wantsCompactTools(req) ? compactToolMessagesForTransport(enriched) : enriched;
265
265
  const total = all.length;
266
266
  // Web history: fetch at most one block window *before* the currently
package/dist/server.js CHANGED
@@ -463,7 +463,7 @@ export async function startServer(config, configPath, options = {}) {
463
463
  defaultModelOf: (provider) => getDefaultModelForProvider(config, provider),
464
464
  });
465
465
  const conversationService = new ConversationService({ storage, config, structured: structuredSessions, runner: aiTeams,
466
- notifySession: notifier.conversationSession, deleteSession: id => { sessionRegistry.deleteWithProviderHistory(id); } });
466
+ notifySession: notifier.conversationSession, notifyChanged: () => notifier.system({ kind: "conversations-changed" }), deleteSession: id => { sessionRegistry.deleteWithProviderHistory(id); } });
467
467
  conversations = conversationService;
468
468
  conversationService.migrateExistingChats();
469
469
  const scheduledTasks = new ScheduledTaskService({ storage, conversations: conversationService,
@@ -802,6 +802,7 @@ export async function startServer(config, configPath, options = {}) {
802
802
  const wsManager = new WsBroadcastManager(wss, () => config.cardDefaults ?? {}, useHttps, authService, (req) => authenticateBearerAppToken(req, storage, config) !== null);
803
803
  wsManager.setup({
804
804
  getSession: (id) => sessionRegistry.get(id),
805
+ projectMessages: (id, messages) => sessionRegistry.transportMessages(id, messages),
805
806
  getTerminalState: (id) => processes.getTerminalState(id),
806
807
  sendPtyInput: (id, input, shortcutKey, userInput) => processes.sendInputConfirmed(id, input, "terminal", shortcutKey, userInput).then(() => { }),
807
808
  resizePty: (id, cols, rows) => {
@@ -817,6 +818,7 @@ export async function startServer(config, configPath, options = {}) {
817
818
  });
818
819
  // Wire process events to WebSocket broadcast
819
820
  processes.on("process", (event) => {
821
+ sessionRegistry.invalidateMessageProjection(event.sessionId);
820
822
  taskTitles.ingest(event);
821
823
  sessionCompletions.ingest(event);
822
824
  missions.ingest(event);
@@ -825,6 +827,7 @@ export async function startServer(config, configPath, options = {}) {
825
827
  wsManager.emitEvent(event);
826
828
  });
827
829
  structuredSessions.setEventEmitter((event) => {
830
+ sessionRegistry.invalidateMessageProjection(event.sessionId);
828
831
  taskTitles.ingest(event);
829
832
  storage.recordTaskCommentSessionEvent(event);
830
833
  conversationService.ingestSessionEvent(event);
@@ -1,7 +1,7 @@
1
1
  import type { ProcessManager } from "./process-manager.js";
2
2
  import type { WandStorage } from "./storage.js";
3
3
  import type { StructuredSessionManager } from "./structured-session-manager.js";
4
- import type { ExecutionMode, SessionSnapshot } from "./types.js";
4
+ import type { ExecutionMode, SessionSnapshot, ConversationTurn } from "./types.js";
5
5
  export type SessionOwner = "structured" | "pty" | "storage";
6
6
  export type ProviderHistoryDeleter = Pick<ProcessManager, "deleteClaudeHistoryFiles" | "deleteCodexHistoryFiles" | "deleteOpenCodeHistorySessions" | "deleteQoderHistoryFiles" | "deletePiHistoryFiles" | "deleteGrokHistoryFiles">;
7
7
  export declare function deleteProviderNativeHistory(deleter: ProviderHistoryDeleter, provider: string, providerSessionId: string, cwd: string): void;
@@ -28,6 +28,11 @@ export declare class SessionRegistry {
28
28
  private readonly processes;
29
29
  private readonly structured;
30
30
  private readonly storage;
31
+ private readonly messageProjections;
32
+ private readonly messageEpochs;
33
+ invalidateMessageProjection(id: string): void;
34
+ /** One semantic pass per source/revision, shared by HTTP and all WS readers. */
35
+ transportMessages(id: string, messages: ConversationTurn[]): ConversationTurn[];
31
36
  /** Deleted owners can still be exiting; listSlim alone no longer sees them. */
32
37
  private readonly nativeDeletionBarriers;
33
38
  constructor(processes: ProcessManager, structured: StructuredSessionManager, storage: WandStorage);
@@ -1,9 +1,11 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { isArchiveBlocked } from "./archive-policy.js";
2
3
  import { logInfo } from "./runtime-log.js";
3
4
  import { isSystemAiCall } from "./system-ai-call.js";
4
5
  import { systemAiSessions } from "./system-ai-session-recorder.js";
5
6
  import { cleanupWorktreeSync } from "./git-worktree.js";
6
7
  import { inferProviderFromCommand } from "./session-provider.js";
8
+ import { enrichStructuredMessages } from "./structured-client-protocol.js";
7
9
  export function deleteProviderNativeHistory(deleter, provider, providerSessionId, cwd) {
8
10
  const id = providerSessionId.trim();
9
11
  if (!id)
@@ -117,6 +119,33 @@ export class SessionRegistry {
117
119
  processes;
118
120
  structured;
119
121
  storage;
122
+ messageProjections = new Map();
123
+ messageEpochs = new Map();
124
+ invalidateMessageProjection(id) {
125
+ this.messageProjections.delete(id);
126
+ this.messageEpochs.set(id, (this.messageEpochs.get(id) ?? 0) + 1);
127
+ if (this.messageEpochs.size > 2048)
128
+ this.messageEpochs.delete(this.messageEpochs.keys().next().value);
129
+ }
130
+ /** One semantic pass per source/revision, shared by HTTP and all WS readers. */
131
+ transportMessages(id, messages) {
132
+ // Some live adapters append in place before the debounced event boundary. A
133
+ // content digest prevents cached semantics from crossing that mutation window.
134
+ const raw = JSON.stringify(messages);
135
+ const digest = createHash("sha256").update(raw).digest("hex");
136
+ const revision = `${this.storage.projectionRevision()}:${this.messageEpochs.get(id) ?? 0}:${digest}`;
137
+ const cached = this.messageProjections.get(id);
138
+ if (cached?.revision === revision)
139
+ return cached.projected;
140
+ const projected = enrichStructuredMessages(messages, id);
141
+ this.messageProjections.delete(id);
142
+ if (raw.length <= 1024 * 1024 && messages.length <= 2000 && messages.reduce((n, turn) => n + turn.content.length, 0) <= 10000) {
143
+ this.messageProjections.set(id, { revision, source: messages, projected });
144
+ while (this.messageProjections.size > 16)
145
+ this.messageProjections.delete(this.messageProjections.keys().next().value);
146
+ }
147
+ return projected;
148
+ }
120
149
  /** Deleted owners can still be exiting; listSlim alone no longer sees them. */
121
150
  nativeDeletionBarriers = new Map();
122
151
  constructor(processes, structured, storage) {