@searls/turbocommit 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/session.js CHANGED
@@ -1,28 +1,36 @@
1
1
  const fs = require('fs')
2
2
  const path = require('path')
3
3
  const { ensureDir } = require('./io')
4
+ const { gitCommonDir } = require('./git')
4
5
 
5
6
  const BREADCRUMB_THRESHOLD_MS = 2000
6
7
  const STALE_TTL_MS = 24 * 60 * 60 * 1000
7
8
 
8
9
  function turbocommitDir (root) {
9
- return path.join(root, '.git', 'turbocommit')
10
+ if (!root) return null
11
+ const gitDir = gitCommonDir(root)
12
+ if (!gitDir) return null
13
+ return path.join(gitDir, 'turbocommit')
10
14
  }
11
15
 
12
16
  function breadcrumbDir (root) {
13
- return path.join(turbocommitDir(root), 'breadcrumbs')
17
+ const base = turbocommitDir(root)
18
+ return base && path.join(base, 'breadcrumbs')
14
19
  }
15
20
 
16
21
  function chainDir (root) {
17
- return path.join(turbocommitDir(root), 'chains')
22
+ const base = turbocommitDir(root)
23
+ return base && path.join(base, 'chains')
18
24
  }
19
25
 
20
26
  function pendingDir (root) {
21
- return path.join(turbocommitDir(root), 'pending')
27
+ const base = turbocommitDir(root)
28
+ return base && path.join(base, 'pending')
22
29
  }
23
30
 
24
31
  function watermarkDir (root) {
25
- return path.join(turbocommitDir(root), 'watermarks')
32
+ const base = turbocommitDir(root)
33
+ return base && path.join(base, 'watermarks')
26
34
  }
27
35
 
28
36
  /**
@@ -31,17 +39,14 @@ function watermarkDir (root) {
31
39
  function handleSessionEnd (input, root) {
32
40
  if (!root) return
33
41
 
34
- let hookInput
35
- try {
36
- hookInput = JSON.parse(input)
37
- } catch {
38
- return
39
- }
42
+ const hookInput = typeof input === 'string' ? parseInput(input) : input
43
+ if (!hookInput) return
40
44
 
41
- const sessionId = hookInput.session_id
45
+ const sessionId = hookInput.sessionId || hookInput.session_id
42
46
  if (!sessionId) return
43
47
 
44
48
  const dir = breadcrumbDir(root)
49
+ if (!dir) return
45
50
  ensureDir(dir)
46
51
  const data = { session_id: sessionId, timestamp: Date.now() }
47
52
  fs.writeFileSync(path.join(dir, sessionId + '.json'), JSON.stringify(data) + '\n')
@@ -53,21 +58,17 @@ function handleSessionEnd (input, root) {
53
58
  function handleSessionStart (input, root) {
54
59
  if (!root) return
55
60
 
56
- let hookInput
57
- try {
58
- hookInput = JSON.parse(input)
59
- } catch {
60
- return
61
- }
61
+ const hookInput = typeof input === 'string' ? parseInput(input) : input
62
+ if (!hookInput) return
62
63
 
63
- const sessionId = hookInput.session_id
64
+ const sessionId = hookInput.sessionId || hookInput.session_id
64
65
  if (!sessionId) return
65
66
 
66
67
  const source = hookInput.source
67
68
  if (source !== 'clear' && source !== 'resume') return
68
69
 
69
70
  const dir = breadcrumbDir(root)
70
- if (!fs.existsSync(dir)) return
71
+ if (!dir || !fs.existsSync(dir)) return
71
72
 
72
73
  // Scan breadcrumbs for closest match
73
74
  const now = Date.now()
@@ -108,11 +109,20 @@ function handleSessionStart (input, root) {
108
109
 
109
110
  // Write chain for this session
110
111
  const cDir = chainDir(root)
112
+ if (!cDir) return
111
113
  ensureDir(cDir)
112
114
  const chain = { parent: best.session_id, ancestors }
113
115
  fs.writeFileSync(path.join(cDir, sessionId + '.json'), JSON.stringify(chain) + '\n')
114
116
  }
115
117
 
118
+ function parseInput (input) {
119
+ try {
120
+ return JSON.parse(input)
121
+ } catch {
122
+ return null
123
+ }
124
+ }
125
+
116
126
  function readChain (root, sessionId) {
117
127
  try {
118
128
  return JSON.parse(fs.readFileSync(path.join(chainDir(root), sessionId + '.json'), 'utf8'))
@@ -133,21 +143,27 @@ function getAncestors (root, sessionId) {
133
143
  * Save formatted transcript to pending directory for later pickup.
134
144
  */
