mixdog 1.2.1 → 1.2.2

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 (233) hide show
  1. package/package.json +1 -1
  2. package/scripts/gate-local.mjs +19 -3
  3. package/scripts/model-catalog-audit.mjs +1 -15
  4. package/src/agents/reviewer/AGENT.md +3 -3
  5. package/src/agents/reviewer/agent.json +1 -1
  6. package/src/defaults/shipped-history.json +1 -0
  7. package/src/repl/slash-commands.mjs +16 -1
  8. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +29 -4
  9. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +10 -0
  10. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +36 -7
  11. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +49 -10
  12. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +40 -3
  13. package/src/runtime/agent/orchestrator/mcp/client.mjs +230 -96
  14. package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +103 -0
  15. package/src/runtime/agent/orchestrator/mcp/oauth-credentials.mjs +150 -0
  16. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +111 -0
  17. package/src/runtime/agent/orchestrator/mcp/oauth-probe.mjs +87 -0
  18. package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +95 -0
  19. package/src/runtime/agent/orchestrator/mcp/oauth-request.mjs +122 -0
  20. package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +9 -0
  21. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +229 -0
  22. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +188 -29
  23. package/src/runtime/agent/orchestrator/providers/anthropic-api-recovery.mjs +2 -1
  24. package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +94 -31
  25. package/src/runtime/agent/orchestrator/providers/anthropic-error-contract.mjs +96 -0
  26. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +39 -18
  27. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens-recovery.mjs +55 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +25 -21
  29. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +3 -1
  30. package/src/runtime/agent/orchestrator/providers/anthropic-midstream-recovery.mjs +18 -4
  31. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +13 -1
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-midstream.mjs +10 -1
  34. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-recovery.mjs +23 -9
  35. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +46 -20
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +14 -9
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +73 -35
  38. package/src/runtime/agent/orchestrator/providers/anthropic-overload-fallback.mjs +69 -0
  39. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +4 -0
  40. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -1
  41. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -7
  42. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +30 -9
  43. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  44. package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +30 -14
  45. package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +2 -1
  46. package/src/runtime/agent/orchestrator/providers/antigravity-retry-policy.mjs +93 -0
  47. package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +82 -9
  48. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +3 -0
  49. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +38 -5
  50. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  51. package/src/runtime/agent/orchestrator/providers/cursor-wire-guards.mjs +97 -2
  52. package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-bridge.mjs +102 -23
  53. package/src/runtime/agent/orchestrator/providers/cursor-wire-transport.mjs +103 -33
  54. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +29 -6
  55. package/src/runtime/agent/orchestrator/providers/cursor.mjs +18 -1
  56. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +77 -17
  57. package/src/runtime/agent/orchestrator/providers/gemini-cache-policy.mjs +1 -1
  58. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +29 -8
  59. package/src/runtime/agent/orchestrator/providers/gemini-error-contract.mjs +191 -0
  60. package/src/runtime/agent/orchestrator/providers/gemini-family.mjs +9 -0
  61. package/src/runtime/agent/orchestrator/providers/gemini-history-signatures.mjs +9 -4
  62. package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +3 -1
  63. package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +180 -0
  64. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +21 -82
  65. package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +4 -1
  66. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -12
  67. package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +21 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +56 -23
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
  70. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +68 -19
  71. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +3 -0
  72. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +2 -1
  73. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +4 -9
  74. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +4 -2
  75. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-result.mjs +22 -0
  76. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +1 -0
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-error-contract.mjs +50 -0
  78. package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +23 -0
  79. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +16 -8
  80. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-state.mjs +3 -2
  81. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +94 -13
  82. package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +193 -0
  83. package/src/runtime/agent/orchestrator/providers/openai-compat-stream-common.mjs +66 -2
  84. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +2 -1
  85. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +6 -0
  86. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +4 -2
  87. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -14
  88. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +17 -3
  89. package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +42 -25
  90. package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +274 -0
  91. package/src/runtime/agent/orchestrator/providers/openai-http-sse-request.mjs +72 -32
  92. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/terminal-frames.mjs +18 -11
  93. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-outcome.mjs +1 -2
  94. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-stamps.mjs +2 -2
  95. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-state.mjs +2 -3
  96. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state.mjs +2 -2
  97. package/src/runtime/agent/orchestrator/providers/openai-http-sse-watchdogs.mjs +1 -3
  98. package/src/runtime/agent/orchestrator/providers/openai-oauth-dispatch.mjs +34 -3
  99. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +5 -1
  100. package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +22 -2
  101. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +5 -0
  102. package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +5 -9
  103. package/src/runtime/agent/orchestrator/providers/openai-retry-policy.mjs +9 -0
  104. package/src/runtime/agent/orchestrator/providers/openai-ws-handshake.mjs +16 -8
  105. package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/attempt-context.mjs +1 -2
  106. package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/policy.mjs +25 -14
  107. package/src/runtime/agent/orchestrator/providers/openai-ws-terminal.mjs +18 -11
  108. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +31 -20
  109. package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +581 -0
  110. package/src/runtime/agent/orchestrator/providers/retry-backoff.mjs +46 -7
  111. package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +118 -219
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +2 -0
  113. package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +148 -45
  114. package/src/runtime/agent/orchestrator/providers/retry-midstream.mjs +81 -16
  115. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +3 -0
  116. package/src/runtime/agent/orchestrator/runtime-core/goal-reminder.mjs +0 -18
  117. package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +57 -28
  118. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +19 -16
  119. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +1 -97
  120. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +3 -1
  121. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +4 -0
  122. package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +102 -18
  123. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +3 -1
  124. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +30 -2
  125. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +11 -2
  126. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +41 -21
  127. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +5 -10
  128. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +18 -7
  130. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +155 -29
  131. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +6 -3
  132. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +16 -0
  133. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +22 -10
  134. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +5 -0
  136. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +3 -0
  137. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +20 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +2 -2
  139. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/deadline-plan.mjs +12 -15
  140. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  141. package/src/runtime/memory/lib/pg/process.mjs +34 -24
  142. package/src/runtime/memory/lib/pg/supervisor.mjs +16 -13
  143. package/src/runtime/office/README.md +1 -1
  144. package/src/runtime/office/shared/values.mjs +0 -5
  145. package/src/runtime/shared/background-tasks.mjs +92 -14
  146. package/src/runtime/shared/err-text.mjs +25 -0
  147. package/src/runtime/shared/error-presentation.mjs +159 -19
  148. package/src/runtime/shared/llm/anthropic-betas.mjs +4 -3
  149. package/src/runtime/shared/llm/anthropic-effort.mjs +123 -134
  150. package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +50 -14
  151. package/src/runtime/shared/llm/model-catalog.mjs +53 -40
  152. package/src/runtime/shared/llm/openai-daybreak.mjs +4 -16
  153. package/src/runtime/shared/llm/provider-catalog-registry.mjs +11 -0
  154. package/src/runtime/shared/llm/provider-model-identities.mjs +13 -4
  155. package/src/runtime/shared/llm/usage-rollup.mjs +0 -1
  156. package/src/runtime/shared/model-display.mjs +13 -0
  157. package/src/runtime/shared/quota-resume-store.mjs +11 -0
  158. package/src/runtime/shared/session-baseline-store.mjs +960 -0
  159. package/src/runtime/shared/statusline/statusline-format.mjs +4 -4
  160. package/src/runtime/shared/task-notification-envelope.mjs +2 -2
  161. package/src/runtime/shared/turn-changes.mjs +447 -219
  162. package/src/runtime/shared/turn-snapshot-store.mjs +182 -1
  163. package/src/runtime/shared/turn-snapshot.mjs +170 -48
  164. package/src/runtime/shared/turn-worktree-snapshot.mjs +135 -46
  165. package/src/runtime/shared/worktrees/contract.mjs +0 -24
  166. package/src/runtime/shared/worktrees/test-support.mjs +19 -4
  167. package/src/session-runtime/boot/apis.mjs +6 -0
  168. package/src/session-runtime/boot/infrastructure.mjs +6 -0
  169. package/src/session-runtime/lifecycle/session-catalog.mjs +2 -0
  170. package/src/session-runtime/lifecycle/teardown.mjs +1 -1
  171. package/src/session-runtime/mcp-glue/connect.mjs +29 -9
  172. package/src/session-runtime/mcp-glue/server-catalog.mjs +20 -3
  173. package/src/session-runtime/mcp-glue.mjs +3 -0
  174. package/src/session-runtime/mcp-oauth-login.mjs +174 -0
  175. package/src/session-runtime/model-route/model-switch-target.mjs +34 -0
  176. package/src/session-runtime/model-route/set-route.mjs +83 -15
  177. package/src/session-runtime/model-route/web-search-route.mjs +1 -1
  178. package/src/session-runtime/native-web-search/routes.mjs +22 -3
  179. package/src/session-runtime/provider-models/model-meta.mjs +12 -3
  180. package/src/session-runtime/quick-model-rows/web-search-rows.mjs +13 -8
  181. package/src/session-runtime/quick-web-search-models.mjs +13 -38
  182. package/src/session-runtime/resource-extension-sync.mjs +4 -0
  183. package/src/session-runtime/resource-mcp-api.mjs +98 -2
  184. package/src/session-runtime/runtime-review-api.mjs +13 -0
  185. package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +13 -1
  186. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +7 -2
  187. package/src/session-runtime/services/agent-tool/send-flow.mjs +22 -4
  188. package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +2 -0
  189. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +22 -5
  190. package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -1
  191. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +2 -2
  192. package/src/session-runtime/services/agent-tool/worker-rows.mjs +17 -0
  193. package/src/session-runtime/settings-compaction-api.mjs +1 -0
  194. package/src/standalone/daemon-drain-policy.mjs +8 -0
  195. package/src/standalone/daemon-telemetry.mjs +55 -0
  196. package/src/standalone/daemon.mjs +3 -7
  197. package/src/standalone/session-protocol.mjs +7 -0
  198. package/src/standalone/session-service/agent-tree/agent-cancel.mjs +33 -2
  199. package/src/standalone/session-service/agent-tree/agent-turns.mjs +26 -10
  200. package/src/standalone/session-service/agent-tree.mjs +12 -0
  201. package/src/standalone/session-service/quota-resume.mjs +8 -0
  202. package/src/standalone/session-service/retention/busy.mjs +15 -1
  203. package/src/standalone/session-service-api.mjs +5 -0
  204. package/src/standalone/session-service.mjs +1 -0
  205. package/src/tui/app/app-view.jsx +1 -0
  206. package/src/tui/app/create-app-pickers.mjs +4 -0
  207. package/src/tui/app/extension-pickers/mcp-login.mjs +107 -0
  208. package/src/tui/app/extension-pickers/mcp-servers-picker.mjs +2 -1
  209. package/src/tui/app/extension-pickers.mjs +2 -1
  210. package/src/tui/app/model-options.mjs +11 -16
  211. package/src/tui/app/slash-commands.mjs +7 -2
  212. package/src/tui/app/slash-dispatch/panel-commands.mjs +16 -1
  213. package/src/tui/components/StatusLine.jsx +2 -0
  214. package/src/tui/components/status-line/local-line.mjs +5 -2
  215. package/src/tui/components/status-line/use-statusline-text.mjs +7 -1
  216. package/src/tui/dist/index.mjs +816 -43
  217. package/src/tui/session/quota-resume.mjs +46 -14
  218. package/src/tui/session/session-api/commands.mjs +33 -24
  219. package/src/tui/session/session-api/extensions.mjs +22 -0
  220. package/src/tui/session/session-api/integrations.mjs +7 -0
  221. package/src/tui/session/session-api/lifecycle.mjs +5 -1
  222. package/src/tui/session/session-api/routes.mjs +71 -7
  223. package/src/tui/session/session-api/settings.mjs +5 -14
  224. package/src/tui/session/session-flow/drain.mjs +7 -1
  225. package/src/tui/session/session-flow/failed-turn-rewind.mjs +10 -4
  226. package/src/tui/session/turn.mjs +38 -15
  227. package/src/tui/statusline-ansi-bridge.mjs +1 -1
  228. package/src/ui/statusline-agents.mjs +15 -4
  229. package/src/ui/statusline.mjs +7 -3
  230. package/src/ui/streaming-markdown-heal.mjs +34 -3
  231. package/src/vendor/statusline/src/gateway/claude-current.mjs +14 -4
  232. package/src/runtime/memory/lib/memory-session-merge.mjs +0 -32
  233. package/src/runtime/shared/llm/usage-session-history.mjs +0 -266
