@herbertgao/pi-extensions 2026.8.9 → 2026.8.11

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 (194) hide show
  1. package/README.md +4 -2
  2. package/node_modules/@herbertgao/resume-from/LICENSE +21 -0
  3. package/node_modules/@herbertgao/resume-from/README.md +163 -0
  4. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/adapter.d.ts +25 -0
  5. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/adapter.js +60 -0
  6. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/contract.d.ts +8 -0
  7. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/contract.js +4 -0
  8. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/entries.d.ts +30 -0
  9. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/entries.js +107 -0
  10. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/index.d.ts +3 -0
  11. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/index.js +2 -0
  12. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/layout.d.ts +29 -0
  13. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/layout.js +80 -0
  14. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/readback.d.ts +9 -0
  15. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/readback.js +39 -0
  16. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/reader.d.ts +37 -0
  17. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/reader.js +424 -0
  18. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/redaction.d.ts +26 -0
  19. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/redaction.js +132 -0
  20. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/validation.d.ts +4 -0
  21. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/validation.js +39 -0
  22. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/writer.d.ts +23 -0
  23. package/node_modules/@herbertgao/resume-from/dist/adapters/claude-code/writer.js +306 -0
  24. package/node_modules/@herbertgao/resume-from/dist/adapters/codex/contract.d.ts +15 -0
  25. package/node_modules/@herbertgao/resume-from/dist/adapters/codex/contract.js +4 -0
  26. package/node_modules/@herbertgao/resume-from/dist/adapters/codex/index.d.ts +6 -0
  27. package/node_modules/@herbertgao/resume-from/dist/adapters/codex/index.js +44 -0
  28. package/node_modules/@herbertgao/resume-from/dist/adapters/codex/read.d.ts +28 -0
  29. package/node_modules/@herbertgao/resume-from/dist/adapters/codex/read.js +270 -0
  30. package/node_modules/@herbertgao/resume-from/dist/adapters/codex/readback.d.ts +9 -0
  31. package/node_modules/@herbertgao/resume-from/dist/adapters/codex/readback.js +47 -0
  32. package/node_modules/@herbertgao/resume-from/dist/adapters/codex/redaction.d.ts +26 -0
  33. package/node_modules/@herbertgao/resume-from/dist/adapters/codex/redaction.js +132 -0
  34. package/node_modules/@herbertgao/resume-from/dist/adapters/codex/rollout.d.ts +75 -0
  35. package/node_modules/@herbertgao/resume-from/dist/adapters/codex/rollout.js +185 -0
  36. package/node_modules/@herbertgao/resume-from/dist/adapters/codex/validation.d.ts +7 -0
  37. package/node_modules/@herbertgao/resume-from/dist/adapters/codex/validation.js +78 -0
  38. package/node_modules/@herbertgao/resume-from/dist/adapters/codex/write.d.ts +17 -0
  39. package/node_modules/@herbertgao/resume-from/dist/adapters/codex/write.js +141 -0
  40. package/node_modules/@herbertgao/resume-from/dist/adapters/contract.d.ts +74 -0
  41. package/node_modules/@herbertgao/resume-from/dist/adapters/contract.js +4 -0
  42. package/node_modules/@herbertgao/resume-from/dist/adapters/pi/adapter.d.ts +15 -0
  43. package/node_modules/@herbertgao/resume-from/dist/adapters/pi/adapter.js +209 -0
  44. package/node_modules/@herbertgao/resume-from/dist/adapters/pi/contract.d.ts +24 -0
  45. package/node_modules/@herbertgao/resume-from/dist/adapters/pi/contract.js +4 -0
  46. package/node_modules/@herbertgao/resume-from/dist/adapters/pi/format.d.ts +162 -0
  47. package/node_modules/@herbertgao/resume-from/dist/adapters/pi/format.js +223 -0
  48. package/node_modules/@herbertgao/resume-from/dist/adapters/pi/index.d.ts +7 -0
  49. package/node_modules/@herbertgao/resume-from/dist/adapters/pi/index.js +10 -0
  50. package/node_modules/@herbertgao/resume-from/dist/adapters/pi/parse.d.ts +41 -0
  51. package/node_modules/@herbertgao/resume-from/dist/adapters/pi/parse.js +371 -0
  52. package/node_modules/@herbertgao/resume-from/dist/adapters/pi/redaction.d.ts +26 -0
  53. package/node_modules/@herbertgao/resume-from/dist/adapters/pi/redaction.js +132 -0
  54. package/node_modules/@herbertgao/resume-from/dist/adapters/pi/serialize.d.ts +22 -0
  55. package/node_modules/@herbertgao/resume-from/dist/adapters/pi/serialize.js +87 -0
  56. package/node_modules/@herbertgao/resume-from/dist/adapters/pi/validate.d.ts +14 -0
  57. package/node_modules/@herbertgao/resume-from/dist/adapters/pi/validate.js +134 -0
  58. package/node_modules/@herbertgao/resume-from/dist/bin.d.ts +35 -0
  59. package/node_modules/@herbertgao/resume-from/dist/bin.js +135 -0
  60. package/node_modules/@herbertgao/resume-from/dist/contract.d.ts +10 -0
  61. package/node_modules/@herbertgao/resume-from/dist/contract.js +4 -0
  62. package/node_modules/@herbertgao/resume-from/dist/host/agents.d.ts +29 -0
  63. package/node_modules/@herbertgao/resume-from/dist/host/agents.js +20 -0
  64. package/node_modules/@herbertgao/resume-from/dist/host/cli/args.d.ts +21 -0
  65. package/node_modules/@herbertgao/resume-from/dist/host/cli/args.js +187 -0
  66. package/node_modules/@herbertgao/resume-from/dist/host/cli/contract.d.ts +27 -0
  67. package/node_modules/@herbertgao/resume-from/dist/host/cli/contract.js +4 -0
  68. package/node_modules/@herbertgao/resume-from/dist/host/cli/index.d.ts +2 -0
  69. package/node_modules/@herbertgao/resume-from/dist/host/cli/index.js +1 -0
  70. package/node_modules/@herbertgao/resume-from/dist/host/cli/presentation.d.ts +2 -0
  71. package/node_modules/@herbertgao/resume-from/dist/host/cli/presentation.js +11 -0
  72. package/node_modules/@herbertgao/resume-from/dist/host/cli/render.d.ts +3 -0
  73. package/node_modules/@herbertgao/resume-from/dist/host/cli/render.js +70 -0
  74. package/node_modules/@herbertgao/resume-from/dist/host/cli/runner.d.ts +8 -0
  75. package/node_modules/@herbertgao/resume-from/dist/host/cli/runner.js +125 -0
  76. package/node_modules/@herbertgao/resume-from/dist/host/contract.d.ts +42 -0
  77. package/node_modules/@herbertgao/resume-from/dist/host/contract.js +4 -0
  78. package/node_modules/@herbertgao/resume-from/dist/host/entry.d.ts +52 -0
  79. package/node_modules/@herbertgao/resume-from/dist/host/entry.js +91 -0
  80. package/node_modules/@herbertgao/resume-from/dist/host/index.d.ts +11 -0
  81. package/node_modules/@herbertgao/resume-from/dist/host/index.js +10 -0
  82. package/node_modules/@herbertgao/resume-from/dist/host/pi-extension/command.d.ts +18 -0
  83. package/node_modules/@herbertgao/resume-from/dist/host/pi-extension/command.js +103 -0
  84. package/node_modules/@herbertgao/resume-from/dist/host/pi-extension/contract.d.ts +34 -0
  85. package/node_modules/@herbertgao/resume-from/dist/host/pi-extension/contract.js +4 -0
  86. package/node_modules/@herbertgao/resume-from/dist/host/pi-extension/index.d.ts +6 -0
  87. package/node_modules/@herbertgao/resume-from/dist/host/pi-extension/index.js +4 -0
  88. package/node_modules/@herbertgao/resume-from/dist/host/pi-extension/picker.d.ts +16 -0
  89. package/node_modules/@herbertgao/resume-from/dist/host/pi-extension/picker.js +35 -0
  90. package/node_modules/@herbertgao/resume-from/dist/host/pi-extension/presentation.d.ts +2 -0
  91. package/node_modules/@herbertgao/resume-from/dist/host/pi-extension/presentation.js +11 -0
  92. package/node_modules/@herbertgao/resume-from/dist/host/pi-extension/register.d.ts +25 -0
  93. package/node_modules/@herbertgao/resume-from/dist/host/pi-extension/register.js +11 -0
  94. package/node_modules/@herbertgao/resume-from/dist/host/pi-extension/ui.d.ts +20 -0
  95. package/node_modules/@herbertgao/resume-from/dist/host/pi-extension/ui.js +1 -0
  96. package/node_modules/@herbertgao/resume-from/dist/host/profile.d.ts +8 -0
  97. package/node_modules/@herbertgao/resume-from/dist/host/profile.js +31 -0
  98. package/node_modules/@herbertgao/resume-from/dist/host/registry.d.ts +8 -0
  99. package/node_modules/@herbertgao/resume-from/dist/host/registry.js +33 -0
  100. package/node_modules/@herbertgao/resume-from/dist/host/wiring.d.ts +41 -0
  101. package/node_modules/@herbertgao/resume-from/dist/host/wiring.js +67 -0
  102. package/node_modules/@herbertgao/resume-from/dist/import/confirmation.d.ts +6 -0
  103. package/node_modules/@herbertgao/resume-from/dist/import/confirmation.js +27 -0
  104. package/node_modules/@herbertgao/resume-from/dist/import/contract.d.ts +41 -0
  105. package/node_modules/@herbertgao/resume-from/dist/import/contract.js +4 -0
  106. package/node_modules/@herbertgao/resume-from/dist/import/discovery/contract.d.ts +58 -0
  107. package/node_modules/@herbertgao/resume-from/dist/import/discovery/contract.js +4 -0
  108. package/node_modules/@herbertgao/resume-from/dist/import/discovery/errors.d.ts +9 -0
  109. package/node_modules/@herbertgao/resume-from/dist/import/discovery/errors.js +12 -0
  110. package/node_modules/@herbertgao/resume-from/dist/import/discovery/finder.d.ts +10 -0
  111. package/node_modules/@herbertgao/resume-from/dist/import/discovery/finder.js +129 -0
  112. package/node_modules/@herbertgao/resume-from/dist/import/discovery/homes.d.ts +23 -0
  113. package/node_modules/@herbertgao/resume-from/dist/import/discovery/homes.js +83 -0
  114. package/node_modules/@herbertgao/resume-from/dist/import/discovery/index.d.ts +2 -0
  115. package/node_modules/@herbertgao/resume-from/dist/import/discovery/index.js +2 -0
  116. package/node_modules/@herbertgao/resume-from/dist/import/discovery/ordering.d.ts +3 -0
  117. package/node_modules/@herbertgao/resume-from/dist/import/discovery/ordering.js +21 -0
  118. package/node_modules/@herbertgao/resume-from/dist/import/errors.d.ts +22 -0
  119. package/node_modules/@herbertgao/resume-from/dist/import/errors.js +20 -0
  120. package/node_modules/@herbertgao/resume-from/dist/import/index.d.ts +3 -0
  121. package/node_modules/@herbertgao/resume-from/dist/import/index.js +5 -0
  122. package/node_modules/@herbertgao/resume-from/dist/import/landing/contract.d.ts +44 -0
  123. package/node_modules/@herbertgao/resume-from/dist/import/landing/contract.js +4 -0
  124. package/node_modules/@herbertgao/resume-from/dist/import/landing/errors.d.ts +15 -0
  125. package/node_modules/@herbertgao/resume-from/dist/import/landing/errors.js +15 -0
  126. package/node_modules/@herbertgao/resume-from/dist/import/landing/handover.d.ts +5 -0
  127. package/node_modules/@herbertgao/resume-from/dist/import/landing/handover.js +12 -0
  128. package/node_modules/@herbertgao/resume-from/dist/import/landing/index.d.ts +4 -0
  129. package/node_modules/@herbertgao/resume-from/dist/import/landing/index.js +4 -0
  130. package/node_modules/@herbertgao/resume-from/dist/import/landing/lander.d.ts +7 -0
  131. package/node_modules/@herbertgao/resume-from/dist/import/landing/lander.js +133 -0
  132. package/node_modules/@herbertgao/resume-from/dist/import/landing/marker.d.ts +9 -0
  133. package/node_modules/@herbertgao/resume-from/dist/import/landing/marker.js +46 -0
  134. package/node_modules/@herbertgao/resume-from/dist/import/pipeline.d.ts +17 -0
  135. package/node_modules/@herbertgao/resume-from/dist/import/pipeline.js +134 -0
  136. package/node_modules/@herbertgao/resume-from/dist/import/preview/builder.d.ts +6 -0
  137. package/node_modules/@herbertgao/resume-from/dist/import/preview/builder.js +76 -0
  138. package/node_modules/@herbertgao/resume-from/dist/import/preview/contract.d.ts +39 -0
  139. package/node_modules/@herbertgao/resume-from/dist/import/preview/contract.js +4 -0
  140. package/node_modules/@herbertgao/resume-from/dist/import/preview/format.d.ts +19 -0
  141. package/node_modules/@herbertgao/resume-from/dist/import/preview/format.js +45 -0
  142. package/node_modules/@herbertgao/resume-from/dist/import/preview/index.d.ts +2 -0
  143. package/node_modules/@herbertgao/resume-from/dist/import/preview/index.js +1 -0
  144. package/node_modules/@herbertgao/resume-from/dist/import/preview/warnings.d.ts +11 -0
  145. package/node_modules/@herbertgao/resume-from/dist/import/preview/warnings.js +80 -0
  146. package/node_modules/@herbertgao/resume-from/dist/import/transfer/contract.d.ts +48 -0
  147. package/node_modules/@herbertgao/resume-from/dist/import/transfer/contract.js +4 -0
  148. package/node_modules/@herbertgao/resume-from/dist/import/transfer/index.d.ts +2 -0
  149. package/node_modules/@herbertgao/resume-from/dist/import/transfer/index.js +1 -0
  150. package/node_modules/@herbertgao/resume-from/dist/import/transfer/rules.d.ts +3 -0
  151. package/node_modules/@herbertgao/resume-from/dist/import/transfer/rules.js +228 -0
  152. package/node_modules/@herbertgao/resume-from/dist/import/wiring.d.ts +24 -0
  153. package/node_modules/@herbertgao/resume-from/dist/import/wiring.js +27 -0
  154. package/node_modules/@herbertgao/resume-from/dist/index.d.ts +36 -0
  155. package/node_modules/@herbertgao/resume-from/dist/index.js +36 -0
  156. package/node_modules/@herbertgao/resume-from/dist/platform/config/contract.d.ts +35 -0
  157. package/node_modules/@herbertgao/resume-from/dist/platform/config/contract.js +4 -0
  158. package/node_modules/@herbertgao/resume-from/dist/platform/config/defaults.d.ts +16 -0
  159. package/node_modules/@herbertgao/resume-from/dist/platform/config/defaults.js +22 -0
  160. package/node_modules/@herbertgao/resume-from/dist/platform/config/errors.d.ts +9 -0
  161. package/node_modules/@herbertgao/resume-from/dist/platform/config/errors.js +12 -0
  162. package/node_modules/@herbertgao/resume-from/dist/platform/config/index.d.ts +5 -0
  163. package/node_modules/@herbertgao/resume-from/dist/platform/config/index.js +6 -0
  164. package/node_modules/@herbertgao/resume-from/dist/platform/config/loader.d.ts +10 -0
  165. package/node_modules/@herbertgao/resume-from/dist/platform/config/loader.js +47 -0
  166. package/node_modules/@herbertgao/resume-from/dist/platform/config/paths.d.ts +13 -0
  167. package/node_modules/@herbertgao/resume-from/dist/platform/config/paths.js +37 -0
  168. package/node_modules/@herbertgao/resume-from/dist/platform/config/validate.d.ts +9 -0
  169. package/node_modules/@herbertgao/resume-from/dist/platform/config/validate.js +117 -0
  170. package/node_modules/@herbertgao/resume-from/dist/platform/repo/contract.d.ts +30 -0
  171. package/node_modules/@herbertgao/resume-from/dist/platform/repo/contract.js +4 -0
  172. package/node_modules/@herbertgao/resume-from/dist/platform/repo/git.d.ts +21 -0
  173. package/node_modules/@herbertgao/resume-from/dist/platform/repo/git.js +74 -0
  174. package/node_modules/@herbertgao/resume-from/dist/platform/repo/index.d.ts +2 -0
  175. package/node_modules/@herbertgao/resume-from/dist/platform/repo/index.js +1 -0
  176. package/node_modules/@herbertgao/resume-from/dist/platform/repo/reader.d.ts +8 -0
  177. package/node_modules/@herbertgao/resume-from/dist/platform/repo/reader.js +130 -0
  178. package/node_modules/@herbertgao/resume-from/dist/platform/store/contract.d.ts +31 -0
  179. package/node_modules/@herbertgao/resume-from/dist/platform/store/contract.js +4 -0
  180. package/node_modules/@herbertgao/resume-from/dist/platform/store/file-committer.d.ts +3 -0
  181. package/node_modules/@herbertgao/resume-from/dist/platform/store/file-committer.js +391 -0
  182. package/node_modules/@herbertgao/resume-from/dist/platform/store/index.d.ts +2 -0
  183. package/node_modules/@herbertgao/resume-from/dist/platform/store/index.js +2 -0
  184. package/node_modules/@herbertgao/resume-from/dist/platform/tokens/contract.d.ts +12 -0
  185. package/node_modules/@herbertgao/resume-from/dist/platform/tokens/contract.js +4 -0
  186. package/node_modules/@herbertgao/resume-from/dist/platform/tokens/estimator.d.ts +12 -0
  187. package/node_modules/@herbertgao/resume-from/dist/platform/tokens/estimator.js +111 -0
  188. package/node_modules/@herbertgao/resume-from/dist/platform/tokens/index.d.ts +2 -0
  189. package/node_modules/@herbertgao/resume-from/dist/platform/tokens/index.js +1 -0
  190. package/node_modules/@herbertgao/resume-from/dist/session/contract.d.ts +109 -0
  191. package/node_modules/@herbertgao/resume-from/dist/session/contract.js +4 -0
  192. package/node_modules/@herbertgao/resume-from/package.json +94 -0
  193. package/node_modules/@herbertgao/resume-from/shims/pi/extensions/resume-from.js +118 -0
  194. package/package.json +6 -2
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Writing a canonical session into a new Claude Code session file.
3
+ *
4
+ * C-9 measured that a file holding one `user` entry and one `assistant` entry is listed by the
5
+ * native resume list and opened by `claude --resume <id>` as native turns. This module writes
6
+ * exactly those two entry types and nothing else: the other eight types of C-3 are eight more
7
+ * ways to corrupt an internal application database.
8
+ *
9
+ * Nothing here touches the filesystem except to read. `serialize` returns bytes;
10
+ * `src/import/landing/` creates the files (FR-49, FR-53).
11
+ */
12
+ import { createHash, randomUUID } from "node:crypto";
13
+ import { existsSync, readdirSync } from "node:fs";
14
+ import path from "node:path";
15
+ import { asObject, asString, ENTRY_TYPE_ASSISTANT, ENTRY_TYPE_USER, parseJsonl, toIsoUtc, WRITTEN_ENTRY_TYPES, } from "./entries.js";
16
+ import { projectDir, SESSION_FILE_SUFFIX, sessionFilePath } from "./layout.js";
17
+ /** The version C-9 was measured against. Written so the target reads a version it knows. */
18
+ export const CLAUDE_CODE_FORMAT_VERSION = "2.1.220";
19
+ /** The model field of an imported turn. No model produced it, and none is claimed. */
20
+ export const IMPORTED_MODEL = "imported";
21
+ export const USER_TYPE = "external";
22
+ const EMPTY_TURN_TEXT = "(empty message)";
23
+ const TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
24
+ /** A per-session sidecar directory is named after its session. */
25
+ const SIDECAR_DIR_PATTERN = /^[0-9a-fA-F-]{8,}$/;
26
+ const INERT_PROJECT_FILES = new Set(["sessions-index.json", ".session-aliases"]);
27
+ const INERT_PROJECT_DIRS = new Set(["memory"]);
28
+ const MAX_MINT_ATTEMPTS = 8;
29
+ /** sha256-derived uuid, so one minted session id fixes every entry of the file. */
30
+ function deterministicUuid(seed, index) {
31
+ const digest = createHash("sha256").update(`${seed}:${index}`).digest();
32
+ const bytes = Buffer.from(digest.subarray(0, 16));
33
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
34
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
35
+ const hex = bytes.toString("hex");
36
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
37
+ }
38
+ /**
39
+ * Refuse when the repository's record holds anything this adapter does not recognise.
40
+ *
41
+ * A stranger in that directory may be a registration index the new session would have to be
42
+ * added to, and adding it would mean opening an existing file for writing. FR-49 outranks
43
+ * convenience, so the adapter declares the limitation instead of guessing (C-3).
44
+ */
45
+ function refuseUnrecognisedProjectRecord(home, repoPath) {
46
+ const dir = projectDir(home, repoPath);
47
+ let items;
48
+ try {
49
+ items = readdirSync(dir, { withFileTypes: true });
50
+ }
51
+ catch {
52
+ return; // a record that does not exist yet is a new path, which is exactly what is allowed
53
+ }
54
+ for (const item of items) {
55
+ if (item.isFile() && item.name.endsWith(SESSION_FILE_SUFFIX))
56
+ continue;
57
+ if (item.isFile() && INERT_PROJECT_FILES.has(item.name))
58
+ continue;
59
+ if (item.isDirectory() && SIDECAR_DIR_PATTERN.test(item.name))
60
+ continue;
61
+ if (item.isDirectory() && INERT_PROJECT_DIRS.has(item.name))
62
+ continue;
63
+ throw new Error(`Claude Code adapter: ${path.join(dir, item.name)} is not a session file this adapter ` +
64
+ "recognises. Registering the imported session may require editing it, and this adapter " +
65
+ "only ever creates new paths (FR-49). Import refused.");
66
+ }
67
+ }
68
+ /** A session id whose file does not exist in the target home. A collision is never overwritten. */
69
+ function mintSessionId(home, repoPath) {
70
+ for (let attempt = 0; attempt < MAX_MINT_ATTEMPTS; attempt++) {
71
+ const id = randomUUID();
72
+ if (!existsSync(sessionFilePath(home, repoPath, id)))
73
+ return id;
74
+ }
75
+ throw new Error(`Claude Code adapter: could not mint an unused session id in ${home}`);
76
+ }
77
+ function envelope(ctx, index, parentUuid, timestamp) {
78
+ return {
79
+ parentUuid,
80
+ isSidechain: false,
81
+ userType: USER_TYPE,
82
+ cwd: ctx.repoPath,
83
+ sessionId: ctx.sessionId,
84
+ version: CLAUDE_CODE_FORMAT_VERSION,
85
+ gitBranch: ctx.branch,
86
+ uuid: deterministicUuid(ctx.sessionId, index),
87
+ timestamp,
88
+ };
89
+ }
90
+ /**
91
+ * The provenance marker (FR-47, FR-48).
92
+ *
93
+ * It rides in a `user` entry flagged `isMeta`, the flag Claude Code puts on entries it shows in
94
+ * the transcript but does not send as a turn. C-9 proved the entry type; it did not prove the
95
+ * flag. T-CC-17 is what can falsify it — if the marker ever reaches the model, this module
96
+ * declares `provenance: "host-output-only"` instead and the marker moves to host output.
97
+ */
98
+ function outOfContextMarkerEntry(ctx, marker) {
99
+ return {
100
+ ...envelope(ctx, 0, null, toIsoUtc(marker.importedAt) ?? new Date(0).toISOString()),
101
+ type: ENTRY_TYPE_USER,
102
+ isMeta: true,
103
+ message: { role: "user", content: marker.lines.join("\n") },
104
+ };
105
+ }
106
+ /** What one canonical turn says, as text. A tool call crosses as its one outcome line (FR-23). */
107
+ function turnText(turn) {
108
+ const call = turn.toolCall;
109
+ if (turn.kind === "tool-call" && call !== null) {
110
+ // FR-27: the tool name crosses unchanged, whatever adapter recorded the call.
111
+ return call.outcomeLine.includes(call.toolName)
112
+ ? call.outcomeLine
113
+ : `${call.toolName}(${call.argumentsText}) → ${call.outcomeLine}`;
114
+ }
115
+ return turn.text.trim() === "" ? EMPTY_TURN_TEXT : turn.text;
116
+ }
117
+ function buildEntries(session, marker, ctx) {
118
+ const fallbackTimestamp = toIsoUtc(marker.importedAt) ??
119
+ toIsoUtc(session.provenance.updatedAt) ??
120
+ new Date(0).toISOString();
121
+ const entries = [outOfContextMarkerEntry(ctx, marker)];
122
+ for (const turn of session.turns) {
123
+ const index = entries.length;
124
+ const previous = entries[index - 1];
125
+ const parentUuid = asString(previous?.uuid);
126
+ const timestamp = toIsoUtc(turn.timestamp) ?? fallbackTimestamp;
127
+ const base = envelope(ctx, index, parentUuid, timestamp);
128
+ const text = turnText(turn);
129
+ if (turn.role === "user") {
130
+ entries.push({
131
+ ...base,
132
+ type: ENTRY_TYPE_USER,
133
+ message: { role: "user", content: text },
134
+ });
135
+ continue;
136
+ }
137
+ entries.push({
138
+ ...base,
139
+ type: ENTRY_TYPE_ASSISTANT,
140
+ message: {
141
+ id: `msg_${base.uuid.replace(/-/g, "").slice(0, 24)}`,
142
+ type: "message",
143
+ role: "assistant",
144
+ model: IMPORTED_MODEL,
145
+ content: [{ type: "text", text }],
146
+ stop_reason: "end_turn",
147
+ stop_sequence: null,
148
+ usage: {
149
+ input_tokens: 0,
150
+ output_tokens: 0,
151
+ cache_creation_input_tokens: 0,
152
+ cache_read_input_tokens: 0,
153
+ service_tier: "standard",
154
+ },
155
+ },
156
+ });
157
+ }
158
+ return entries;
159
+ }
160
+ /** Bytes only: the file is created by `src/import/landing/`, never here (FR-49, FR-53). */
161
+ export function serialize(session, target, marker, context) {
162
+ const repoPath = path.resolve(context.cwd);
163
+ refuseUnrecognisedProjectRecord(target.home, repoPath);
164
+ const sessionId = mintSessionId(target.home, repoPath);
165
+ const entries = buildEntries(session, marker, {
166
+ sessionId,
167
+ repoPath,
168
+ branch: session.provenance.repo.branch ?? "",
169
+ });
170
+ const text = `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
171
+ return {
172
+ sessionId,
173
+ files: [
174
+ {
175
+ absolutePath: sessionFilePath(target.home, repoPath, sessionId),
176
+ bytes: Buffer.from(text, "utf8"),
177
+ },
178
+ ],
179
+ itemCount: entries.length,
180
+ };
181
+ }
182
+ function checkEntry(entry, index, previousUuid, sessionId, defects) {
183
+ const at = `items/${index}`;
184
+ const type = entry.type;
185
+ if (typeof type !== "string" || !WRITTEN_ENTRY_TYPES.includes(type)) {
186
+ defects.push({
187
+ path: `${at}/type`,
188
+ message: `entry type ${JSON.stringify(type)} is not one of ${WRITTEN_ENTRY_TYPES.join(", ")}; Claude Code would not show it as a turn`,
189
+ });
190
+ }
191
+ if (asString(entry.uuid) === null) {
192
+ defects.push({
193
+ path: `${at}/uuid`,
194
+ message: "missing uuid; the transcript chain cannot be built",
195
+ });
196
+ }
197
+ const timestamp = asString(entry.timestamp);
198
+ if (timestamp === null || !TIMESTAMP_PATTERN.test(timestamp)) {
199
+ defects.push({
200
+ path: `${at}/timestamp`,
201
+ message: `malformed timestamp ${JSON.stringify(entry.timestamp)}; the resume list sorts on it`,
202
+ });
203
+ }
204
+ if (asString(entry.sessionId) !== sessionId) {
205
+ defects.push({
206
+ path: `${at}/sessionId`,
207
+ message: `entry sessionId ${JSON.stringify(entry.sessionId)} does not match the session being written`,
208
+ });
209
+ }
210
+ if (index === 0) {
211
+ if (entry.parentUuid !== null) {
212
+ defects.push({
213
+ path: `${at}/parentUuid`,
214
+ message: "the first entry must have a null parentUuid",
215
+ });
216
+ }
217
+ }
218
+ else if (asString(entry.parentUuid) !== previousUuid) {
219
+ defects.push({
220
+ path: `${at}/parentUuid`,
221
+ message: "parentUuid does not point at the entry before it; the transcript would break",
222
+ });
223
+ }
224
+ const message = asObject(entry.message);
225
+ if (message === null) {
226
+ defects.push({
227
+ path: `${at}/message`,
228
+ message: "null or missing message body; Claude Code would fail on it",
229
+ });
230
+ return;
231
+ }
232
+ const expectedRole = type === ENTRY_TYPE_ASSISTANT ? "assistant" : "user";
233
+ if (message.role !== expectedRole) {
234
+ defects.push({
235
+ path: `${at}/message/role`,
236
+ message: `message role ${JSON.stringify(message.role)} does not match entry type ${JSON.stringify(type)}`,
237
+ });
238
+ }
239
+ const content = message.content;
240
+ const emptyContent = content === null ||
241
+ content === undefined ||
242
+ (typeof content === "string" && content === "") ||
243
+ (Array.isArray(content) && content.length === 0);
244
+ if (emptyContent) {
245
+ defects.push({
246
+ path: `${at}/message/content`,
247
+ message: "empty message content; the turn would be invisible",
248
+ });
249
+ }
250
+ if (type === ENTRY_TYPE_ASSISTANT) {
251
+ if (asObject(message.usage) === null) {
252
+ defects.push({
253
+ path: `${at}/message/usage`,
254
+ message: "assistant message has no usage object",
255
+ });
256
+ }
257
+ if (asString(message.model) === null) {
258
+ defects.push({
259
+ path: `${at}/message/model`,
260
+ message: "assistant message has no model",
261
+ });
262
+ }
263
+ }
264
+ }
265
+ /** Every structural defect, not the first (FR-50). Empty means the bytes may be placed. */
266
+ export function validate(serialized) {
267
+ const defects = [];
268
+ if (serialized.files.length === 0) {
269
+ defects.push({ path: "files", message: "no file to place" });
270
+ }
271
+ let index = 0;
272
+ serialized.files.forEach((file, fileIndex) => {
273
+ if (!path.isAbsolute(file.absolutePath)) {
274
+ defects.push({
275
+ path: `files/${fileIndex}/absolutePath`,
276
+ message: "path is not absolute",
277
+ });
278
+ }
279
+ if (!file.absolutePath.endsWith(`${serialized.sessionId}${SESSION_FILE_SUFFIX}`)) {
280
+ defects.push({
281
+ path: `files/${fileIndex}/absolutePath`,
282
+ message: `file name does not match the session id ${serialized.sessionId}; Claude Code keys a session by its file name`,
283
+ });
284
+ }
285
+ const { entries, unreadable } = parseJsonl(file.bytes.toString("utf8"));
286
+ if (unreadable !== null) {
287
+ defects.push({
288
+ path: `files/${fileIndex}`,
289
+ message: `not a readable session file: ${unreadable}`,
290
+ });
291
+ }
292
+ let previousUuid = null;
293
+ for (const entry of entries) {
294
+ checkEntry(entry, index, previousUuid, serialized.sessionId, defects);
295
+ previousUuid = asString(entry.uuid);
296
+ index++;
297
+ }
298
+ });
299
+ if (index !== serialized.itemCount) {
300
+ defects.push({
301
+ path: "itemCount",
302
+ message: `itemCount is ${serialized.itemCount} but the files hold ${index} items; FR-52 compares the two`,
303
+ });
304
+ }
305
+ return defects;
306
+ }
@@ -0,0 +1,15 @@
1
+ import type { AdapterRole, AgentAdapter, AgentCapabilities, AgentRuntime, Bytes, LandingLevel, PendingFile, ProvenanceSupport, SelectionLevel, SerializedSession, StoredSessionFacts, SwitchOutcome, ValidationDefect } from "../contract.js";
2
+ export type { AdapterRole, AgentAdapter, AgentCapabilities, AgentRuntime, Bytes, LandingLevel, PendingFile, ProvenanceSupport, SelectionLevel, SerializedSession, StoredSessionFacts, SwitchOutcome, ValidationDefect, };
3
+ import type { AgentId, CanonicalSession, CanonicalTurn, HomePath, ProvenanceMarker, RepoSnapshot, SessionDescriptor, SessionId, SessionRef, SourceProvenance, TargetProfile, ToolCallRecord, ToolEffect, TurnKind, TurnRole } from "../../session/contract.js";
4
+ export type { AgentId, CanonicalSession, CanonicalTurn, HomePath, ProvenanceMarker, RepoSnapshot, SessionDescriptor, SessionId, SessionRef, SourceProvenance, TargetProfile, ToolCallRecord, ToolEffect, TurnKind, TurnRole, };
5
+ /** The injectable seam that keeps serialize pure (no ambient process state). */
6
+ export interface CodexSerializeDeps {
7
+ /** Where the user is when the import runs. `codex resume` filters the picker by cwd. */
8
+ cwd(): string;
9
+ /** Produces the new thread's UUID. Injected so two calls with the same deps are byte-equal. */
10
+ newSessionId(): string;
11
+ }
12
+ /** Builds the Codex adapter. The only export of this module (FR-57). */
13
+ export interface CodexAdapterFactory {
14
+ create(overrides?: Partial<CodexSerializeDeps>): AgentAdapter;
15
+ }
@@ -0,0 +1,4 @@
1
+ // GENERATED from src/adapters/codex/module.md — the Public Contract section is the normative home.
2
+ // Declarations only: no behaviour, no defaults. If this file and module.md disagree,
3
+ // the document wins and this file is corrected.
4
+ export {};
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The Codex adapter: everything the tool knows about Codex, behind the one contract every
3
+ * adapter implements (FR-57).
4
+ */
5
+ import type { CodexAdapterFactory } from "./contract.js";
6
+ export declare const codexAdapterFactory: CodexAdapterFactory;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The Codex adapter: everything the tool knows about Codex, behind the one contract every
3
+ * adapter implements (FR-57).
4
+ */
5
+ import { randomUUID } from "node:crypto";
6
+ import { listCodexSessions, loadCodexSession } from "./read.js";
7
+ import { readBackCodex, switchToCodex } from "./readback.js";
8
+ import { defaultCodexHome } from "./rollout.js";
9
+ import { serializeCodex, validateCodex } from "./write.js";
10
+ /** Codex's own default for the models it ships with. Configuration overrides it (FR-18). */
11
+ const CODEX_WINDOW_TOKENS = 258_400;
12
+ const DEFAULT_DEPS = {
13
+ cwd: () => process.cwd(),
14
+ newSessionId: () => randomUUID(),
15
+ };
16
+ function codexCapabilities() {
17
+ return {
18
+ agent: "codex",
19
+ roles: ["source", "target"],
20
+ // C-1: Codex cannot host our picker, so the user picks from a numbered list we print.
21
+ selection: "numbered-list",
22
+ // C-2: Codex cannot move the user, so the landing hands back the command (FR-45).
23
+ landing: "create-only",
24
+ // Codex has no verified durable, out-of-context entry shape. The CLI prints
25
+ // provenance after landing instead of writing it as a conversation turn.
26
+ provenance: "host-output-only",
27
+ defaultHome: defaultCodexHome(),
28
+ defaultWindowTokens: CODEX_WINDOW_TOKENS,
29
+ };
30
+ }
31
+ export const codexAdapterFactory = {
32
+ create(overrides = {}) {
33
+ const deps = { ...DEFAULT_DEPS, ...overrides };
34
+ return {
35
+ capabilities: codexCapabilities,
36
+ listSessions: listCodexSessions,
37
+ loadSession: loadCodexSession,
38
+ serialize: (session, target, marker) => serializeCodex(session, target, marker, deps),
39
+ validate: validateCodex,
40
+ readBack: readBackCodex,
41
+ switchTo: switchToCodex,
42
+ };
43
+ },
44
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The source role: a Codex home becomes a list of sessions, and one session becomes the
3
+ * canonical vocabulary. Every result body is dropped (FR-24) and every reasoning trace is
4
+ * ignored (FR-28, C-4).
5
+ */
6
+ import type { CanonicalSession, CanonicalTurn, SessionDescriptor } from "./contract.js";
7
+ import type { CodexSessionMeta } from "./rollout.js";
8
+ export interface CodexRollout {
9
+ filePath: string;
10
+ meta: CodexSessionMeta | null;
11
+ turns: CanonicalTurn[];
12
+ /** Entries of a type this module does not understand. Codex drops them in silence (C-6). */
13
+ skippedEntries: number;
14
+ startedAt: string | null;
15
+ updatedAt: string | null;
16
+ title: string;
17
+ changedPaths: string[];
18
+ truncated: boolean;
19
+ }
20
+ export declare class CodexRolloutUnreadableError extends Error {
21
+ constructor(filePath: string);
22
+ }
23
+ /** Lenient: a file cut mid-entry still yields whatever parsed, with `truncated` set. */
24
+ export declare function scanRollout(filePath: string): Promise<CodexRollout>;
25
+ /** Strict: FR-51's "unreadable" answer. */
26
+ export declare function readRollout(filePath: string): Promise<CodexRollout>;
27
+ export declare function listCodexSessions(home: string): Promise<SessionDescriptor[]>;
28
+ export declare function loadCodexSession(descriptor: SessionDescriptor): Promise<CanonicalSession>;
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The source role: a Codex home becomes a list of sessions, and one session becomes the
3
+ * canonical vocabulary. Every result body is dropped (FR-24) and every reasoning trace is
4
+ * ignored (FR-28, C-4).
5
+ */
6
+ import { readFile } from "node:fs/promises";
7
+ import { isAbsolute } from "node:path";
8
+ import { redactSensitiveArgumentsText, redactSensitiveText } from "./redaction.js";
9
+ import { CODEX_ENTRY_COMPACTED, CODEX_ENTRY_EVENT_MSG, CODEX_ENTRY_RESPONSE_ITEM, CODEX_EVENT_AGENT_MESSAGE, CODEX_EVENT_USER_MESSAGE, CODEX_ITEM_CUSTOM_TOOL_CALL, CODEX_ITEM_CUSTOM_TOOL_CALL_OUTPUT, CODEX_ITEM_FUNCTION_CALL, CODEX_ITEM_FUNCTION_CALL_OUTPUT, isNotFoundError, KNOWN_ENTRY_TYPES, listRolloutFiles, parseRolloutText, payloadType, readSessionMeta, sessionsRoot, toIsoUtc, } from "./rollout.js";
10
+ const TITLE_LIMIT = 80;
11
+ const ARGUMENTS_PREVIEW_LIMIT = 60;
12
+ /** Tools whose name alone settles the question (FR-26). Everything else stays "unknown". */
13
+ const READ_ONLY_TOOLS = new Set([
14
+ "read_file",
15
+ "read",
16
+ "view_image",
17
+ "list_dir",
18
+ "grep",
19
+ "find",
20
+ "web_search",
21
+ ]);
22
+ const MUTATING_TOOLS = new Set([
23
+ "apply_patch",
24
+ "write_file",
25
+ "edit_file",
26
+ "create_file",
27
+ "delete_file",
28
+ ]);
29
+ export class CodexRolloutUnreadableError extends Error {
30
+ constructor(filePath) {
31
+ super(`Codex thread is unreadable: ${filePath} was cut mid-entry`);
32
+ this.name = "CodexRolloutUnreadableError";
33
+ }
34
+ }
35
+ /** Lenient: a file cut mid-entry still yields whatever parsed, with `truncated` set. */
36
+ export async function scanRollout(filePath) {
37
+ // Whole-file read: listing a home reads every rollout, and C-5 measured those files
38
+ // in megabytes. Ceiling accepted because title and turn count both need the whole file;
39
+ // revisit if a home with thousands of threads makes the listing feel slow.
40
+ const text = await readFile(filePath, "utf8");
41
+ const { entries, truncated } = parseRolloutText(text);
42
+ let meta = null;
43
+ for (const entry of entries) {
44
+ meta = readSessionMeta(entry);
45
+ if (meta !== null)
46
+ break;
47
+ }
48
+ const { turns, skippedEntries, changedPaths } = toCanonicalTurns(entries);
49
+ const firstUserTurn = turns.find((turn) => turn.role === "user" && turn.kind === "message");
50
+ const lastStamp = [...entries].reverse().find((entry) => toIsoUtc(entry.timestamp) !== null);
51
+ return {
52
+ filePath,
53
+ meta,
54
+ turns,
55
+ skippedEntries,
56
+ startedAt: toIsoUtc(meta?.timestamp ?? entries[0]?.timestamp ?? null),
57
+ updatedAt: toIsoUtc(lastStamp?.timestamp ?? meta?.timestamp ?? null),
58
+ title: titleOf(firstUserTurn?.text ?? ""),
59
+ changedPaths,
60
+ truncated,
61
+ };
62
+ }
63
+ /** Strict: FR-51's "unreadable" answer. */
64
+ export async function readRollout(filePath) {
65
+ const rollout = await scanRollout(filePath);
66
+ if (rollout.truncated)
67
+ throw new CodexRolloutUnreadableError(filePath);
68
+ return rollout;
69
+ }
70
+ export async function listCodexSessions(home) {
71
+ const descriptors = [];
72
+ for (const filePath of await listRolloutFiles(sessionsRoot(home))) {
73
+ let rollout;
74
+ try {
75
+ rollout = await scanRollout(filePath);
76
+ }
77
+ catch (error) {
78
+ if (isNotFoundError(error))
79
+ continue; // A rollout may disappear during a concurrent cleanup.
80
+ throw error;
81
+ }
82
+ if (rollout.meta === null)
83
+ continue;
84
+ descriptors.push({
85
+ ref: { agent: "codex", home, id: rollout.meta.id },
86
+ title: rollout.title,
87
+ startedAt: rollout.startedAt ?? "",
88
+ updatedAt: rollout.updatedAt ?? rollout.startedAt ?? "",
89
+ turnCount: rollout.turns.length,
90
+ repoPath: rollout.meta.cwd !== null && isAbsolute(rollout.meta.cwd) ? rollout.meta.cwd : null,
91
+ filePath,
92
+ });
93
+ }
94
+ descriptors.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
95
+ return descriptors;
96
+ }
97
+ export async function loadCodexSession(descriptor) {
98
+ const rollout = await readRollout(descriptor.filePath);
99
+ return {
100
+ provenance: {
101
+ ref: descriptor.ref,
102
+ title: descriptor.title,
103
+ startedAt: descriptor.startedAt,
104
+ updatedAt: descriptor.updatedAt,
105
+ repo: {
106
+ commit: rollout.meta?.commit ?? null,
107
+ branch: rollout.meta?.branch ?? null,
108
+ changedPaths: rollout.changedPaths,
109
+ },
110
+ },
111
+ turns: rollout.turns,
112
+ };
113
+ }
114
+ function titleOf(text) {
115
+ const line = text
116
+ .split("\n")
117
+ .find((candidate) => candidate.trim() !== "")
118
+ ?.trim() ?? "";
119
+ return line.length > TITLE_LIMIT ? `${line.slice(0, TITLE_LIMIT - 1)}…` : line;
120
+ }
121
+ /**
122
+ * Messages come from `event_msg` and tool calls from `response_item`: the same text appears in
123
+ * both shapes, and taking each from one place is what keeps every turn out of the session twice.
124
+ * Reasoning, in either shape, produces nothing at all (C-4, FR-28, NG-8).
125
+ */
126
+ function toCanonicalTurns(entries) {
127
+ const outputs = new Map();
128
+ for (const entry of entries) {
129
+ if (entry.type !== CODEX_ENTRY_RESPONSE_ITEM)
130
+ continue;
131
+ const type = payloadType(entry);
132
+ if (type !== CODEX_ITEM_FUNCTION_CALL_OUTPUT && type !== CODEX_ITEM_CUSTOM_TOOL_CALL_OUTPUT)
133
+ continue;
134
+ const callId = entry.payload.call_id;
135
+ if (typeof callId !== "string")
136
+ continue;
137
+ outputs.set(callId, outputTextOf(entry.payload.output));
138
+ }
139
+ const turns = [];
140
+ const changed = new Set();
141
+ let skippedEntries = 0;
142
+ for (const entry of entries) {
143
+ if (!KNOWN_ENTRY_TYPES.has(entry.type)) {
144
+ skippedEntries += 1;
145
+ continue;
146
+ }
147
+ const type = payloadType(entry);
148
+ if (entry.type === CODEX_ENTRY_COMPACTED) {
149
+ const message = entry.payload.message;
150
+ if (typeof message !== "string" || message.trim() === "")
151
+ continue;
152
+ turns.push({
153
+ index: turns.length,
154
+ role: "agent",
155
+ kind: "summary",
156
+ // FR-28, security: credentials in message text must not cross to a different vendor.
157
+ text: redactSensitiveText(message),
158
+ toolCall: null,
159
+ timestamp: toIsoUtc(entry.timestamp),
160
+ });
161
+ continue;
162
+ }
163
+ if (entry.type === CODEX_ENTRY_EVENT_MSG) {
164
+ if (type !== CODEX_EVENT_USER_MESSAGE && type !== CODEX_EVENT_AGENT_MESSAGE)
165
+ continue;
166
+ const message = entry.payload.message;
167
+ if (typeof message !== "string" || message.trim() === "")
168
+ continue;
169
+ turns.push({
170
+ index: turns.length,
171
+ role: type === CODEX_EVENT_USER_MESSAGE ? "user" : "agent",
172
+ kind: "message",
173
+ // FR-28, security: credentials in message text must not cross to a different vendor.
174
+ text: redactSensitiveText(message),
175
+ toolCall: null,
176
+ timestamp: toIsoUtc(entry.timestamp),
177
+ });
178
+ continue;
179
+ }
180
+ if (entry.type !== CODEX_ENTRY_RESPONSE_ITEM)
181
+ continue;
182
+ if (type !== CODEX_ITEM_FUNCTION_CALL && type !== CODEX_ITEM_CUSTOM_TOOL_CALL)
183
+ continue;
184
+ const toolName = typeof entry.payload.name === "string" ? entry.payload.name : "";
185
+ if (toolName === "")
186
+ continue;
187
+ const rawArguments = type === CODEX_ITEM_FUNCTION_CALL ? entry.payload.arguments : entry.payload.input;
188
+ const argumentsText = typeof rawArguments === "string" ? rawArguments : "";
189
+ const callId = entry.payload.call_id;
190
+ // FR-54: use has() not get() — outputs.get() can return "" for an empty result.
191
+ const resultRecorded = typeof callId === "string" && outputs.has(callId);
192
+ const output = typeof callId === "string" ? (outputs.get(callId) ?? null) : null;
193
+ const toolCall = toolCallRecord(toolName, argumentsText, output, resultRecorded);
194
+ if (toolCall.effect === "mutating")
195
+ for (const path of pathsOf(argumentsText))
196
+ changed.add(path);
197
+ turns.push({
198
+ index: turns.length,
199
+ role: "agent",
200
+ kind: "tool-call",
201
+ text: "",
202
+ toolCall,
203
+ timestamp: toIsoUtc(entry.timestamp),
204
+ });
205
+ }
206
+ return { turns, skippedEntries, changedPaths: [...changed] };
207
+ }
208
+ /** Only the shape of the output is measured. Not one fragment of it is carried (FR-24, FR-25). */
209
+ function toolCallRecord(toolName, argumentsText, output, resultRecorded) {
210
+ const safeArguments = redactSensitiveArgumentsText(argumentsText);
211
+ const head = `${toolName}(${singleLine(safeArguments, ARGUMENTS_PREVIEW_LIMIT)})`;
212
+ const outcomeLine = output === null
213
+ ? `${head} → no output recorded`
214
+ : `${head} → ${output.split("\n").length} lines, body dropped`;
215
+ return {
216
+ toolName,
217
+ argumentsText: safeArguments,
218
+ outcomeLine: redactSensitiveText(outcomeLine),
219
+ effect: effectOf(toolName),
220
+ bodyDropped: output !== null,
221
+ // FR-54: false when no *_call_output entry existed for this call_id (broken tail signal).
222
+ resultRecorded,
223
+ };
224
+ }
225
+ function effectOf(toolName) {
226
+ if (MUTATING_TOOLS.has(toolName))
227
+ return "mutating";
228
+ if (READ_ONLY_TOOLS.has(toolName))
229
+ return "read-only";
230
+ return "unknown";
231
+ }
232
+ function outputTextOf(output) {
233
+ if (typeof output === "string")
234
+ return output;
235
+ if (Array.isArray(output)) {
236
+ return output
237
+ .map((part) => typeof part === "object" && part !== null
238
+ ? String(part.text ?? "")
239
+ : "")
240
+ .join("\n");
241
+ }
242
+ return output === undefined || output === null ? "" : JSON.stringify(output);
243
+ }
244
+ function singleLine(text, limit) {
245
+ const flat = text.replaceAll(/\s+/g, " ").trim();
246
+ return flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat;
247
+ }
248
+ /** Files a mutating call touched, for RepoSnapshot.changedPaths (FR-36). */
249
+ function pathsOf(argumentsText) {
250
+ let text = argumentsText;
251
+ const direct = [];
252
+ try {
253
+ const parsed = JSON.parse(argumentsText);
254
+ if (typeof parsed === "object" && parsed !== null) {
255
+ const record = parsed;
256
+ const strings = Object.values(record).filter((value) => typeof value === "string");
257
+ text = strings.join("\n");
258
+ for (const key of ["path", "file_path", "filename"]) {
259
+ const value = record[key];
260
+ if (typeof value === "string" && value !== "")
261
+ direct.push(value);
262
+ }
263
+ }
264
+ }
265
+ catch {
266
+ // Arguments that are not JSON are searched as they were recorded.
267
+ }
268
+ const patched = [...text.matchAll(/\*\*\* (?:Add|Update|Delete) File: (.+)/g)].map((match) => (match[1] ?? "").trim());
269
+ return [...direct, ...patched].filter((path) => path !== "");
270
+ }