@searls/turbocommit 0.10.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/LICENSE +21 -0
- package/README.md +179 -0
- package/cli.js +193 -0
- package/lib/agent.js +77 -0
- package/lib/doctor.js +101 -0
- package/lib/git.js +70 -0
- package/lib/init.js +41 -0
- package/lib/install.js +115 -0
- package/lib/io.js +58 -0
- package/lib/log.js +18 -0
- package/lib/monitor.js +92 -0
- package/lib/run.js +208 -0
- package/lib/session.js +298 -0
- package/lib/track.js +120 -0
- package/lib/transcript.js +133 -0
- package/lib/wrap.js +129 -0
- package/package.json +33 -0
package/lib/session.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { ensureDir } = require('./io')
|
|
4
|
+
|
|
5
|
+
const BREADCRUMB_THRESHOLD_MS = 2000
|
|
6
|
+
const STALE_TTL_MS = 24 * 60 * 60 * 1000
|
|
7
|
+
|
|
8
|
+
function turbocommitDir (root) {
|
|
9
|
+
return path.join(root, '.git', 'turbocommit')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function breadcrumbDir (root) {
|
|
13
|
+
return path.join(turbocommitDir(root), 'breadcrumbs')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function chainDir (root) {
|
|
17
|
+
return path.join(turbocommitDir(root), 'chains')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function pendingDir (root) {
|
|
21
|
+
return path.join(turbocommitDir(root), 'pending')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function watermarkDir (root) {
|
|
25
|
+
return path.join(turbocommitDir(root), 'watermarks')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* SessionEnd handler. Writes a breadcrumb for the ending session.
|
|
30
|
+
*/
|
|
31
|
+
function handleSessionEnd (input, root) {
|
|
32
|
+
if (!root) return
|
|
33
|
+
|
|
34
|
+
let hookInput
|
|
35
|
+
try {
|
|
36
|
+
hookInput = JSON.parse(input)
|
|
37
|
+
} catch {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sessionId = hookInput.session_id
|
|
42
|
+
if (!sessionId) return
|
|
43
|
+
|
|
44
|
+
const dir = breadcrumbDir(root)
|
|
45
|
+
ensureDir(dir)
|
|
46
|
+
const data = { session_id: sessionId, timestamp: Date.now() }
|
|
47
|
+
fs.writeFileSync(path.join(dir, sessionId + '.json'), JSON.stringify(data) + '\n')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* SessionStart handler. Matches breadcrumbs for /clear and resume continuations.
|
|
52
|
+
*/
|
|
53
|
+
function handleSessionStart (input, root) {
|
|
54
|
+
if (!root) return
|
|
55
|
+
|
|
56
|
+
let hookInput
|
|
57
|
+
try {
|
|
58
|
+
hookInput = JSON.parse(input)
|
|
59
|
+
} catch {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sessionId = hookInput.session_id
|
|
64
|
+
if (!sessionId) return
|
|
65
|
+
|
|
66
|
+
const source = hookInput.source
|
|
67
|
+
if (source !== 'clear' && source !== 'resume') return
|
|
68
|
+
|
|
69
|
+
const dir = breadcrumbDir(root)
|
|
70
|
+
if (!fs.existsSync(dir)) return
|
|
71
|
+
|
|
72
|
+
// Scan breadcrumbs for closest match
|
|
73
|
+
const now = Date.now()
|
|
74
|
+
let best = null
|
|
75
|
+
let bestGap = Infinity
|
|
76
|
+
|
|
77
|
+
let files
|
|
78
|
+
try {
|
|
79
|
+
files = fs.readdirSync(dir)
|
|
80
|
+
} catch {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
if (!file.endsWith('.json')) continue
|
|
86
|
+
try {
|
|
87
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
|
|
88
|
+
const gap = Math.abs(now - data.timestamp)
|
|
89
|
+
if (gap < bestGap) {
|
|
90
|
+
bestGap = gap
|
|
91
|
+
best = data
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!best || bestGap > BREADCRUMB_THRESHOLD_MS) return
|
|
99
|
+
|
|
100
|
+
// Claim the breadcrumb (delete it so no other session grabs it)
|
|
101
|
+
try {
|
|
102
|
+
fs.unlinkSync(path.join(dir, best.session_id + '.json'))
|
|
103
|
+
} catch {}
|
|
104
|
+
|
|
105
|
+
// Read predecessor's chain to get full ancestry
|
|
106
|
+
const predecessorChain = readChain(root, best.session_id)
|
|
107
|
+
const ancestors = [best.session_id, ...(predecessorChain ? predecessorChain.ancestors : [])]
|
|
108
|
+
|
|
109
|
+
// Write chain for this session
|
|
110
|
+
const cDir = chainDir(root)
|
|
111
|
+
ensureDir(cDir)
|
|
112
|
+
const chain = { parent: best.session_id, ancestors }
|
|
113
|
+
fs.writeFileSync(path.join(cDir, sessionId + '.json'), JSON.stringify(chain) + '\n')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readChain (root, sessionId) {
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(fs.readFileSync(path.join(chainDir(root), sessionId + '.json'), 'utf8'))
|
|
119
|
+
} catch {
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get ordered ancestor list for a session (nearest first).
|
|
126
|
+
*/
|
|
127
|
+
function getAncestors (root, sessionId) {
|
|
128
|
+
const chain = readChain(root, sessionId)
|
|
129
|
+
return chain ? chain.ancestors : []
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Save formatted transcript to pending directory for later pickup.
|
|
134
|
+
*/
|
|
135
|
+
let pendingSeq = 0
|
|
136
|
+
function savePending (root, sessionId, transcript) {
|
|
137
|
+
const dir = path.join(pendingDir(root), sessionId)
|
|
138
|
+
ensureDir(dir)
|
|
139
|
+
const timestamp = String(Date.now()) + '-' + String(pendingSeq++).padStart(4, '0')
|
|
140
|
+
fs.writeFileSync(path.join(dir, timestamp + '.txt'), transcript)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Collect pending transcripts for a list of session IDs, in order
|
|
145
|
+
* (oldest ancestor first). Returns array of strings.
|
|
146
|
+
*/
|
|
147
|
+
function collectPending (root, sessionIds) {
|
|
148
|
+
const results = []
|
|
149
|
+
for (const sid of sessionIds) {
|
|
150
|
+
const dir = path.join(pendingDir(root), sid)
|
|
151
|
+
let files
|
|
152
|
+
try {
|
|
153
|
+
files = fs.readdirSync(dir).sort()
|
|
154
|
+
} catch {
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
for (const file of files) {
|
|
158
|
+
if (!file.endsWith('.txt')) continue
|
|
159
|
+
try {
|
|
160
|
+
const content = fs.readFileSync(path.join(dir, file), 'utf8')
|
|
161
|
+
if (content.trim()) results.push(content)
|
|
162
|
+
} catch {
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return results
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Read watermark for a session. Returns { pairs, commit } or null.
|
|
172
|
+
*/
|
|
173
|
+
function readWatermark (root, sessionId) {
|
|
174
|
+
try {
|
|
175
|
+
return JSON.parse(fs.readFileSync(path.join(watermarkDir(root), sessionId + '.json'), 'utf8'))
|
|
176
|
+
} catch {
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Save watermark after a commit: pair count and commit SHA.
|
|
183
|
+
*/
|
|
184
|
+
function saveWatermark (root, sessionId, pairs, commit) {
|
|
185
|
+
const dir = watermarkDir(root)
|
|
186
|
+
ensureDir(dir)
|
|
187
|
+
fs.writeFileSync(path.join(dir, sessionId + '.json'), JSON.stringify({ pairs, commit }) + '\n')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolve parent commit SHA for continuation references.
|
|
192
|
+
* Checks own watermark first, then walks chain ancestors nearest-first.
|
|
193
|
+
*/
|
|
194
|
+
function resolveParentCommit (root, sessionId) {
|
|
195
|
+
// Own watermark takes priority (same-session previous commit)
|
|
196
|
+
const own = readWatermark(root, sessionId)
|
|
197
|
+
if (own) return own.commit
|
|
198
|
+
|
|
199
|
+
// Walk chain ancestors nearest-first
|
|
200
|
+
const ancestors = getAncestors(root, sessionId)
|
|
201
|
+
for (const aid of ancestors) {
|
|
202
|
+
const wm = readWatermark(root, aid)
|
|
203
|
+
if (wm) return wm.commit
|
|
204
|
+
}
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Delete consumed pending + chain files after commit.
|
|
210
|
+
*/
|
|
211
|
+
function cleanupConsumed (root, sessionIds) {
|
|
212
|
+
for (const sid of sessionIds) {
|
|
213
|
+
// Remove pending dir
|
|
214
|
+
const dir = path.join(pendingDir(root), sid)
|
|
215
|
+
try {
|
|
216
|
+
const files = fs.readdirSync(dir)
|
|
217
|
+
for (const file of files) {
|
|
218
|
+
fs.unlinkSync(path.join(dir, file))
|
|
219
|
+
}
|
|
220
|
+
fs.rmdirSync(dir)
|
|
221
|
+
} catch {}
|
|
222
|
+
|
|
223
|
+
// Chain files are preserved — resolveParentCommit needs them
|
|
224
|
+
// to walk cross-session lineage. Stale cleanup handles them after 24h.
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Remove stale orphaned files older than maxAgeMs (default 24h).
|
|
230
|
+
*/
|
|
231
|
+
function cleanupStale (root, maxAgeMs) {
|
|
232
|
+
const ttl = maxAgeMs != null ? maxAgeMs : STALE_TTL_MS
|
|
233
|
+
const now = Date.now()
|
|
234
|
+
const base = turbocommitDir(root)
|
|
235
|
+
|
|
236
|
+
for (const sub of ['breadcrumbs', 'chains', 'tracking', 'watermarks']) {
|
|
237
|
+
const dir = path.join(base, sub)
|
|
238
|
+
let files
|
|
239
|
+
try {
|
|
240
|
+
files = fs.readdirSync(dir)
|
|
241
|
+
} catch {
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
for (const file of files) {
|
|
245
|
+
const fp = path.join(dir, file)
|
|
246
|
+
try {
|
|
247
|
+
const stat = fs.statSync(fp)
|
|
248
|
+
if (now - stat.mtimeMs > ttl) {
|
|
249
|
+
fs.unlinkSync(fp)
|
|
250
|
+
}
|
|
251
|
+
} catch {}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Clean stale pending directories
|
|
256
|
+
const pDir = path.join(base, 'pending')
|
|
257
|
+
let pdirs
|
|
258
|
+
try {
|
|
259
|
+
pdirs = fs.readdirSync(pDir)
|
|
260
|
+
} catch {
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
for (const sid of pdirs) {
|
|
264
|
+
const dir = path.join(pDir, sid)
|
|
265
|
+
try {
|
|
266
|
+
const stat = fs.statSync(dir)
|
|
267
|
+
if (!stat.isDirectory()) continue
|
|
268
|
+
if (now - stat.mtimeMs > ttl) {
|
|
269
|
+
const files = fs.readdirSync(dir)
|
|
270
|
+
for (const file of files) {
|
|
271
|
+
fs.unlinkSync(path.join(dir, file))
|
|
272
|
+
}
|
|
273
|
+
fs.rmdirSync(dir)
|
|
274
|
+
}
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = {
|
|
280
|
+
handleSessionEnd,
|
|
281
|
+
handleSessionStart,
|
|
282
|
+
getAncestors,
|
|
283
|
+
savePending,
|
|
284
|
+
collectPending,
|
|
285
|
+
cleanupConsumed,
|
|
286
|
+
cleanupStale,
|
|
287
|
+
readChain,
|
|
288
|
+
readWatermark,
|
|
289
|
+
saveWatermark,
|
|
290
|
+
resolveParentCommit,
|
|
291
|
+
breadcrumbDir,
|
|
292
|
+
chainDir,
|
|
293
|
+
pendingDir,
|
|
294
|
+
watermarkDir,
|
|
295
|
+
turbocommitDir,
|
|
296
|
+
BREADCRUMB_THRESHOLD_MS,
|
|
297
|
+
STALE_TTL_MS
|
|
298
|
+
}
|
package/lib/track.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { ensureDir } = require('./io')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Directory under .git where turbocommit stores tracking state.
|
|
7
|
+
*/
|
|
8
|
+
function trackingDir (root) {
|
|
9
|
+
return path.join(root, '.git', 'turbocommit', 'tracking')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function trackingPath (root, sessionId) {
|
|
13
|
+
return path.join(trackingDir(root), sessionId + '.jsonl')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Keys to probe in tool_input for a file path (MCP tools, Write, Edit, etc.)
|
|
18
|
+
*/
|
|
19
|
+
const FILE_PATH_KEYS = ['file_path', 'filePath', 'path', 'file', 'notebook_path']
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extract a file path from tool_input, heuristically checking known keys.
|
|
23
|
+
*/
|
|
24
|
+
function extractFilePath (toolInput) {
|
|
25
|
+
if (!toolInput || typeof toolInput !== 'object') return null
|
|
26
|
+
for (const key of FILE_PATH_KEYS) {
|
|
27
|
+
if (typeof toolInput[key] === 'string' && toolInput[key].length > 0) {
|
|
28
|
+
return toolInput[key]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* PreToolUse handler. Appends a tracking entry for potentially-modifying tools.
|
|
36
|
+
* Always exits 0 (never blocks tool execution).
|
|
37
|
+
*/
|
|
38
|
+
function handleTrack (input, root) {
|
|
39
|
+
if (!root) return
|
|
40
|
+
|
|
41
|
+
let hookInput
|
|
42
|
+
try {
|
|
43
|
+
hookInput = JSON.parse(input)
|
|
44
|
+
} catch {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sessionId = hookInput.session_id
|
|
49
|
+
if (!sessionId) return
|
|
50
|
+
|
|
51
|
+
const toolName = hookInput.tool_name
|
|
52
|
+
if (!toolName) return
|
|
53
|
+
|
|
54
|
+
const toolInput = hookInput.tool_input || {}
|
|
55
|
+
|
|
56
|
+
const entry = { tool: toolName, t: Date.now() }
|
|
57
|
+
|
|
58
|
+
// Extract file path for file-modifying tools
|
|
59
|
+
const filePath = extractFilePath(toolInput)
|
|
60
|
+
if (filePath) {
|
|
61
|
+
entry.file = filePath
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// For Bash, record the command
|
|
65
|
+
if (toolName === 'Bash' && typeof toolInput.command === 'string') {
|
|
66
|
+
entry.command = toolInput.command
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Skip Bash with no command (malformed input). All other non-Bash tools
|
|
70
|
+
// passed the PreToolUse matcher, so they're known modifying tools even if
|
|
71
|
+
// we can't extract a specific file path (e.g. MultiEdit nests paths in edits[]).
|
|
72
|
+
if (toolName === 'Bash' && typeof toolInput.command !== 'string') return
|
|
73
|
+
|
|
74
|
+
const file = trackingPath(root, sessionId)
|
|
75
|
+
ensureDir(path.dirname(file))
|
|
76
|
+
fs.appendFileSync(file, JSON.stringify(entry) + '\n')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check whether a session has tracked any file-modifying tool calls.
|
|
81
|
+
* Bash entries alone don't count — Bash is too noisy (ls, git status, etc.)
|
|
82
|
+
* and we can't reliably distinguish read-only from write commands.
|
|
83
|
+
* The definitive signal comes from Write/Edit/NotebookEdit/MCP tools.
|
|
84
|
+
*/
|
|
85
|
+
function hasTrackedModifications (root, sessionId) {
|
|
86
|
+
const file = trackingPath(root, sessionId)
|
|
87
|
+
try {
|
|
88
|
+
const data = fs.readFileSync(file, 'utf8')
|
|
89
|
+
if (!data) return false
|
|
90
|
+
const lines = data.trim().split('\n')
|
|
91
|
+
return lines.some(line => {
|
|
92
|
+
try {
|
|
93
|
+
const entry = JSON.parse(line)
|
|
94
|
+
return entry.tool !== 'Bash'
|
|
95
|
+
} catch {
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
} catch {
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Delete tracking file after commit or cleanup.
|
|
106
|
+
*/
|
|
107
|
+
function cleanupTracking (root, sessionId) {
|
|
108
|
+
try {
|
|
109
|
+
fs.unlinkSync(trackingPath(root, sessionId))
|
|
110
|
+
} catch {}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
handleTrack,
|
|
115
|
+
hasTrackedModifications,
|
|
116
|
+
cleanupTracking,
|
|
117
|
+
extractFilePath,
|
|
118
|
+
trackingDir,
|
|
119
|
+
trackingPath
|
|
120
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse a JSONL transcript file into prompt/response pairs.
|
|
5
|
+
* Returns array of { prompt, response } objects.
|
|
6
|
+
*/
|
|
7
|
+
function parseTranscript (filePath) {
|
|
8
|
+
if (!filePath || !fs.existsSync(filePath)) return []
|
|
9
|
+
|
|
10
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n')
|
|
11
|
+
const state = { pairs: [], prompt: null, response: '' }
|
|
12
|
+
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
if (!line.trim()) continue
|
|
15
|
+
let entry
|
|
16
|
+
try {
|
|
17
|
+
entry = JSON.parse(line)
|
|
18
|
+
} catch {
|
|
19
|
+
continue
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (entry.type === 'user' && typeof entry.message?.content === 'string') {
|
|
23
|
+
if (state.prompt !== null) {
|
|
24
|
+
state.pairs.push({ prompt: state.prompt, response: state.response })
|
|
25
|
+
}
|
|
26
|
+
state.prompt = entry.message.content
|
|
27
|
+
state.response = ''
|
|
28
|
+
} else if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
|
|
29
|
+
const hasToolUse = entry.message.content.some(b => b.type === 'tool_use')
|
|
30
|
+
if (hasToolUse) continue
|
|
31
|
+
const texts = entry.message.content
|
|
32
|
+
.filter(b => b.type === 'text')
|
|
33
|
+
.map(b => b.text)
|
|
34
|
+
state.response += texts.join('')
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (state.prompt !== null) {
|
|
39
|
+
state.pairs.push({ prompt: state.prompt, response: state.response })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return state.pairs
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Format a single prompt/response pair.
|
|
47
|
+
*/
|
|
48
|
+
function formatPair (p) {
|
|
49
|
+
return `Prompt:\n${p.prompt}\n\nResponse:\n${p.response}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Format prompt/response pairs into a commit body.
|
|
54
|
+
*/
|
|
55
|
+
function formatBody (pairs) {
|
|
56
|
+
if (pairs.length === 0) return '(no transcript)'
|
|
57
|
+
return pairs.map(formatPair).join('\n\n---\n\n')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Format a condensed transcript for title generation.
|
|
62
|
+
* Returns the full transcript when it fits within budget, otherwise
|
|
63
|
+
* samples the first, middle, and last pairs with gap markers.
|
|
64
|
+
* Individual prompts and responses are capped to keep the sample compact.
|
|
65
|
+
*/
|
|
66
|
+
function formatTitleTranscript (pairs, budget) {
|
|
67
|
+
budget = budget || 20000
|
|
68
|
+
if (pairs.length === 0) return '(no transcript)'
|
|
69
|
+
|
|
70
|
+
const full = formatBody(pairs)
|
|
71
|
+
if (full.length <= budget) return full
|
|
72
|
+
|
|
73
|
+
const sep = '\n\n---\n\n'
|
|
74
|
+
const cap = (text, max) => text.length > max ? text.slice(0, max) + '...' : text
|
|
75
|
+
const fmt = p => `Prompt:\n${cap(p.prompt, 500)}\n\nResponse:\n${cap(p.response, 2000)}`
|
|
76
|
+
|
|
77
|
+
// Pick first, middle, and last — deduplicated and in order
|
|
78
|
+
const indices = [...new Set([0, Math.floor(pairs.length / 2), pairs.length - 1])]
|
|
79
|
+
|
|
80
|
+
const parts = []
|
|
81
|
+
let prev = -1
|
|
82
|
+
for (const i of indices) {
|
|
83
|
+
const skipped = i - prev - 1
|
|
84
|
+
if (skipped > 0) parts.push(`[... ${skipped} turns omitted ...]`)
|
|
85
|
+
parts.push(fmt(pairs[i]))
|
|
86
|
+
prev = i
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return parts.join(sep)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extract a headline from the last user prompt.
|
|
94
|
+
* Falls back to a timestamped default.
|
|
95
|
+
*/
|
|
96
|
+
function extractHeadline (pairs) {
|
|
97
|
+
if (pairs.length === 0) return fallbackHeadline()
|
|
98
|
+
const lastPrompt = pairs[pairs.length - 1].prompt
|
|
99
|
+
if (!lastPrompt) return fallbackHeadline()
|
|
100
|
+
const firstLine = lastPrompt.split('\n')[0]
|
|
101
|
+
return firstLine.slice(0, 72) || fallbackHeadline()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fallbackHeadline () {
|
|
105
|
+
const now = new Date()
|
|
106
|
+
const pad = n => String(n).padStart(2, '0')
|
|
107
|
+
return `auto-commit ${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Extract the model ID from the first assistant entry in a JSONL transcript.
|
|
112
|
+
* Returns null if not found.
|
|
113
|
+
*/
|
|
114
|
+
function extractModel (filePath) {
|
|
115
|
+
if (!filePath || !fs.existsSync(filePath)) return null
|
|
116
|
+
|
|
117
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n')
|
|
118
|
+
for (const line of lines) {
|
|
119
|
+
if (!line.trim()) continue
|
|
120
|
+
let entry
|
|
121
|
+
try {
|
|
122
|
+
entry = JSON.parse(line)
|
|
123
|
+
} catch {
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (entry.type === 'assistant' && entry.message?.model) {
|
|
127
|
+
return entry.message.model
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { parseTranscript, formatPair, formatBody, formatTitleTranscript, extractHeadline, fallbackHeadline, extractModel }
|
package/lib/wrap.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrap prose lines at a specified width while preserving structured
|
|
3
|
+
* Markdown content (code blocks, tables, headers, etc.) verbatim.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function wrapLine (text, maxLen, indent) {
|
|
7
|
+
indent = indent || ''
|
|
8
|
+
const leadMatch = text.match(/^(\s+)/)
|
|
9
|
+
const lead = leadMatch ? leadMatch[1] : ''
|
|
10
|
+
const words = text.trimStart().split(' ')
|
|
11
|
+
if (words.length > 0) words[0] = lead + words[0]
|
|
12
|
+
const lines = []
|
|
13
|
+
let current = ''
|
|
14
|
+
|
|
15
|
+
for (const word of words) {
|
|
16
|
+
if (current === '') {
|
|
17
|
+
current = word
|
|
18
|
+
} else if (current.length + 1 + word.length <= maxLen) {
|
|
19
|
+
current += ' ' + word
|
|
20
|
+
} else {
|
|
21
|
+
lines.push(current)
|
|
22
|
+
current = indent + word
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (current) lines.push(current)
|
|
26
|
+
return lines.join('\n')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function wrapText (text, maxLineLength) {
|
|
30
|
+
if (!maxLineLength || typeof maxLineLength !== 'number' || maxLineLength < 1) return text
|
|
31
|
+
if (text === '') return text
|
|
32
|
+
|
|
33
|
+
const lines = text.split('\n')
|
|
34
|
+
const result = []
|
|
35
|
+
let inFence = false
|
|
36
|
+
let fencePattern = null
|
|
37
|
+
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
// Fenced code block toggle (backtick or tilde)
|
|
40
|
+
const fenceMatch = line.match(/^(`{3,}|~{3,})/)
|
|
41
|
+
if (fenceMatch) {
|
|
42
|
+
if (!inFence) {
|
|
43
|
+
inFence = true
|
|
44
|
+
fencePattern = fenceMatch[1]
|
|
45
|
+
result.push(line)
|
|
46
|
+
continue
|
|
47
|
+
} else if (line.startsWith(fencePattern) && line.trim() === fencePattern) {
|
|
48
|
+
inFence = false
|
|
49
|
+
fencePattern = null
|
|
50
|
+
result.push(line)
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Inside fenced block — preserve verbatim
|
|
56
|
+
if (inFence) {
|
|
57
|
+
result.push(line)
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Blank line
|
|
62
|
+
if (line === '') {
|
|
63
|
+
result.push(line)
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Indented code (4+ spaces or tab)
|
|
68
|
+
if (/^( {4,}|\t)/.test(line)) {
|
|
69
|
+
result.push(line)
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Header
|
|
74
|
+
if (/^#{1,6}\s/.test(line)) {
|
|
75
|
+
result.push(line)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Table row (starts with |)
|
|
80
|
+
if (/^\|/.test(line.trimStart())) {
|
|
81
|
+
result.push(line)
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Horizontal rule
|
|
86
|
+
if (/^[-*_]{3,}\s*$/.test(line)) {
|
|
87
|
+
result.push(line)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Blockquote
|
|
92
|
+
const bqMatch = line.match(/^((?:>\s?)+)/)
|
|
93
|
+
if (bqMatch) {
|
|
94
|
+
const prefix = bqMatch[0]
|
|
95
|
+
const indent = bqMatch[1].replace(/\s?$/, ' ')
|
|
96
|
+
if (line.length <= maxLineLength) {
|
|
97
|
+
result.push(line)
|
|
98
|
+
} else {
|
|
99
|
+
const content = line.slice(prefix.length)
|
|
100
|
+
result.push(wrapLine(prefix + content, maxLineLength, indent))
|
|
101
|
+
}
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// List item
|
|
106
|
+
const listMatch = line.match(/^(\s*)([-*]|\d+\.)\s/)
|
|
107
|
+
if (listMatch) {
|
|
108
|
+
const prefix = listMatch[0]
|
|
109
|
+
const indent = ' '.repeat(prefix.length)
|
|
110
|
+
if (line.length <= maxLineLength) {
|
|
111
|
+
result.push(line)
|
|
112
|
+
} else {
|
|
113
|
+
result.push(wrapLine(line, maxLineLength, indent))
|
|
114
|
+
}
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Prose — wrap at word boundaries
|
|
119
|
+
if (line.length <= maxLineLength) {
|
|
120
|
+
result.push(line)
|
|
121
|
+
} else {
|
|
122
|
+
result.push(wrapLine(line, maxLineLength, ''))
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return result.join('\n')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { wrapText }
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@searls/turbocommit",
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Auto-commit after every Claude Code turn",
|
|
5
|
+
"bin": {
|
|
6
|
+
"turbocommit": "./cli.js"
|
|
7
|
+
},
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=18.0.0"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"cli.js",
|
|
13
|
+
"lib/"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/searlsco/turbocommit.git"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"claude",
|
|
21
|
+
"claude-code",
|
|
22
|
+
"git",
|
|
23
|
+
"auto-commit"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test test/*.test.js",
|
|
27
|
+
"lint": "npx standard --fix"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"standard": "^17.1.0"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT"
|
|
33
|
+
}
|