@@ -5,9 +5,10 @@
5
5
  // turn-snapshot-store.mjs. Every file carries its own complete patch, separate
6
6
  // from the bounded display patch the review bar renders, so a historical turn
7
7
  // stays undoable after later turns, cache eviction or a runtime restart.
8
+ import { createHash } from 'node:crypto';
8
9
  import { lstat, open, realpath, stat } from 'node:fs/promises';
9
- import { isAbsolute, relative, resolve } from 'node:path';
10
- import { applyPatch, parsePatch, reversePatch, structuredPatch } from 'diff';
10
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
11
+ import { structuredPatch } from 'diff';
11
12
  import { cleanString as clean } from './clean.mjs';
12
13
  import { runGitOffThread } from './git-runner.mjs';
13
14
 
@@ -344,25 +345,32 @@ export function turnChangesReply({ status, checkpointId, record = null, patch =
344
345
  return reply;
345
346
  }
346
347
 
347
- // ── Session review: net recorded changes ────────────────────────────────────
348
+ // ── Session review: first-seen baselines against the disk ───────────────────
348
349
 
349
350
  export const NET_TEXT_MAX_BYTES = 1024 * 1024;
350
351
  /** Budget of the merged session patch, in UTF-8 bytes. */
351
352
  export const MERGED_PATCH_MAX_BYTES = 2 * 1024 * 1024;
352
353
  const VIEW_PATCH_MAX_BYTES = 256 * 1024;
353
354
 
354
- /** A git-format patch of exact before/after text under the names a viewer
355
- * shows (absolute for a file outside the worktree). `before`/`after` are
356
- * Buffers or null (absent). */
357
- export function namedChangePatch({ before, after, oldName, newName, maxBytes = VIEW_PATCH_MAX_BYTES }) {
358
- const renamed = oldName !== newName;
355
+ /** Counts and a placeholder-named patch of exact before/after bytes. */
356
+ function measureChange(before, after, renamed) {
359
357
  const measured = trackedChangeFile({
360
358
  oldPath: renamed ? 'mx-old-file' : 'mx-file',
361
359
  newPath: renamed ? 'mx-new-file' : 'mx-file',
362
360
  before,
363
361
  after,
364
362
  });
365
- const text = measured.patch ? measured.patch.toString('utf8') : '';
363
+ return {
364
+ status: measured.status,
365
+ additions: measured.additions,
366
+ deletions: measured.deletions,
367
+ ...(measured.binary ? { binary: true } : {}),
368
+ text: measured.patch ? measured.patch.toString('utf8') : '',
369
+ };
370
+ }
371
+
372
+ /** A measured patch with its placeholders replaced by the names a viewer shows. */
373
+ function nameMeasuredPatch(text, oldName, newName) {
366
374
  const split = text.search(/\n@@ /);
367
375
  const head = (split >= 0 ? text.slice(0, split) : text)
368
376
  .split('mx-old-file')
@@ -371,19 +379,21 @@ export function namedChangePatch({ before, after, oldName, newName, maxBytes = V
371
379
  .join(newName)
372
380
  .split('mx-file')
373
381
  .join(newName);
374
- const patch = head + (split >= 0 ? text.slice(split) : '');
375
- return {
376
- status: measured.status,
377
- additions: measured.additions,
378
- deletions: measured.deletions,
379
- ...(measured.binary ? { binary: true } : {}),
380
- patch: Buffer.byteLength(patch, 'utf8') > maxBytes ? '' : patch,
381
- };
382
+ return head + (split >= 0 ? text.slice(split) : '');
383
+ }
384
+
385
+ /** A git-format patch of exact before/after text under the names a viewer
386
+ * shows (absolute for a file outside the worktree). `before`/`after` are
387
+ * Buffers or null (absent). */
388
+ export function namedChangePatch({ before, after, oldName, newName, maxBytes = VIEW_PATCH_MAX_BYTES }) {
389
+ const { text, ...measured } = measureChange(before, after, oldName !== newName);
390
+ const patch = nameMeasuredPatch(text, oldName, newName);
391
+ return { ...measured, patch: Buffer.byteLength(patch, 'utf8') > maxBytes ? '' : patch };
382
392
  }
383
393
 
384
394
  const slashPath = (value) => clean(value).replace(/\\/g, '/');
385
395
  const isOutsidePath = (path) => isAbsolute(path) || /^[A-Za-z]:[\\/]/.test(path);
386
- const absoluteKey = (root, path) => changePathKey(isOutsidePath(path) ? resolve(path) : resolve(root, path));
396
+ const absolutePath = (root, path) => (isOutsidePath(path) ? resolve(path) : resolve(root, path));
387
397
  const patchOf = (file) =>
388
398
  changePatchBuffer(file)?.toString('utf8') || (typeof file?.viewPatch === 'string' ? file.viewPatch : '');
389
399
 
@@ -407,12 +417,13 @@ async function readBounded(file) {
407
417
  }
408
418
  }
409
419
 
410
- /** The file's current text: a string, null when absent, undefined when it
411
- * cannot be netted (unreadable, binary, large, or reached through a link).
412
- * An in-root file is followed only while it stays inside the recorded root;
413
- * an outside file is read only at its exact recorded path, never through a
414
- * link. */
415
- export async function currentText(root, path) {
420
+ const missingFile = (error) => error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
421
+
422
+ /** The recorded file's absolute path when it may be read: null when absent,
423
+ * undefined when it is reached through a link. An in-root file is followed
424
+ * only while it stays inside the recorded root; an outside file is read only
425
+ * at its exact recorded path, never through a link. */
426
+ async function safeCurrentFile(root, path) {
416
427
  const outside = isOutsidePath(path);
417
428
  const file = outside ? resolve(path) : resolve(root, path);
418
429
  try {
@@ -426,10 +437,22 @@ export async function currentText(root, path) {
426
437
  const [realRoot, realFile] = await Promise.all([realpath(root), realpath(file)]);
427
438
  if (!insideDirectory(realRoot, realFile)) return undefined;
428
439
  }
440
+ return file;
441
+ } catch (error) {
442
+ return missingFile(error) ? null : undefined;
443
+ }
444
+ }
445
+
446
+ /** The file's current text: a string, null when absent, undefined when it
447
+ * cannot be netted (unreadable, binary, large, or reached through a link). */
448
+ export async function currentText(root, path) {
449
+ const file = await safeCurrentFile(root, path);
450
+ if (!file) return file;
451
+ try {
429
452
  const bytes = await readBounded(file);
430
453
  return bytes === undefined ? undefined : (decodeText(bytes) ?? undefined);
431
454
  } catch (error) {
432
- return error?.code === 'ENOENT' || error?.code === 'ENOTDIR' ? null : undefined;
455
+ return missingFile(error) ? null : undefined;
433
456
  }
434
457
  }
435
458
 
@@ -459,248 +482,453 @@ export async function measureLegacyChangeFile(file, root) {
459
482
  return { ...file, additions: named.additions, deletions: named.deletions, viewPatch: named.patch };
460
483
  }
461
484
 
462
- /** The text a chain's file had before its first recorded turn, rebuilt by
463
- * reverse-applying the recorded per-turn patches, newest first, onto the
464
- * file as it is now (`current`: string, or null when absent). null = the file
465
- * did not exist; undefined = a patch is missing or no longer matches, so the
466
- * chain cannot be netted exactly. Nothing about the before-state is stored. */
467
- function originalText(entries, current) {
468
- let text = current;
469
- for (let index = entries.length - 1; index >= 0; index -= 1) {
470
- const { file } = entries[index];
471
- const patch = patchOf(file);
472
- if (!patch || file.binary === true) return undefined;
473
- let sections;
474
- try {
475
- sections = parsePatch(patch);
476
- } catch {
477
- return undefined;
478
- }
479
- if (sections.length !== 1) return undefined;
480
- const [section] = sections;
481
- if (file.status === 'D') {
482
- if (text !== null) return undefined;
483
- text = '';
484
- } else if (text === null) {
485
- return undefined;
486
- }
487
- if (section.hunks.length) {
488
- const result = applyPatch(text, reversePatch(section));
489
- if (result === false) return undefined;
490
- text = result;
485
+ // Largest current file hashed against a baseline whose bytes were not kept.
486
+ const DIGEST_MAX_BYTES = 64 * 1024 * 1024;
487
+ // Largest measured patch a review keeps cached between polls.
488
+ const CACHED_PATCH_MAX_BYTES = 256 * 1024;
489
+ const READ_CONCURRENCY = 16;
490
+ const TOO_LARGE_REASON = 'file is too large to diff';
491
+ const UNREADABLE_REASON = 'the file cannot be read safely now';
492
+ const SEVERAL_TURNS_REASON = 'edited in several turns before its session baseline was recorded';
493
+ /** Why a listed file has no exact diff against the session's first state. */
494
+ const BASELINE_REASONS = {
495
+ 'omitted-binary': "the session's original binary content was not captured",
496
+ 'omitted-oversized': "the session's original content was too large to capture",
497
+ evicted: "the session's original content was evicted to bound storage",
498
+ missing: "the session's original state was not recorded",
499
+ };
500
+
501
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
502
+
503
+ async function mapLimit(items, limit, task) {
504
+ const results = new Array(items.length);
505
+ let next = 0;
506
+ const worker = async () => {
507
+ while (next < items.length) {
508
+ const index = next++;
509
+ results[index] = await task(items[index]);
491
510
  }
492
- if (file.status === 'A') {
493
- if (text !== '') return undefined;
494
- text = null;
511
+ };
512
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
513
+ return results;
514
+ }
515
+
516
+ /** An absolute file under the link rules of currentText: relative to its
517
+ * recorded root when inside it, else its exact path. */
518
+ const fileRef = (root, abs) =>
519
+ root && insideDirectory(root, abs) ? { root, path: relative(root, abs) } : { root: dirname(abs), path: abs };
520
+
521
+ /** `{ state: 'absent' | 'unsafe' }` or `{ state: 'present', file, size, sig }`. */
522
+ async function currentFile({ root, path }) {
523
+ const file = await safeCurrentFile(root, path);
524
+ if (file === null) return { state: 'absent' };
525
+ if (file === undefined) return { state: 'unsafe' };
526
+ try {
527
+ const info = await stat(file);
528
+ if (!info.isFile()) return { state: 'unsafe' };
529
+ return { state: 'present', file, size: info.size, sig: `${info.size}:${info.mtimeMs}:${info.ctimeMs}` };
530
+ } catch (error) {
531
+ return { state: missingFile(error) ? 'absent' : 'unsafe' };
532
+ }
533
+ }
534
+
535
+ const currentSig = (current) => (current?.state === 'present' ? current.sig : current?.state || '');
536
+
537
+ /** Bytes of a present file up to NET_TEXT_MAX_BYTES; undefined past it or
538
+ * when it cannot be read. */
539
+ async function currentBytes(current) {
540
+ if (current.size > NET_TEXT_MAX_BYTES) return undefined;
541
+ try {
542
+ return await readBounded(current.file);
543
+ } catch {
544
+ return undefined;
545
+ }
546
+ }
547
+
548
+ async function fileDigest(file) {
549
+ let handle;
550
+ try {
551
+ handle = await open(file, 'r');
552
+ const info = await handle.stat();
553
+ if (!info.isFile() || info.size > DIGEST_MAX_BYTES) return undefined;
554
+ const hash = createHash('sha256');
555
+ const buffer = Buffer.alloc(1024 * 1024);
556
+ for (let position = 0; ; ) {
557
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
558
+ if (!bytesRead) break;
559
+ hash.update(buffer.subarray(0, bytesRead));
560
+ position += bytesRead;
495
561
  }
562
+ return hash.digest('hex');
563
+ } catch {
564
+ return undefined;
565
+ } finally {
566
+ await handle?.close().catch(() => {});
496
567
  }
497
- return text;
498
568
  }
499
569
 
500
- /** A recorded patch re-headed with the names the merged review shows, so a
501
- * disambiguated file keeps one identity in its list row and its diff. */
502
- function retargetPatch(patch, { status, oldName, newName }) {
503
- const split = patch.search(/\n@@ /);
504
- const head = split >= 0 ? patch.slice(0, split) : patch;
505
- const tail = split >= 0 ? patch.slice(split) : '';
506
- const from = status === 'R' ? oldName : newName;
507
- const tab = (name) => (name.includes(' ') ? '\t' : '');
508
- const lines = head.split('\n').map((line) => {
509
- if (line.startsWith('diff --git ')) return `diff --git a/${from} b/${newName}`;
510
- if (line.startsWith('--- ') && !line.startsWith('--- /dev/null')) return `--- a/${from}${tab(from)}`;
511
- if (line.startsWith('+++ ') && !line.startsWith('+++ /dev/null')) return `+++ b/${newName}${tab(newName)}`;
512
- if (line.startsWith('rename from ')) return `rename from ${from}`;
513
- if (line.startsWith('rename to ')) return `rename to ${newName}`;
514
- return line;
570
+ /** Whether a present file still holds the baseline's bytes: true, false, or
571
+ * undefined when that cannot be told. */
572
+ async function sameAsBaseline(entry, current, after) {
573
+ if (entry.size > 0 && entry.size !== current.size) return false;
574
+ if (!entry.sha256) return undefined;
575
+ const hash = after ? sha256(after) : await fileDigest(current.file);
576
+ return hash === undefined ? undefined : hash === entry.sha256;
577
+ }
578
+
579
+ /** A row the review cannot measure exactly: it never vouches for the set. */
580
+ const partialChange = (status, reason, extra = {}) => ({
581
+ status,
582
+ additions: 0,
583
+ deletions: 0,
584
+ text: '',
585
+ reason,
586
+ partial: true,
587
+ ...extra,
588
+ });
589
+
590
+ const baselineGap = (entry, status) =>
591
+ partialChange(status, BASELINE_REASONS[entry.status] || BASELINE_REASONS.missing, {
592
+ baseline: entry.status,
593
+ ...(entry.status === 'omitted-binary' ? { binary: true } : {}),
515
594
  });
516
- return lines.join('\n') + tail;
517
- }
518
-
519
- /** One path's turns, oldest first, as a single net change; null when the
520
- * session left the file as it found it. The original is rebuilt from the
521
- * recorded patches (see originalText) and compared with the disk, so undo,
522
- * create→delete and renames fall out; when that fails the turns are merged
523
- * per turn. `show` names a file for display. */
524
- async function netChain(chain, show) {
525
- const first = chain.entries[0];
526
- const last = chain.entries.at(-1);
527
- const path = show(last.root, last.file.path);
528
- const firstRel = first.file.oldPath || first.file.path;
529
- const oldName = absoluteKey(first.root, firstRel) === chain.key ? path : show(first.root, firstRel);
530
- const identity = {
531
- id: slashPath(isOutsidePath(last.file.path) ? resolve(last.file.path) : resolve(last.root, last.file.path)),
532
- outside: isOutsidePath(last.file.path),
533
- };
534
- const after = await currentText(last.root, last.file.path);
535
- if (after !== undefined) {
536
- const before = originalText(chain.entries, after);
537
- if (before !== undefined) {
538
- if (before === after && oldName === path) return null;
539
- const net = namedChangePatch({
540
- before: before === null ? null : Buffer.from(before, 'utf8'),
541
- after: after === null ? null : Buffer.from(after, 'utf8'),
542
- oldName,
543
- newName: path,
544
- maxBytes: MERGED_PATCH_MAX_BYTES,
545
- });
546
- const omitted = !net.patch && net.additions + net.deletions > 0;
547
- return {
548
- path,
549
- ...identity,
550
- ...(oldName !== path ? { oldPath: oldName } : {}),
551
- ...net,
552
- ...(omitted ? { patchOmitted: true } : {}),
553
- };
595
+
596
+ /** A file matching the hash of a first state whose bytes were not kept: not
597
+ * listed, but it does not let the review vouch for the set either. */
598
+ const UNVOUCHED_UNCHANGED = Object.freeze({ hidden: true, partial: true });
599
+
600
+ /** One file (or rename) measured from its first-seen state `entry` to the
601
+ * disk now (`current`); null when the session left it as it found it,
602
+ * UNVOUCHED_UNCHANGED when only a hash says so. */
603
+ async function measureBaseline(entry, current, renamed, readBlob) {
604
+ const changed = renamed ? 'R' : 'M';
605
+ if (current.state === 'unsafe') {
606
+ return partialChange(entry.state === 'absent' ? 'A' : changed, UNREADABLE_REASON);
607
+ }
608
+ const present = current.state === 'present';
609
+ const after = present ? await currentBytes(current) : null;
610
+ if (entry.state === 'absent') {
611
+ if (!present) return null;
612
+ if (after === undefined) return { status: 'A', additions: 0, deletions: 0, text: '', reason: TOO_LARGE_REASON };
613
+ return measureChange(null, after, false);
614
+ }
615
+ if (present) {
616
+ const same = await sameAsBaseline(entry, current, after);
617
+ const exact = entry.status === 'captured';
618
+ if (same === true && !renamed) return exact ? null : UNVOUCHED_UNCHANGED;
619
+ if (same === true) {
620
+ const moved = await measureChange(Buffer.alloc(0), Buffer.alloc(0), true);
621
+ return exact
622
+ ? moved
623
+ : { ...moved, partial: true, baseline: entry.status, reason: BASELINE_REASONS[entry.status] };
554
624
  }
625
+ if (same === undefined || !exact) return baselineGap(entry, changed);
555
626
  }
556
- if (first.file.status === 'A' && last.file.status === 'D') return null;
557
- const files = await Promise.all(chain.entries.map((entry) => measureLegacyChangeFile(entry.file, entry.root)));
558
- const patches = files
559
- .map((file) => {
560
- const patch = patchOf(file);
561
- return patch ? retargetPatch(patch, { status: file.status, oldName, newName: path }) : '';
562
- })
563
- .filter(Boolean);
564
- const additions = files.reduce((sum, file) => sum + (Number(file.additions) || 0), 0);
565
- const deletions = files.reduce((sum, file) => sum + (Number(file.deletions) || 0), 0);
566
- const binary = files.some((file) => file.binary === true);
567
- const patch = patches.join('');
568
- return {
569
- path,
570
- ...identity,
571
- ...(oldName !== path ? { oldPath: oldName } : {}),
572
- status: first.file.status === 'A' ? 'A' : last.file.status,
573
- additions,
574
- deletions,
575
- ...(binary ? { binary: true } : {}),
576
- patch,
577
- // A legacy record that measured nothing: the transcript may know more.
578
- ...(!patch && additions + deletions === 0 && !binary ? { unmeasured: true, reason: clean(last.file.reason) } : {}),
579
- ...(!patch && last.file.patchOmitted === true ? { patchOmitted: true } : {}),
580
- };
627
+ const before = entry.status === 'captured' ? await readBlob(entry) : undefined;
628
+ if (!before)
629
+ return baselineGap(entry.status === 'captured' ? { ...entry, status: 'evicted' } : entry, present ? changed : 'D');
630
+ if (after === undefined) return { status: changed, additions: 0, deletions: 0, text: '', reason: TOO_LARGE_REASON };
631
+ return measureChange(before, after, renamed);
632
+ }
633
+
634
+ /** Every baseline not `covered` by another source, measured against the disk.
635
+ * A path holding a renamed file pairs with the path it came from when that
636
+ * one is gone. Measurements are reused while the files' stats are unchanged. */
637
+ async function baselineChanges(baselines, covered, unvouched) {
638
+ const entries = new Map();
639
+ for (const entry of baselines.files) if (!covered(entry.path)) entries.set(entry.key, entry);
640
+ const list = [...entries.values()];
641
+ const states = await mapLimit(list, READ_CONCURRENCY, (entry) => currentFile(fileRef(entry.root, entry.path)));
642
+ const current = new Map(list.map((entry, index) => [entry.key, states[index]]));
643
+ const paired = new Set();
644
+ const units = [];
645
+ for (const [destinationKey, originKey] of baselines.renames) {
646
+ const destination = entries.get(destinationKey);
647
+ const origin = entries.get(originKey);
648
+ if (!destination || !origin || paired.has(destinationKey) || paired.has(originKey)) continue;
649
+ if (destination.state !== 'absent' || origin.state === 'absent') continue;
650
+ if (current.get(destinationKey).state === 'absent' || current.get(originKey).state !== 'absent') continue;
651
+ paired.add(destinationKey);
652
+ paired.add(originKey);
653
+ units.push({ key: `${originKey}\0${destinationKey}`, before: origin, after: destination });
654
+ }
655
+ for (const entry of list) if (!paired.has(entry.key)) units.push({ key: entry.key, before: entry, after: entry });
656
+ const measured = await mapLimit(units, READ_CONCURRENCY, async (unit) => {
657
+ const renamed = unit.before !== unit.after;
658
+ const now = current.get(unit.after.key);
659
+ const sig = [
660
+ unit.before.state,
661
+ unit.before.status,
662
+ unit.before.sha256 || '',
663
+ unit.before.blob || '',
664
+ unit.before.at,
665
+ unit.after.at,
666
+ currentSig(now),
667
+ currentSig(current.get(unit.before.key)),
668
+ ].join('|');
669
+ const cached = baselines.cache?.get(unit.key);
670
+ let change = cached?.sig === sig ? cached.change : undefined;
671
+ if (change === undefined) {
672
+ change = await measureBaseline(unit.before, now, renamed, baselines.readBlob);
673
+ if (Buffer.byteLength(change?.text || '', 'utf8') <= CACHED_PATCH_MAX_BYTES) {
674
+ baselines.cache?.set(unit.key, { sig, change });
675
+ }
676
+ }
677
+ if (change?.hidden) unvouched.add(unit.after.key);
678
+ if (!change || change.hidden) return null;
679
+ return {
680
+ key: unit.after.key,
681
+ abs: unit.after.path,
682
+ root: unit.after.root,
683
+ ...(renamed ? { oldAbs: unit.before.path, oldRoot: unit.before.root } : {}),
684
+ change,
685
+ };
686
+ });
687
+ return measured.filter(Boolean);
581
688
  }
582
689
 
583
- /** Group the records' files into per-path chains (renames continue a chain).
584
- * A chain is keyed by root + path, so two roots' `x.txt` stay two files. */
585
- function recordChains(records, { inRootKinds }) {
586
- const chains = new Map();
690
+ /**
691
+ * Files of the retained turn records that no baseline lists — a session (or
692
+ * turns) recorded before baselines existed. Never exact: a file edited in one
693
+ * turn keeps that turn's diff, one edited in several is listed unmeasured.
694
+ * Also returns the baseline keys such older records touched: those baselines
695
+ * were first seen after an unrecorded edit.
696
+ */
697
+ async function legacyChanges(records, { kinds, listed, covered, since }) {
698
+ const groups = new Map();
699
+ const late = new Set();
587
700
  for (const record of records) {
588
701
  const root = clean(record?.root);
589
702
  const entries = [
590
- ...(inRootKinds.has(record?.kind) ? record.files || [] : []),
703
+ ...(kinds.has(record?.kind) ? record.files || [] : []),
591
704
  ...(Array.isArray(record?.outside) ? record.outside : []),
592
705
  ];
593
706
  for (const file of entries) {
594
707
  if (!file?.path || file.state === 'reverted') continue;
595
- const key = absoluteKey(root, file.path);
596
- const from = file.oldPath ? absoluteKey(root, file.oldPath) : key;
597
- const chain = chains.get(from) || chains.get(key) || { entries: [] };
598
- chains.delete(chain.key);
599
- chain.entries.push({ file, root });
600
- chain.key = key;
601
- chains.set(key, chain);
708
+ const abs = absolutePath(root, file.path);
709
+ const key = changePathKey(abs);
710
+ const oldAbs = file.oldPath ? absolutePath(root, file.oldPath) : '';
711
+ if (covered(abs)) continue;
712
+ if (listed.has(key) || (oldAbs && listed.has(changePathKey(oldAbs)))) {
713
+ if (!(Number(record.createdAt) >= since)) late.add(key);
714
+ continue;
715
+ }
716
+ const prior = groups.get(key) || (oldAbs ? groups.get(changePathKey(oldAbs)) : undefined);
717
+ if (prior) groups.delete(prior.key);
718
+ let origin = {};
719
+ if (prior) origin = { oldAbs: prior.oldAbs, oldRoot: prior.oldRoot };
720
+ else if (oldAbs) origin = { oldAbs, oldRoot: root };
721
+ groups.set(key, {
722
+ key,
723
+ abs,
724
+ root,
725
+ file,
726
+ first: prior?.first || file,
727
+ turns: (prior?.turns || 0) + 1,
728
+ ...origin,
729
+ });
602
730
  }
603
731
  }
604
- return [...chains.values()];
732
+ const changes = [];
733
+ for (const group of groups.values()) {
734
+ const { file, first } = group;
735
+ if (group.turns > 1 && first.status === 'A' && file.status === 'D') continue;
736
+ const unit = {
737
+ key: group.key,
738
+ abs: group.abs,
739
+ root: group.root,
740
+ ...(group.oldAbs ? { oldAbs: group.oldAbs, oldRoot: group.oldRoot } : {}),
741
+ };
742
+ if (group.turns > 1) {
743
+ const status = first.status === 'A' ? 'A' : file.status;
744
+ changes.push({
745
+ ...unit,
746
+ change: partialChange(status, SEVERAL_TURNS_REASON, { baseline: 'missing', unmeasured: true }),
747
+ });
748
+ continue;
749
+ }
750
+ const measured = await measureLegacyChangeFile(file, group.root);
751
+ const recordPatch = patchOf(measured);
752
+ const counts = (Number(measured.additions) || 0) + (Number(measured.deletions) || 0);
753
+ changes.push({
754
+ ...unit,
755
+ change: {
756
+ status: file.status,
757
+ additions: Number(measured.additions) || 0,
758
+ deletions: Number(measured.deletions) || 0,
759
+ ...(measured.binary ? { binary: true } : {}),
760
+ text: '',
761
+ recordPatch,
762
+ recordName: slashPath(file.path),
763
+ partial: true,
764
+ baseline: 'missing',
765
+ ...(!recordPatch && counts === 0 && !measured.binary
766
+ ? { unmeasured: true, reason: clean(file.reason) || BASELINE_REASONS.missing }
767
+ : {}),
768
+ ...(!recordPatch && file.patchOmitted === true ? { patchOmitted: true } : {}),
769
+ },
770
+ });
771
+ }
772
+ return { changes, late };
605
773
  }
606
774
 
607
- /** Records whose emptiness the session review may vouch for: a baseline that
608
- * lists the files itself, or retained records (undo-aware). Without either
609
- * the history is absent, never authoritative. */
610
- const knownSources = new Set(['git', 'empty', 'tool']);
775
+ /** Files (`{ root, abs }`) the session claims but no baseline or record
776
+ * lists: the ones still present are listed without a diff. */
777
+ async function missingChanges(refs, { listed, covered }) {
778
+ const files = [];
779
+ for (const { root, abs } of refs) {
780
+ const key = changePathKey(abs);
781
+ if (listed.has(key) || covered(abs)) continue;
782
+ listed.add(key);
783
+ files.push({ key, abs, root });
784
+ }
785
+ const states = await mapLimit(files, READ_CONCURRENCY, (file) => currentFile(fileRef(file.root, file.abs)));
786
+ return files.map((file, index) =>
787
+ states[index].state === 'absent'
788
+ ? { ...file, gone: true }
789
+ : { ...file, change: partialChange('M', BASELINE_REASONS.missing, { baseline: 'missing' }) }
790
+ );
791
+ }
611
792
 
612
793
  /**
613
- * The session-wide review completed with the turns' recorded changes — the
614
- * source the transcript's turn-changes cards read — as ONE net change per
615
- * file, identical before and after a runtime restart. `base.source` says what
616
- * the baseline review could vouch for:
617
- * - `git`: the baseline is authoritative; only edits outside its root are added.
618
- * - `empty` / `tool`: no baseline lists files (a non-Git session, or a
619
- * session whose tracker holds nothing yet): the `tracked` records and the
620
- * running turn (`live`) supply them, never the tracker's single-turn patch.
621
- * - `absent` / `unavailable`: no ownership metadata survived; every retained
622
- * record supplies the files, and with no record the review is not complete.
623
- * A file the user undid is never listed; Reapply lists it again. Each listed
624
- * file carries an `id` (root + path); when two roots would show the same
794
+ * The session-wide review: the Git baseline review (`base`) completed with
795
+ * every file it does not cover — a non-Git root, or a file outside the Git
796
+ * root — measured from its first-seen state in `baselines` (the Lead's and
797
+ * its attributed workers' edits together) against the disk now. `base.source`
798
+ * says what the Git side vouches for (`git`: its root; `empty`: nothing was
799
+ * owned). Retained `records` only list files no baseline knows (a session
800
+ * recorded before baselines existed) and never make the result complete.
801
+ * `complete` is set only when every listed file was measured exactly from a
802
+ * first state the session itself recorded; otherwise the result is `partial`.
803
+ * A file the user undid is back at its first state and is not listed. Each
804
+ * listed file carries an `id` (its absolute path); two roots sharing a
625
805
  * relative name both show their absolute path.
626
806
  */
627
- export async function mergeRecordedSessionChanges(base, records, live = null) {
628
- const source = base?.source || '';
807
+ export async function sessionChangeReview(base, { baselines = null, records = [] } = {}) {
808
+ const { source = '', reason: unavailableReason, owned, ...kept } = base || {};
629
809
  const gitBacked = source === 'git';
630
- const cleared = source === 'tool' ? { ...base, files: [], patch: '' } : base;
631
- const retained = Array.isArray(records) ? records : [];
632
- const withLive =
633
- live && !retained.some((record) => record?.checkpointId === live.checkpointId) ? [...retained, live] : retained;
634
- let inRootKinds = new Set(['tracked']);
635
- if (gitBacked) inRootKinds = new Set();
636
- else if (source === 'absent' || source === 'unavailable') inRootKinds = new Set(['git', 'tracked']);
637
- const baseFiles = Array.isArray(cleared?.files) ? cleared.files : [];
638
- const basePatch = typeof cleared?.patch === 'string' ? cleared.patch : '';
639
810
  const baseRoot = clean(base?.root);
640
- const known = new Set();
641
- for (const file of baseFiles) {
642
- if (file?.path) known.add(absoluteKey(baseRoot, file.path));
643
- if (file?.oldPath) known.add(absoluteKey(baseRoot, file.oldPath));
644
- }
645
- const chains = recordChains(withLive, { inRootKinds }).filter((chain) => !known.has(chain.key));
646
- // A relative name shared by different roots (or with a baseline file) is
647
- // shown absolute, so each file stays distinct.
811
+ const covered = (abs) => gitBacked && Boolean(baseRoot) && insideDirectory(baseRoot, abs);
812
+ const listed = new Set((baselines?.files || []).map((entry) => entry.key));
813
+ let kinds = new Set(['tracked']);
814
+ if (gitBacked) kinds = new Set();
815
+ else if (source === 'absent' || source === 'unavailable') kinds = new Set(['git', 'tracked']);
816
+ // Baselines nothing is listed for, yet whose emptiness cannot be vouched for.
817
+ const unvouched = new Set();
818
+ const measured = baselines ? await baselineChanges(baselines, covered, unvouched) : [];
819
+ const legacy = await legacyChanges(Array.isArray(records) ? records : [], {
820
+ kinds,
821
+ listed,
822
+ covered,
823
+ since: Number(baselines?.createdAt) || Number.POSITIVE_INFINITY,
824
+ });
825
+ for (const change of legacy.changes) listed.add(change.key);
826
+ // Owned files of the non-Git root, and paths whose first state a clear in
827
+ // another process removed (the baselines are incomplete for them).
828
+ const refs = (baselines?.missing || []).map((entry) => ({ root: entry.root, abs: entry.path }));
829
+ if (!gitBacked && Array.isArray(owned)) {
830
+ for (const path of owned) refs.push({ root: baseRoot, abs: absolutePath(baseRoot, path) });
831
+ }
832
+ const missing = await missingChanges(refs, { listed, covered });
833
+ for (const change of measured) {
834
+ if (legacy.late.has(change.key)) change.change = { ...change.change, partial: true, reason: SEVERAL_TURNS_REASON };
835
+ }
836
+ const changes = [...measured, ...legacy.changes, ...missing.filter((file) => file.change)];
837
+ const baseFiles = gitBacked && Array.isArray(base.files) ? base.files : [];
838
+ const basePatch = gitBacked && typeof base.patch === 'string' ? base.patch : '';
839
+ // A relative name shared by different roots (or with a Git file) is shown
840
+ // absolute, so each file stays distinct.
841
+ const nameOf = (abs, root) => (root && insideDirectory(root, abs) ? slashPath(relative(root, abs)) : slashPath(abs));
648
842
  const owners = new Map();
649
- const claim = (rel, key) => {
650
- const name = changePathKey(rel);
651
- owners.set(name, new Set([...(owners.get(name) || []), key]));
843
+ const claim = (name, abs) => {
844
+ if (isOutsidePath(name)) return;
845
+ const key = changePathKey(name);
846
+ owners.set(key, new Set([...(owners.get(key) || []), changePathKey(abs)]));
652
847
  };
653
- for (const file of baseFiles) if (file?.path) claim(file.path, absoluteKey(baseRoot, file.path));
654
- for (const chain of chains) {
655
- const entry = chain.entries.at(-1);
656
- claim(entry.file.path, chain.key);
657
- const origin = chain.entries[0];
658
- if (origin.file.oldPath) claim(origin.file.oldPath, absoluteKey(origin.root, origin.file.oldPath));
659
- }
660
- const show = (root, rel) =>
661
- isOutsidePath(rel) || (owners.get(changePathKey(rel))?.size || 0) <= 1
662
- ? slashPath(rel)
663
- : slashPath(resolve(root, rel));
664
- const nets = [];
665
- for (const chain of chains) {
666
- const net = await netChain(chain, show);
667
- if (net) nets.push(net);
668
- }
669
- const { source: _source, reason: unavailableReason, ...kept } = cleared || {};
670
- const complete = knownSources.has(source) || withLive.length > 0;
671
- if (nets.length === 0) {
672
- // Nothing recorded to add: the baseline answer stands (an unavailable
673
- // baseline stays unavailable, never an invented empty set).
674
- if (cleared?.supported === false) return { ...kept, reason: unavailableReason };
675
- return { ...kept, ...(complete ? { complete: true } : {}) };
848
+ for (const file of baseFiles) if (file?.path) claim(file.path, resolve(baseRoot, file.path));
849
+ for (const change of changes) {
850
+ claim(nameOf(change.abs, change.root), change.abs);
851
+ if (change.oldAbs) claim(nameOf(change.oldAbs, change.oldRoot), change.oldAbs);
676
852
  }
853
+ const show = (abs, root) => {
854
+ const name = nameOf(abs, root);
855
+ return isOutsidePath(name) || (owners.get(changePathKey(name))?.size || 0) <= 1 ? name : slashPath(abs);
856
+ };
857
+ const rows = changes
858
+ .map(({ abs, root, oldAbs, oldRoot, change }) => {
859
+ const path = show(abs, root);
860
+ const oldName = oldAbs ? show(oldAbs, oldRoot) : path;
861
+ let patch = '';
862
+ if (change.text) patch = nameMeasuredPatch(change.text, oldName, path);
863
+ else if (change.recordPatch && change.recordName === path) patch = change.recordPatch;
864
+ let patchOmitted = change.patchOmitted === true;
865
+ if (Buffer.byteLength(patch, 'utf8') > MERGED_PATCH_MAX_BYTES) {
866
+ patch = '';
867
+ patchOmitted = true;
868
+ }
869
+ return {
870
+ path,
871
+ id: slashPath(abs),
872
+ outside: !(root && insideDirectory(root, abs)),
873
+ ...(oldName !== path ? { oldPath: oldName } : {}),
874
+ status: change.status,
875
+ additions: change.additions,
876
+ deletions: change.deletions,
877
+ ...(change.binary ? { binary: true } : {}),
878
+ ...(change.reason ? { reason: change.reason } : {}),
879
+ ...(change.baseline ? { baseline: change.baseline } : {}),
880
+ ...(change.unmeasured ? { unmeasured: true } : {}),
881
+ ...(patchOmitted ? { patchOmitted: true } : {}),
882
+ patch,
883
+ };
884
+ })
885
+ .sort((left, right) => {
886
+ if (left.path === right.path) return 0;
887
+ return left.path < right.path ? -1 : 1;
888
+ });
677
889
  // The merged response stays bounded: past the budget a file is listed
678
890
  // without its diff.
679
891
  let budget = MERGED_PATCH_MAX_BYTES - Buffer.byteLength(basePatch, 'utf8');
680
892
  let truncated = false;
681
893
  const files = [];
682
894
  const patches = [];
683
- for (const { patch, ...file } of nets) {
684
- const size = patch ? Buffer.byteLength(patch, 'utf8') : 0;
895
+ for (const { patch, ...file } of rows) {
896
+ const size = Buffer.byteLength(patch, 'utf8');
685
897
  if (patch && size <= budget) {
686
898
  budget -= size;
687
899
  patches.push(patch);
688
900
  } else if (patch) {
689
901
  truncated = true;
690
- files.push({ ...file, patchOmitted: true });
691
- continue;
902
+ file.patchOmitted = true;
692
903
  }
693
904
  files.push(file);
694
905
  }
906
+ // A baseline first seen after an older recorded edit (`late`) was not the
907
+ // session's first state: even with no row it cannot vouch for the set.
908
+ // Baselines the disk refused (`unsaved`) are known only to this process.
909
+ const partial =
910
+ baselines?.incomplete === true ||
911
+ baselines?.unsaved === true ||
912
+ unvouched.size > 0 ||
913
+ legacy.late.size > 0 ||
914
+ missing.length > 0 ||
915
+ changes.some((change) => change.change.partial === true);
916
+ const vouched = (gitBacked || source === 'empty' || Boolean(baselines)) && !partial;
917
+ const all = [...baseFiles, ...files];
918
+ // An unavailable Git baseline with nothing else to list stays unavailable.
919
+ if (all.length === 0 && !vouched && kept.supported === false) return { ...kept, reason: unavailableReason };
695
920
  return {
696
921
  ...kept,
697
922
  supported: true,
698
- authoritative: true,
699
- complete,
700
- files: [...baseFiles, ...files],
923
+ // Emptiness is claimed only when it can be vouched for.
924
+ authoritative: all.length > 0 || vouched,
925
+ ...(vouched ? { complete: true } : {}),
926
+ ...(partial ? { partial: true } : {}),
927
+ ...(baselines?.unsaved === true ? { baselinesUnsaved: true } : {}),
928
+ files: all,
701
929
  patch: [basePatch, ...patches].filter(Boolean).join(basePatch.endsWith('\n') || !basePatch ? '' : '\n'),
702
- patchTruncated: kept.patchTruncated === true || truncated,
703
- snapshotKind: cleared?.snapshotKind || 'session',
930
+ patchTruncated: (gitBacked && kept.patchTruncated === true) || truncated,
931
+ snapshotKind: kept.snapshotKind || 'session',
704
932
  };
705
933
  }
706
934