135
145
  let pendingSeq = 0
136
- function savePending (root, sessionId, transcript) {
137
- const dir = path.join(pendingDir(root), sessionId)
146
+ function savePending (root, sessionId, transcript, opts = {}) {
147
+ const base = pendingDir(root)
148
+ if (!base) return
149
+ const dir = path.join(base, sessionId)
138
150
  ensureDir(dir)
139
151
  const timestamp = String(Date.now()) + '-' + String(pendingSeq++).padStart(4, '0')
140
- fs.writeFileSync(path.join(dir, timestamp + '.txt'), transcript)
152
+ const source = opts.source ? '.' + String(opts.source).replace(/[^a-z0-9-]/gi, '-') : ''
153
+ fs.writeFileSync(path.join(dir, timestamp + source + '.txt'), transcript)
141
154
  }
142
155
 
143
156
  /**
144
157
  * Collect pending transcripts for a list of session IDs, in order
145
158
  * (oldest ancestor first). Returns array of strings.
146
159
  */
147
- function collectPending (root, sessionIds) {
160
+ function collectPending (root, sessionIds, opts = {}) {
148
161
  const results = []
162
+ const base = pendingDir(root)
163
+ if (!base) return results
164
+ const sourceSuffix = opts.source ? '.' + opts.source + '.txt' : null
149
165
  for (const sid of sessionIds) {
150
- const dir = path.join(pendingDir(root), sid)
166
+ const dir = path.join(base, sid)
151
167
  let files
152
168
  try {
153
169
  files = fs.readdirSync(dir).sort()
@@ -156,6 +172,7 @@ function collectPending (root, sessionIds) {
156
172
  }
157
173
  for (const file of files) {
158
174
  if (!file.endsWith('.txt')) continue
175
+ if (sourceSuffix && !file.endsWith(sourceSuffix)) continue
159
176
  try {
160
177
  const content = fs.readFileSync(path.join(dir, file), 'utf8')
161
178
  if (content.trim()) results.push(content)
@@ -181,10 +198,14 @@ function readWatermark (root, sessionId) {
181
198
  /**
182
199
  * Save watermark after a commit: pair count and commit SHA.
183
200
  */
184
- function saveWatermark (root, sessionId, pairs, commit) {
201
+ function saveWatermark (root, sessionId, pairs, commit, meta = {}) {
185
202
  const dir = watermarkDir(root)
203
+ if (!dir) return
186
204
  ensureDir(dir)
187
- fs.writeFileSync(path.join(dir, sessionId + '.json'), JSON.stringify({ pairs, commit }) + '\n')
205
+ const data = { pairs }
206
+ if (commit) data.commit = commit
207
+ Object.assign(data, meta)
208
+ fs.writeFileSync(path.join(dir, sessionId + '.json'), JSON.stringify(data) + '\n')
188
209
  }
189
210
 
190
211
  /**
@@ -194,13 +215,13 @@ function saveWatermark (root, sessionId, pairs, commit) {
194
215
  function resolveParentCommit (root, sessionId) {
195
216
  // Own watermark takes priority (same-session previous commit)
196
217
  const own = readWatermark(root, sessionId)
197
- if (own) return own.commit
218
+ if (own && own.commit) return own.commit
198
219
 
199
220
  // Walk chain ancestors nearest-first
200
221
  const ancestors = getAncestors(root, sessionId)
201
222
  for (const aid of ancestors) {
202
223
  const wm = readWatermark(root, aid)
203
- if (wm) return wm.commit
224
+ if (wm && wm.commit) return wm.commit
204
225
  }
205
226
  return null
206
227
  }
@@ -209,9 +230,11 @@ function resolveParentCommit (root, sessionId) {
209
230
  * Delete consumed pending + chain files after commit.
210
231
  */
211
232
  function cleanupConsumed (root, sessionIds) {
233
+ const base = pendingDir(root)
234
+ if (!base) return
212
235
  for (const sid of sessionIds) {
213
236
  // Remove pending dir
214
- const dir = path.join(pendingDir(root), sid)
237
+ const dir = path.join(base, sid)
215
238
  try {
216
239
  const files = fs.readdirSync(dir)
217
240
  for (const file of files) {
@@ -232,6 +255,7 @@ function cleanupStale (root, maxAgeMs) {
232
255
  const ttl = maxAgeMs != null ? maxAgeMs : STALE_TTL_MS
233
256
  const now = Date.now()
234
257
  const base = turbocommitDir(root)
258
+ if (!base) return
235
259
 
236
260
  for (const sub of ['breadcrumbs', 'chains', 'tracking', 'watermarks']) {
237
261
  const dir = path.join(base, sub)
package/lib/track.js CHANGED
@@ -1,16 +1,21 @@
1
1
  const fs = require('fs')
2
2
  const path = require('path')
3
3
  const { ensureDir } = require('./io')
4
+ const { turbocommitDir } = require('./session')
4
5
 
5
6
  /**
6
- * Directory under .git where turbocommit stores tracking state.
7
+ * Directory under the git common dir where turbocommit stores tracking state.
8
+ * Returns null when the root isn't resolvable to a git repo (including when
9
+ * run outside a repo, which is not expected during normal operation).
7
10
  */
8
11
  function trackingDir (root) {
9
- return path.join(root, '.git', 'turbocommit', 'tracking')
12
+ const base = turbocommitDir(root)
13
+ return base && path.join(base, 'tracking')
10
14
  }
11
15
 
12
16
  function trackingPath (root, sessionId) {
13
- return path.join(trackingDir(root), sessionId + '.jsonl')
17
+ const dir = trackingDir(root)
18
+ return dir && path.join(dir, sessionId + '.jsonl')
14
19
  }
15
20
 
16
21
  /**
@@ -36,22 +41,17 @@ function extractFilePath (toolInput) {
36
41
  * Always exits 0 (never blocks tool execution).
37
42
  */
38
43
  function handleTrack (input, root) {
39
- if (!root) return
44
+ const hookInput = typeof input === 'string' ? parseInput(input) : input
45
+ root = root || hookInput?.root
46
+ if (!root || !hookInput) return
40
47
 
41
- let hookInput
42
- try {
43
- hookInput = JSON.parse(input)
44
- } catch {
45
- return
46
- }
47
-
48
- const sessionId = hookInput.session_id
48
+ const sessionId = hookInput.sessionId || hookInput.session_id
49
49
  if (!sessionId) return
50
50
 
51
- const toolName = hookInput.tool_name
51
+ const toolName = hookInput.toolName || hookInput.tool_name
52
52
  if (!toolName) return
53
53
 
54
- const toolInput = hookInput.tool_input || {}
54
+ const toolInput = hookInput.toolInput || hookInput.tool_input || {}
55
55
 
56
56
  const entry = { tool: toolName, t: Date.now() }
57
57
 
@@ -72,10 +72,19 @@ function handleTrack (input, root) {
72
72
  if (toolName === 'Bash' && typeof toolInput.command !== 'string') return
73
73
 
74
74
  const file = trackingPath(root, sessionId)
75
+ if (!file) return
75
76
  ensureDir(path.dirname(file))
76
77
  fs.appendFileSync(file, JSON.stringify(entry) + '\n')
77
78
  }
78
79
 
80
+ function parseInput (input) {
81
+ try {
82
+ return JSON.parse(input)
83
+ } catch {
84
+ return null
85
+ }
86
+ }
87
+
79
88
  /**
80
89
  * Check whether a session has tracked any file-modifying tool calls.
81
90
  * Bash entries alone don't count — Bash is too noisy (ls, git status, etc.)
package/lib/transcript.js CHANGED
@@ -42,6 +42,58 @@ function parseTranscript (filePath) {
42
42
  return state.pairs
43
43
  }
44
44
 
45
+ function parseCodexTranscript (filePath) {
46
+ if (!filePath || !fs.existsSync(filePath)) return []
47
+
48
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n')
49
+ const state = { pairs: [], prompt: null, response: '' }
50
+
51
+ for (const line of lines) {
52
+ if (!line.trim()) continue
53
+ let entry
54
+ try {
55
+ entry = JSON.parse(line)
56
+ } catch {
57
+ continue
58
+ }
59
+
60
+ const payload = entry.payload
61
+ if (entry.type !== 'response_item' || payload?.type !== 'message') continue
62
+
63
+ if (payload.role === 'user') {
64
+ const prompt = visibleText(payload.content)
65
+ if (isCodexSystemStatus(prompt)) continue
66
+ if (state.prompt !== null) {
67
+ state.pairs.push({ prompt: state.prompt, response: state.response })
68
+ }
69
+ state.prompt = prompt
70
+ state.response = ''
71
+ } else if (payload.role === 'assistant' && state.prompt !== null) {
72
+ state.response += visibleText(payload.content)
73
+ }
74
+ }
75
+
76
+ if (state.prompt !== null) {
77
+ state.pairs.push({ prompt: state.prompt, response: state.response })
78
+ }
79
+
80
+ return state.pairs.filter(p => p.prompt)
81
+ }
82
+
83
+ function visibleText (content) {
84
+ if (typeof content === 'string') return content
85
+ if (!Array.isArray(content)) return ''
86
+ return content
87
+ .filter(block => block.type === 'input_text' || block.type === 'output_text' || block.type === 'text')
88
+ .map(block => block.text)
89
+ .filter(Boolean)
90
+ .join('')
91
+ }
92
+
93
+ function isCodexSystemStatus (text) {
94
+ return text.startsWith('== System Status ==\n') && text.includes('[automatic message added by system]')
95
+ }
96
+
45
97
  /**
46
98
  * Format a single prompt/response pair.
47
99
  */
@@ -130,4 +182,35 @@ function extractModel (filePath) {
130
182
  return null
131
183
  }
132
184
 
133
- module.exports = { parseTranscript, formatPair, formatBody, formatTitleTranscript, extractHeadline, fallbackHeadline, extractModel }
185
+ function extractCodexModel (filePath, hookModel) {
186
+ if (hookModel) return hookModel
187
+ if (!filePath || !fs.existsSync(filePath)) return null
188
+
189
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n')
190
+ for (const line of lines) {
191
+ if (!line.trim()) continue
192
+ let entry
193
+ try {
194
+ entry = JSON.parse(line)
195
+ } catch {
196
+ continue
197
+ }
198
+ if (entry.type === 'session_meta' && entry.payload?.model) return entry.payload.model
199
+ if (entry.type === 'session_meta' && entry.payload?.model_slug) return entry.payload.model_slug
200
+ }
201
+ return null
202
+ }
203
+
204
+ module.exports = {
205
+ parseTranscript,
206
+ parseCodexTranscript,
207
+ formatPair,
208
+ formatBody,
209
+ formatTitleTranscript,
210
+ extractHeadline,
211
+ fallbackHeadline,
212
+ extractModel,
213
+ extractCodexModel,
214
+ visibleText,
215
+ isCodexSystemStatus
216
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@searls/turbocommit",
3
- "version": "0.12.0",
4
- "description": "Auto-commit after every Claude Code turn",
3
+ "version": "0.13.0",
4
+ "description": "Auto-commit after every AI coding agent turn",
5
5
  "bin": {
6
6
  "turbocommit": "./cli.js"
7
7
  },
@@ -19,6 +19,9 @@
19
19
  "keywords": [
20
20
  "claude",
21
21
  "claude-code",
22
+ "codex",
23
+ "openai-codex",
24
+ "ai-agent",
22
25
  "git",
23
26
  "auto-commit"
24
27
  ],