@leftoff/cli 0.0.0-stage → 0.6.3
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/.claude-plugin/marketplace.json +14 -0
- package/CHANGELOG.md +564 -0
- package/LICENSE.md +9 -0
- package/README.md +28 -2
- package/assets/sample-transcript.jsonl +12 -0
- package/bin/checkpoints.js +7 -0
- package/bin/leftoff.js +585 -0
- package/hooks/README.md +22 -0
- package/hooks/settings-hooks.example.json +82 -0
- package/mod/.claude-plugin/plugin.json +15 -0
- package/mod/hooks/core.js +1397 -0
- package/mod/hooks/hooks.json +4 -0
- package/mod/hooks/register.js +1006 -0
- package/package.json +45 -4
- package/templates/agent-feed-worker/README.md +45 -0
- package/templates/agent-feed-worker/src/index.js +69 -0
- package/templates/agent-feed-worker/wrangler.toml +20 -0
- package/ui/app.css +1282 -0
- package/ui/app.js +2979 -0
- package/ui/apple-touch-icon.png +0 -0
- package/ui/changelog-md.js +64 -0
- package/ui/favicon-32.png +0 -0
- package/ui/favicon-badge-32.png +0 -0
- package/ui/favicon-badge.svg +1 -0
- package/ui/favicon.ico +0 -0
- package/ui/favicon.svg +1 -0
- package/ui/go.js +68 -0
- package/ui/icon-192.png +0 -0
- package/ui/icon-512.png +0 -0
- package/ui/icon-maskable-512.png +0 -0
- package/ui/icons.js +41 -0
- package/ui/index.html +55 -0
- package/ui/manifest.webmanifest +37 -0
- package/ui/needs.js +197 -0
- package/ui/offline.css +11 -0
- package/ui/offline.html +24 -0
- package/ui/pair.html +31 -0
- package/ui/picker.js +294 -0
- package/ui/relay-client.js +187 -0
- package/ui/relay-crypto.js +155 -0
- package/ui/relay-pair.js +48 -0
- package/ui/repos.js +167 -0
- package/ui/settings-nav.js +23 -0
- package/ui/summary-examples.js +66 -0
- package/ui/sw.js +34 -0
- package/ui/theme.js +41 -0
- package/ui/vendor/LUCIDE-LICENSE.txt +45 -0
|
@@ -0,0 +1,1397 @@
|
|
|
1
|
+
// Leftoff shared core (the mod, the daemon and the CLI all import it).
|
|
2
|
+
//
|
|
3
|
+
// Pure functions only: no Node.js APIs, no timers, no I/O. The Claude Code mod
|
|
4
|
+
// (hooks/register.js) and the local daemon (src/) both import this file, so the
|
|
5
|
+
// folder format, the status rules and the checkpoint prompt are defined once.
|
|
6
|
+
// A mod's hooks module may import files inside its own plugin directory by
|
|
7
|
+
// relative path, which is why this lives here and the daemon reaches in.
|
|
8
|
+
|
|
9
|
+
export const SCHEMA = 1
|
|
10
|
+
export const DEFAULT_PORT = 7429
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_CONFIG = {
|
|
13
|
+
port: DEFAULT_PORT,
|
|
14
|
+
dormantAfterMin: 15,
|
|
15
|
+
minGapMin: 30,
|
|
16
|
+
staleAfterSec: 180,
|
|
17
|
+
longRunCheckpointMin: null,
|
|
18
|
+
digest: { time: '07:00', lookbackHours: 24, topOfMind: true, days: ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'], skipEmpty: true, timeZone: null },
|
|
19
|
+
summarizer: { provider: 'auto', model: 'haiku', baseUrl: null, apiKeyEnv: null, maxInputChars: 24000 },
|
|
20
|
+
machine: null,
|
|
21
|
+
machineNames: {},
|
|
22
|
+
redact: { patterns: ['(?i)api[_-]?key\\s*[:=]\\s*\\S+', 'sk-[A-Za-z0-9_-]{20,}'] },
|
|
23
|
+
ignoreRepos: [],
|
|
24
|
+
updates: { check: true, source: null }, // daily `git ls-remote` of the repo; never installs anything
|
|
25
|
+
remoteHosts: [], // extra Host names the daemon answers (e.g. a Tailscale Serve name); see `leftoff tailscale`
|
|
26
|
+
remoteRequireToken: true, // remote browsers sign in with a one-time link instead of getting the cookie on page load
|
|
27
|
+
tabTitle: true, // the mod names the terminal tab after the session (through Claude Code's own /rename; see tabTitleFormat)
|
|
28
|
+
tabTitleFormat: '{name}', // {name} = the session's title (or its repo), {repo}, {id} = 6-char id. Since 0.5.5 the "· cp:<id>" marker is added only when Go needs it (see formatTabTitle)
|
|
29
|
+
liveCheckSec: 30, // how often the daemon checks that each session's Claude Code process is still running
|
|
30
|
+
notify: { needsYou: true, delaySec: 10 }, // macOS notification when a session waits on a permission prompt or question (v0.4.4)
|
|
31
|
+
remote: { allowAnswers: false }, // answering prompts from another device over Tailscale (off: only from this machine)
|
|
32
|
+
summary: { length: 'informative' }, // checkpoint length: short | informative | long (v0.4.6; /cp --short / --long override once)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const STATUSES = ['active', 'waiting', 'background', 'needs_input', 'dormant', 'ended', 'stale']
|
|
36
|
+
export const TRIGGERS = ['dormant', 'manual', 'session_end', 'clear', 'long_run']
|
|
37
|
+
export const EVENT_TYPES = [
|
|
38
|
+
'session.opened', 'session.started', 'activity', 'turn_started', 'turn_completed',
|
|
39
|
+
'notification', 'title_changed', 'heartbeat', 'session.ended',
|
|
40
|
+
]
|
|
41
|
+
export const NEEDS_INPUT_NOTIFICATIONS = ['permission_prompt', 'elicitation_dialog', 'agent_needs_input']
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------- small helpers
|
|
44
|
+
|
|
45
|
+
export function mergeConfig(base, over) {
|
|
46
|
+
const out = Array.isArray(base) ? base.slice() : { ...base }
|
|
47
|
+
if (!over || typeof over !== 'object') return out
|
|
48
|
+
for (const k of Object.keys(over)) {
|
|
49
|
+
const b = base ? base[k] : undefined
|
|
50
|
+
const o = over[k]
|
|
51
|
+
if (b && o && typeof b === 'object' && typeof o === 'object' && !Array.isArray(b) && !Array.isArray(o)) out[k] = mergeConfig(b, o)
|
|
52
|
+
else out[k] = o
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function id8(id) {
|
|
58
|
+
return String(id || '').replace(/[^A-Za-z0-9]/g, '').slice(0, 8).toLowerCase() || 'unknown0'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function slugify(text, max = 40) {
|
|
62
|
+
const s = String(text || '')
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
|
65
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
66
|
+
.replace(/^-+|-+$/g, '')
|
|
67
|
+
return (s.slice(0, max).replace(/-+$/g, '')) || 'untitled'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function basename(p) {
|
|
71
|
+
const parts = String(p || '').split(/[\\/]+/).filter(Boolean)
|
|
72
|
+
return parts.length ? parts[parts.length - 1] : ''
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function repoName(session) {
|
|
76
|
+
const r = session && session.repo
|
|
77
|
+
if (r && r.name) return String(r.name).split('/').pop()
|
|
78
|
+
if (r && r.root) return basename(r.root)
|
|
79
|
+
return basename(session && session.cwd) || 'no-repo'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function clip(text, n) {
|
|
83
|
+
const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim()
|
|
84
|
+
return s.length > n ? s.slice(0, Math.max(0, n - 1)).trimEnd() + '…' : s
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function toIso(t) {
|
|
88
|
+
const d = t instanceof Date ? t : new Date(typeof t === 'number' ? t : Date.parse(t))
|
|
89
|
+
if (isNaN(d.getTime())) return null
|
|
90
|
+
return d.toISOString().replace(/\.\d{3}Z$/, 'Z')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function ms(t) {
|
|
94
|
+
if (t == null || t === '') return NaN
|
|
95
|
+
if (typeof t === 'number') return t
|
|
96
|
+
return Date.parse(t)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 2026-10-09T14:56:02Z -> 20261009T145602Z
|
|
100
|
+
export function compactStamp(iso) {
|
|
101
|
+
return toIso(iso).replace(/[-:]/g, '')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2026-10-09T14:56:02Z -> 2026-10-09T145602Z (the checkpoint file name, no .md)
|
|
105
|
+
export function fileStamp(iso) {
|
|
106
|
+
const s = toIso(iso)
|
|
107
|
+
return s.slice(0, 10) + 'T' + s.slice(11, 19).replace(/:/g, '') + 'Z'
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function checkpointId(sessionId, createdAt, n = 0) {
|
|
111
|
+
return 'cp_' + compactStamp(createdAt) + '_' + id8(sessionId) + (n ? '-' + (n + 1) : '')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function checkpointFileName(createdAt, n = 0) {
|
|
115
|
+
return fileStamp(createdAt) + (n ? '-' + (n + 1) : '') + '.md'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// {start-date}_{repo}_{slug of first title}_{first 8 chars of id}
|
|
119
|
+
export function sessionFolderName(session) {
|
|
120
|
+
const date = (toIso(session.startedAt || Date.now()) || '').slice(0, 10)
|
|
121
|
+
return [date, slugify(repoName(session), 24), slugify(session.title, 40), id8(session.id)].join('_')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Multi-machine: every record carries the machine it was made on, and each
|
|
125
|
+
// machine writes only under its own subfolder (sessions/<machine>/,
|
|
126
|
+
// digests/<machine>/, .spool/<machine>/), so two machines syncing one folder
|
|
127
|
+
// never write the same file.
|
|
128
|
+
export function machineSlug(name) {
|
|
129
|
+
return slugify(String(name || '').replace(/\.local$/i, ''), 40) || 'unknown'
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// env override > config machineNames[hostname] > config machine > hostname
|
|
133
|
+
export function resolveMachine({ env, hostname, cfg = {} } = {}) {
|
|
134
|
+
if (env) return machineSlug(env)
|
|
135
|
+
const names = cfg.machineNames || {}
|
|
136
|
+
if (hostname && names[hostname]) return machineSlug(names[hostname])
|
|
137
|
+
if (cfg.machine) return machineSlug(cfg.machine)
|
|
138
|
+
return machineSlug(hostname || 'unknown')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function camelToSnake(k) { return k.replace(/[A-Z]/g, (m) => '_' + m.toLowerCase()) }
|
|
142
|
+
export function snakeToCamel(k) { return k.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase()) }
|
|
143
|
+
|
|
144
|
+
function mapKeys(obj, fn, deep) {
|
|
145
|
+
if (Array.isArray(obj)) return obj.map((v) => (deep ? mapKeys(v, fn, deep) : v))
|
|
146
|
+
if (!obj || typeof obj !== 'object') return obj
|
|
147
|
+
const out = {}
|
|
148
|
+
for (const [k, v] of Object.entries(obj)) out[fn(k)] = deep && v && typeof v === 'object' ? mapKeys(v, fn, deep) : v
|
|
149
|
+
return out
|
|
150
|
+
}
|
|
151
|
+
export const toSnake = (o) => mapKeys(o, camelToSnake, true)
|
|
152
|
+
export const toCamel = (o) => mapKeys(o, snakeToCamel, true)
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------- frontmatter
|
|
155
|
+
// A small YAML subset: `key: value` lines; values are null, booleans, numbers,
|
|
156
|
+
// plain or double/single-quoted strings, flow sequences [a, b], flow maps
|
|
157
|
+
// {k: v}, and block sequences ("- item" lines) for hand edits. Enough for what
|
|
158
|
+
// we write and for what a person typing in a markdown app is likely to change.
|
|
159
|
+
|
|
160
|
+
const RESERVED = /^(null|~|true|false|yes|no|on|off|y|n)$/i
|
|
161
|
+
const NUMBERISH = /^[-+]?(\d[\d_]*)?(\.\d+)?([eE][-+]?\d+)?$/
|
|
162
|
+
|
|
163
|
+
function needsQuotes(s) {
|
|
164
|
+
if (s === '') return true
|
|
165
|
+
if (RESERVED.test(s)) return true
|
|
166
|
+
if (NUMBERISH.test(s) && /\d/.test(s)) return true
|
|
167
|
+
if (/^[\s\-?:,\[\]{}#&*!|>'"%@`]/.test(s)) return true
|
|
168
|
+
if (/\s$/.test(s)) return true
|
|
169
|
+
if (/:(\s|$)|\s#|[,\[\]{}\n\r\t"]/.test(s)) return true
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function fmScalar(v) {
|
|
174
|
+
if (v === null || v === undefined) return 'null'
|
|
175
|
+
if (typeof v === 'boolean' || typeof v === 'number') return Number.isFinite(v) || typeof v === 'boolean' ? String(v) : 'null'
|
|
176
|
+
if (Array.isArray(v)) return '[' + v.map(fmScalar).join(', ') + ']'
|
|
177
|
+
if (typeof v === 'object') {
|
|
178
|
+
const parts = Object.entries(v).filter(([, x]) => x !== undefined).map(([k, x]) => k + ': ' + fmScalar(x))
|
|
179
|
+
return '{' + parts.join(', ') + '}'
|
|
180
|
+
}
|
|
181
|
+
const s = String(v)
|
|
182
|
+
return needsQuotes(s) ? JSON.stringify(s) : s
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function toFrontmatter(obj, order = []) {
|
|
186
|
+
const keys = [...order.filter((k) => k in obj), ...Object.keys(obj).filter((k) => !order.includes(k))]
|
|
187
|
+
const lines = ['---']
|
|
188
|
+
for (const k of keys) {
|
|
189
|
+
if (obj[k] === undefined) continue
|
|
190
|
+
lines.push(k + ': ' + fmScalar(obj[k]))
|
|
191
|
+
}
|
|
192
|
+
lines.push('---')
|
|
193
|
+
return lines.join('\n') + '\n'
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function splitFlow(inner) {
|
|
197
|
+
const out = []
|
|
198
|
+
let depth = 0, quote = null, cur = ''
|
|
199
|
+
for (let i = 0; i < inner.length; i++) {
|
|
200
|
+
const c = inner[i]
|
|
201
|
+
if (quote) {
|
|
202
|
+
cur += c
|
|
203
|
+
if (c === '\\' && quote === '"') { cur += inner[++i] || ''; continue }
|
|
204
|
+
if (c === quote) {
|
|
205
|
+
if (quote === "'" && inner[i + 1] === "'") { cur += inner[++i]; continue }
|
|
206
|
+
quote = null
|
|
207
|
+
}
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
if (c === '"' || c === "'") { quote = c; cur += c; continue }
|
|
211
|
+
if (c === '[' || c === '{') depth++
|
|
212
|
+
if (c === ']' || c === '}') depth--
|
|
213
|
+
if (c === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue }
|
|
214
|
+
cur += c
|
|
215
|
+
}
|
|
216
|
+
if (cur.trim() !== '') out.push(cur.trim())
|
|
217
|
+
return out
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function stripComment(s) {
|
|
221
|
+
// a ' #' outside quotes starts a comment
|
|
222
|
+
let quote = null
|
|
223
|
+
for (let i = 0; i < s.length; i++) {
|
|
224
|
+
const c = s[i]
|
|
225
|
+
if (quote) { if (c === quote) quote = null; continue }
|
|
226
|
+
if (c === '"' || c === "'") { quote = c; continue }
|
|
227
|
+
if (c === '#' && (i === 0 || /\s/.test(s[i - 1]))) return s.slice(0, i).trimEnd()
|
|
228
|
+
}
|
|
229
|
+
return s
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function parseScalar(raw) {
|
|
233
|
+
const s = stripComment(String(raw).trim())
|
|
234
|
+
if (s === '' || s === '~' || s === 'null' || s === 'Null' || s === 'NULL') return null
|
|
235
|
+
if (/^(true|True|TRUE)$/.test(s)) return true
|
|
236
|
+
if (/^(false|False|FALSE)$/.test(s)) return false
|
|
237
|
+
if (s[0] === '"') { try { return JSON.parse(s) } catch { return s.slice(1, -1) } }
|
|
238
|
+
if (s[0] === "'") return s.slice(1, s.endsWith("'") ? -1 : undefined).replace(/''/g, "'")
|
|
239
|
+
if (s[0] === '[') return splitFlow(s.slice(1, s.lastIndexOf(']'))).map(parseScalar)
|
|
240
|
+
if (s[0] === '{') {
|
|
241
|
+
const o = {}
|
|
242
|
+
for (const part of splitFlow(s.slice(1, s.lastIndexOf('}')))) {
|
|
243
|
+
const m = part.match(/^("(?:[^"\\]|\\.)*"|'[^']*'|[^:]+?)\s*:\s*(.*)$/)
|
|
244
|
+
if (!m) continue
|
|
245
|
+
o[String(parseScalar(m[1]))] = parseScalar(m[2])
|
|
246
|
+
}
|
|
247
|
+
return o
|
|
248
|
+
}
|
|
249
|
+
if (NUMBERISH.test(s) && /\d/.test(s)) { const n = Number(s.replace(/_/g, '')); if (!isNaN(n)) return n }
|
|
250
|
+
return s
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function parseFrontmatter(text) {
|
|
254
|
+
const src = String(text || '').replace(/^\uFEFF/, '').replace(/\r\n/g, '\n')
|
|
255
|
+
if (!src.startsWith('---\n') && src !== '---') return { data: {}, body: src }
|
|
256
|
+
const end = src.indexOf('\n---', 4)
|
|
257
|
+
if (end < 0) return { data: {}, body: src }
|
|
258
|
+
const head = src.slice(4, end)
|
|
259
|
+
let body = src.slice(end + 4)
|
|
260
|
+
body = body.replace(/^[^\n]*\n?/, '') // rest of the closing '---' line
|
|
261
|
+
const data = {}
|
|
262
|
+
const lines = head.split('\n')
|
|
263
|
+
for (let i = 0; i < lines.length; i++) {
|
|
264
|
+
const line = lines[i]
|
|
265
|
+
if (!line.trim() || /^\s*#/.test(line)) continue
|
|
266
|
+
const m = line.match(/^([A-Za-z0-9_.-]+)\s*:\s?(.*)$/)
|
|
267
|
+
if (!m) continue
|
|
268
|
+
const [, key, rest] = m
|
|
269
|
+
if (rest.trim() === '') {
|
|
270
|
+
const items = []
|
|
271
|
+
while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) { items.push(parseScalar(lines[++i].replace(/^\s+-\s+/, ''))); }
|
|
272
|
+
data[key] = items.length ? items : null
|
|
273
|
+
} else {
|
|
274
|
+
data[key] = parseScalar(rest)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return { data, body }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------- checkpoint markdown
|
|
281
|
+
|
|
282
|
+
const CP_ORDER = ['id', 'session_id', 'session_title', 'created_at', 'trigger', 'machine', 'repo', 'branch', 'cwd',
|
|
283
|
+
'files_touched', 'turns_since_last', 'user_note', 'open_question', 'parse_ok', 'length', 'generator', 'schema']
|
|
284
|
+
|
|
285
|
+
export function resumeCommand(session) {
|
|
286
|
+
const id = session.id || session.sessionId
|
|
287
|
+
const cwd = session.cwd
|
|
288
|
+
const q = (p) => (/^[A-Za-z0-9_./~@+-]+$/.test(p) ? p : "'" + String(p).replace(/'/g, "'\\''") + "'")
|
|
289
|
+
return (cwd ? 'cd ' + q(cwd) + ' && ' : '') + 'claude --resume ' + id
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function renderCheckpointMd(cp) {
|
|
293
|
+
const fm = {
|
|
294
|
+
id: cp.id,
|
|
295
|
+
session_id: cp.sessionId,
|
|
296
|
+
session_title: cp.sessionTitle || null,
|
|
297
|
+
created_at: toIso(cp.createdAt),
|
|
298
|
+
trigger: cp.trigger,
|
|
299
|
+
machine: cp.machine || undefined,
|
|
300
|
+
repo: cp.repo || null,
|
|
301
|
+
branch: cp.branch || null,
|
|
302
|
+
cwd: cp.cwd || null,
|
|
303
|
+
files_touched: cp.filesTouched || [],
|
|
304
|
+
turns_since_last: cp.turnsSinceLast == null ? null : cp.turnsSinceLast,
|
|
305
|
+
user_note: cp.userNote || undefined,
|
|
306
|
+
open_question: cp.openQuestion || undefined,
|
|
307
|
+
parse_ok: cp.parseOk === false ? false : undefined,
|
|
308
|
+
length: cp.length && SUMMARY_LENGTHS[cp.length] ? cp.length : undefined,
|
|
309
|
+
generator: cp.generator ? toSnake(cp.generator) : { by: 'unknown' },
|
|
310
|
+
schema: SCHEMA,
|
|
311
|
+
}
|
|
312
|
+
const lines = [
|
|
313
|
+
'**Doing:** ' + oneLine(cp.doing),
|
|
314
|
+
'**Stopped at:** ' + oneLine(cp.stoppedAt),
|
|
315
|
+
'**Next:** ' + oneLine(cp.next),
|
|
316
|
+
]
|
|
317
|
+
if (cp.openQuestion) lines.push('**Open question:** ' + oneLine(cp.openQuestion))
|
|
318
|
+
if (cp.userNote) lines.push('**Note:** ' + oneLine(cp.userNote))
|
|
319
|
+
// Informative / long checkpoints (v0.4.6): sections below the three lines.
|
|
320
|
+
// Older readers only pick up the **Label:** lines, so they still work.
|
|
321
|
+
const bullet = (x) => '- ' + oneLine(x).replace(/^\*+/, '')
|
|
322
|
+
for (const k of SECTION_ORDER) {
|
|
323
|
+
const v = cp[k]
|
|
324
|
+
if (k === 'recap') { if (v) lines.push('', '### ' + SECTION_TITLES[k], oneLine(v).replace(/^\*+/, '')); continue }
|
|
325
|
+
if (!Array.isArray(v) || !v.length) continue
|
|
326
|
+
lines.push('', '### ' + SECTION_TITLES[k])
|
|
327
|
+
if (k === 'files') for (const f of v) lines.push(bullet(f.path ? '`' + String(f.path).replace(/`/g, '') + '`' + (f.why ? ' — ' + oneLine(f.why) : '') : f.why))
|
|
328
|
+
else if (k === 'nextSteps') v.forEach((x, n) => lines.push((n + 1) + '. ' + oneLine(x).replace(/^\*+/, '')))
|
|
329
|
+
else for (const x of v) lines.push(bullet(x))
|
|
330
|
+
}
|
|
331
|
+
const resume = resumeCommand({ id: cp.sessionId, cwd: cp.cwd })
|
|
332
|
+
return toFrontmatter(fm, CP_ORDER) + lines.join('\n') + '\n\nResume: `' + resume + '`\n'
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function oneLine(s) { return String(s == null ? '' : s).replace(/\s*\n\s*/g, ' ').trim() }
|
|
336
|
+
|
|
337
|
+
const BODY_FIELDS = { 'doing': 'doing', 'stopped at': 'stoppedAt', 'next': 'next', 'open question': 'openQuestion', 'note': 'userNote' }
|
|
338
|
+
|
|
339
|
+
export function parseCheckpointMd(text) {
|
|
340
|
+
const { data, body } = parseFrontmatter(text)
|
|
341
|
+
const cp = {
|
|
342
|
+
id: data.id || null,
|
|
343
|
+
sessionId: data.session_id || null,
|
|
344
|
+
sessionTitle: data.session_title || null,
|
|
345
|
+
createdAt: data.created_at ? toIso(data.created_at) : null,
|
|
346
|
+
trigger: data.trigger || 'manual',
|
|
347
|
+
machine: data.machine == null ? null : String(data.machine),
|
|
348
|
+
repo: data.repo == null ? null : String(data.repo),
|
|
349
|
+
branch: data.branch == null ? null : String(data.branch),
|
|
350
|
+
cwd: data.cwd || null,
|
|
351
|
+
filesTouched: Array.isArray(data.files_touched) ? data.files_touched.map(String) : [],
|
|
352
|
+
turnsSinceLast: typeof data.turns_since_last === 'number' ? data.turns_since_last : null,
|
|
353
|
+
userNote: data.user_note || null,
|
|
354
|
+
openQuestion: data.open_question || null,
|
|
355
|
+
parseOk: data.parse_ok === false ? false : true,
|
|
356
|
+
generator: data.generator && typeof data.generator === 'object' ? toCamel(data.generator) : { by: 'unknown' },
|
|
357
|
+
length: data.length && SUMMARY_LENGTHS[data.length] ? data.length : null,
|
|
358
|
+
doing: '', stoppedAt: '', next: '',
|
|
359
|
+
}
|
|
360
|
+
const lines = body.split('\n')
|
|
361
|
+
let k = 0
|
|
362
|
+
for (; k < lines.length; k++) {
|
|
363
|
+
const line = lines[k]
|
|
364
|
+
if (/^###\s/.test(line)) break // the sections of an informative / long checkpoint
|
|
365
|
+
const m = line.match(/^\s*\*\*([A-Za-z ]+):\*\*\s*(.*)$/)
|
|
366
|
+
if (!m) continue
|
|
367
|
+
const f = BODY_FIELDS[m[1].trim().toLowerCase()]
|
|
368
|
+
if (f) cp[f] = m[2].trim()
|
|
369
|
+
}
|
|
370
|
+
const sec = []
|
|
371
|
+
for (; k < lines.length; k++) { if (/^Resume: `/.test(lines[k])) break; sec.push(lines[k]) }
|
|
372
|
+
Object.assign(cp, parseSections(sec))
|
|
373
|
+
return cp
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------- session markdown
|
|
377
|
+
|
|
378
|
+
const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo', 'transcript_path', 'model', 'surface', 'capture',
|
|
379
|
+
'status', 'started_at', 'last_activity_at', 'last_heartbeat_at', 'ended_at', 'end_reason', 'turns',
|
|
380
|
+
'last_checkpoint_id', 'last_checkpoint_at', 'parent_session_id', 'pinned', 'archived', 'ignored', 'schema']
|
|
381
|
+
|
|
382
|
+
// Fields persisted so status survives a daemon restart (beyond the spec's Session JSON).
|
|
383
|
+
export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
|
|
384
|
+
'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
|
|
385
|
+
'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
|
|
386
|
+
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason']
|
|
387
|
+
|
|
388
|
+
export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
|
|
389
|
+
'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
|
|
390
|
+
'lastCheckpointAt', 'parentSessionId', 'pinned', 'archived', 'ignored']
|
|
391
|
+
|
|
392
|
+
export function renderSessionMd(session, checkpoints = []) {
|
|
393
|
+
const fm = {}
|
|
394
|
+
for (const k of SESSION_FIELDS) fm[camelToSnake(k)] = session[k] === undefined ? null : session[k]
|
|
395
|
+
if (fm.repo && typeof fm.repo === 'object') fm.repo = toSnake(fm.repo)
|
|
396
|
+
for (const k of SESSION_EXTRA) {
|
|
397
|
+
if (k === 'folder' || k === 'statusChanges') continue
|
|
398
|
+
if (session[k] !== undefined && session[k] !== null && session[k] !== '' && session[k] !== 0 && session[k] !== false) fm[camelToSnake(k)] = session[k]
|
|
399
|
+
}
|
|
400
|
+
fm.schema = SCHEMA
|
|
401
|
+
const sorted = checkpoints.slice().sort((a, b) => ms(b.createdAt) - ms(a.createdAt))
|
|
402
|
+
const lines = ['# ' + (session.title || 'Untitled session'), '']
|
|
403
|
+
const repo = repoName(session)
|
|
404
|
+
const branch = session.repo && session.repo.branch
|
|
405
|
+
lines.push('`' + repo + (branch ? ' · ' + branch : '') + '` · ' + (session.machine ? session.machine + ' · ' : '') + (session.status || 'unknown') + ' · started ' + (toIso(session.startedAt) || '?'))
|
|
406
|
+
lines.push('')
|
|
407
|
+
lines.push('Resume: `' + resumeCommand(session) + '`')
|
|
408
|
+
lines.push('')
|
|
409
|
+
lines.push('## Checkpoints')
|
|
410
|
+
lines.push('')
|
|
411
|
+
if (!sorted.length) lines.push('_None yet._')
|
|
412
|
+
for (const cp of sorted) {
|
|
413
|
+
const when = toIso(cp.createdAt).replace('T', ' ').slice(0, 16) + 'Z'
|
|
414
|
+
lines.push('- [' + when + ' · ' + cp.trigger + '](checkpoints/' + (cp.file || checkpointFileName(cp.createdAt)) + ') — Next: ' + clip(cp.next, 160))
|
|
415
|
+
}
|
|
416
|
+
return toFrontmatter(fm, SESSION_ORDER) + lines.join('\n') + '\n'
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function parseSessionMd(text) {
|
|
420
|
+
const { data } = parseFrontmatter(text)
|
|
421
|
+
const s = {}
|
|
422
|
+
for (const [k, v] of Object.entries(data)) {
|
|
423
|
+
if (k === 'schema') continue
|
|
424
|
+
s[snakeToCamel(k)] = v
|
|
425
|
+
}
|
|
426
|
+
if (s.repo && typeof s.repo === 'object') s.repo = toCamel(s.repo)
|
|
427
|
+
if (s.repo && typeof s.repo === 'string') s.repo = { root: null, name: s.repo }
|
|
428
|
+
for (const k of ['startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'lastCheckpointAt', 'lastEventAt', 'dormantStretch', 'closedAt', 'pidStartedAt']) {
|
|
429
|
+
if (s[k]) s[k] = toIso(s[k])
|
|
430
|
+
}
|
|
431
|
+
s.turns = typeof s.turns === 'number' ? s.turns : 0
|
|
432
|
+
s.turnsSinceCheckpoint = typeof s.turnsSinceCheckpoint === 'number' ? s.turnsSinceCheckpoint : 0
|
|
433
|
+
s.backgroundTasks = typeof s.backgroundTasks === 'number' ? s.backgroundTasks : 0
|
|
434
|
+
s.pinned = !!s.pinned
|
|
435
|
+
s.archived = !!s.archived
|
|
436
|
+
return s
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------- tab titles and terminal identity (v0.4)
|
|
440
|
+
//
|
|
441
|
+
// The tab marker is "cp:<6 chars of the session id>". Before 0.5.5 the mod put
|
|
442
|
+
// it in every terminal tab title so `leftoff go` could find the tab by title;
|
|
443
|
+
// now it's added only when Go can't tell the tab apart by tty, name or folder. Claude Code repaints the title itself
|
|
444
|
+
// (✳/◐ + the session's name) about once a second while it works, so the mod
|
|
445
|
+
// sets the *session's* name with Claude Code's own /rename and lets Claude Code
|
|
446
|
+
// draw it; a raw OSC 2 write would be painted over within a second.
|
|
447
|
+
|
|
448
|
+
export function shortId(id) { return id8(id).slice(0, 6) }
|
|
449
|
+
export function tabMarker(id) { return 'cp:' + shortId(id) }
|
|
450
|
+
|
|
451
|
+
const MARKER_RE = /\s*(?:·|-|\|)?\s*cp:([a-z0-9]{6})\s*$/i
|
|
452
|
+
|
|
453
|
+
// "auth refactor · cp:7a1e0b" -> { name: "auth refactor", id: "7a1e0b" }; no marker -> { name, id: null }
|
|
454
|
+
export function parseTabTitle(title) {
|
|
455
|
+
const t = String(title == null ? '' : title)
|
|
456
|
+
const m = t.match(MARKER_RE)
|
|
457
|
+
if (!m) return { name: t.trim(), id: null }
|
|
458
|
+
return { name: t.slice(0, m.index).trim(), id: m[1].toLowerCase() }
|
|
459
|
+
}
|
|
460
|
+
export function stripTabMarker(title) { return parseTabTitle(title).name }
|
|
461
|
+
// Since 0.5.5 the mod's own tab title is usually the session's name alone (no
|
|
462
|
+
// marker), clipped to 60 characters: that coming back from Claude Code is the
|
|
463
|
+
// mod's own /rename, not a name you chose.
|
|
464
|
+
export function isOwnTabTitle(t, s) {
|
|
465
|
+
if (!t || !s) return false
|
|
466
|
+
if (parseTabTitle(t).id) return true
|
|
467
|
+
const name = s.title || null
|
|
468
|
+
return !!name && String(t).trim() === formatTabTitle('{name}', { name, id: s.id })
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// The default before 0.5.5: every tab title carried " · cp:<id>". A config
|
|
472
|
+
// that still says exactly that is treated as the new default ({name} alone).
|
|
473
|
+
export const LEGACY_TAB_TITLE_FORMAT = '{name} · cp:{id}'
|
|
474
|
+
export function effectiveTitleFormat(format) {
|
|
475
|
+
if (typeof format !== 'string' || !format.trim() || format === LEGACY_TAB_TITLE_FORMAT) return DEFAULT_CONFIG.tabTitleFormat
|
|
476
|
+
if (!/\{name\}|\{repo\}|\{id\}/.test(format)) return DEFAULT_CONFIG.tabTitleFormat
|
|
477
|
+
return format
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// "colorGenie", or "colorGenie · cp:207651" when `marker` is set: Go needs the
|
|
481
|
+
// marker only when it can't tell the tab apart otherwise (an older Ghostty
|
|
482
|
+
// without tty matching, and another open session with the same name or
|
|
483
|
+
// folder). A format with {id} in it always shows the id.
|
|
484
|
+
export function formatTabTitle(format, { name, id, repo, marker = false } = {}) {
|
|
485
|
+
const fmt = effectiveTitleFormat(format)
|
|
486
|
+
const clean = clip(stripTabMarker(name || '') || repo || 'claude', 60).replace(/[\x00-\x1f\x7f]/g, ' ')
|
|
487
|
+
let out = fmt.replace(/\{name\}/g, clean).replace(/\{repo\}/g, String(repo || '').replace(/[\x00-\x1f\x7f]/g, ' ')).replace(/\{id\}/g, shortId(id)).replace(/\s+/g, ' ').trim()
|
|
488
|
+
if (marker && id && !fmt.includes('{id}')) out += ' · ' + tabMarker(id)
|
|
489
|
+
return out
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// The environment variables that identify which terminal (and which tab or
|
|
493
|
+
// pane in it) a session runs in. Read once at session start.
|
|
494
|
+
export const TERM_ENV_KEYS = [
|
|
495
|
+
'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'TERM', 'GHOSTTY_RESOURCES_DIR', 'ITERM_SESSION_ID',
|
|
496
|
+
'WEZTERM_PANE', 'WEZTERM_UNIX_SOCKET', 'KITTY_WINDOW_ID', 'KITTY_LISTEN_ON', 'TMUX', 'TMUX_PANE', 'TERM_SESSION_ID',
|
|
497
|
+
]
|
|
498
|
+
|
|
499
|
+
const LABELS = { ghostty: 'Ghostty', iterm2: 'iTerm2', wezterm: 'WezTerm', kitty: 'kitty', 'apple-terminal': 'Terminal.app', tmux: 'tmux', vscode: 'VS Code', unknown: 'unknown terminal' }
|
|
500
|
+
|
|
501
|
+
function outerTerminal(env) {
|
|
502
|
+
const tp = String(env.TERM_PROGRAM || '')
|
|
503
|
+
const term = String(env.TERM || '')
|
|
504
|
+
const t = tp.toLowerCase()
|
|
505
|
+
if (t === 'ghostty') return 'ghostty'
|
|
506
|
+
if (t === 'iterm.app') return 'iterm2'
|
|
507
|
+
if (t === 'wezterm') return 'wezterm'
|
|
508
|
+
if (t === 'apple_terminal') return 'apple-terminal'
|
|
509
|
+
if (t === 'vscode') return 'vscode'
|
|
510
|
+
if (t === 'kitty' || env.KITTY_WINDOW_ID || term === 'xterm-kitty') return 'kitty'
|
|
511
|
+
if (env.WEZTERM_PANE) return 'wezterm'
|
|
512
|
+
if (env.ITERM_SESSION_ID) return 'iterm2'
|
|
513
|
+
if (env.GHOSTTY_RESOURCES_DIR || term === 'xterm-ghostty') return 'ghostty'
|
|
514
|
+
return 'unknown'
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// env (any subset of TERM_ENV_KEYS) -> { kind, label, program, version, ...ids, host? }
|
|
518
|
+
export function detectTerminal(env = {}) {
|
|
519
|
+
const e = env || {}
|
|
520
|
+
const out = { kind: 'unknown', label: '', program: e.TERM_PROGRAM || null, version: e.TERM_PROGRAM_VERSION || null }
|
|
521
|
+
const outer = outerTerminal(e)
|
|
522
|
+
const ids = {}
|
|
523
|
+
if (e.ITERM_SESSION_ID) { const s = String(e.ITERM_SESSION_ID); ids.itermSessionId = s.includes(':') ? s.slice(s.indexOf(':') + 1) : s }
|
|
524
|
+
if (e.WEZTERM_PANE) ids.weztermPane = String(e.WEZTERM_PANE)
|
|
525
|
+
if (e.WEZTERM_UNIX_SOCKET) ids.weztermSocket = String(e.WEZTERM_UNIX_SOCKET)
|
|
526
|
+
if (e.KITTY_WINDOW_ID) ids.kittyWindowId = String(e.KITTY_WINDOW_ID)
|
|
527
|
+
if (e.KITTY_LISTEN_ON) ids.kittyListenOn = String(e.KITTY_LISTEN_ON)
|
|
528
|
+
if (e.TERM_SESSION_ID) ids.termSessionId = String(e.TERM_SESSION_ID)
|
|
529
|
+
if (e.TMUX && e.TMUX_PANE) {
|
|
530
|
+
out.kind = 'tmux'
|
|
531
|
+
out.tmuxPane = String(e.TMUX_PANE)
|
|
532
|
+
out.tmuxSocket = String(e.TMUX).split(',')[0] || null
|
|
533
|
+
out.host = outer === 'unknown' ? null : outer
|
|
534
|
+
out.label = 'tmux' + (out.host ? ' in ' + LABELS[out.host] : '')
|
|
535
|
+
return { ...out, ...ids }
|
|
536
|
+
}
|
|
537
|
+
out.kind = outer
|
|
538
|
+
out.label = outer === 'unknown' ? (e.TERM_PROGRAM || e.TERM || 'unknown terminal') : LABELS[outer]
|
|
539
|
+
// Where Ghostty.app lives, so Go can read its dictionary (does it have tty?) from the bundle.
|
|
540
|
+
if (outer === 'ghostty' && e.GHOSTTY_RESOURCES_DIR) out.resourcesDir = String(e.GHOSTTY_RESOURCES_DIR)
|
|
541
|
+
if (outer === 'vscode' && /cursor/i.test(String(e.TERM_PROGRAM_VERSION || ''))) out.label = 'Cursor'
|
|
542
|
+
return { ...out, ...ids }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// `ps -o etime=` ("[[dd-]hh:]mm:ss") -> seconds, or null
|
|
546
|
+
export function parseEtime(s) {
|
|
547
|
+
const m = String(s || '').trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/)
|
|
548
|
+
if (!m) return null
|
|
549
|
+
return (Number(m[1] || 0) * 86400) + (Number(m[2] || 0) * 3600) + Number(m[3]) * 60 + Number(m[4])
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// The tty column of `ps -o tty=` -> a device path, or null ("?", "??" = none)
|
|
553
|
+
export function ttyPath(t) {
|
|
554
|
+
const s = String(t || '').trim()
|
|
555
|
+
if (!s || /^\?+$/.test(s) || s === '-') return null
|
|
556
|
+
if (s.startsWith('/dev/')) return s
|
|
557
|
+
return '/dev/' + (/^s\d+$/.test(s) ? 'tty' + s : s) // macOS `ps -o tt` prints "s003" for /dev/ttys003
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// One line from the mod's process probe: "<pid> <tty> <etime>" -> { pid, tty, pidStartedAt }
|
|
561
|
+
export function parseProcLine(line, now = Date.now()) {
|
|
562
|
+
const parts = String(line || '').trim().split(/\s+/)
|
|
563
|
+
const pid = Number(parts[0])
|
|
564
|
+
if (!Number.isInteger(pid) || pid <= 1) return null
|
|
565
|
+
const secs = parseEtime(parts[2])
|
|
566
|
+
return { pid, tty: ttyPath(parts[1]), pidStartedAt: secs == null ? null : toIso(now - secs * 1000) }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ---------------------------------------------------------------- status and dormancy (§6.5)
|
|
570
|
+
|
|
571
|
+
const MIN = 60000
|
|
572
|
+
|
|
573
|
+
export function computeStatus(s, now, cfg = DEFAULT_CONFIG) {
|
|
574
|
+
if (s.endedAt) return 'ended'
|
|
575
|
+
const lastActivity = ms(s.lastActivityAt || s.startedAt)
|
|
576
|
+
const idle = isNaN(lastActivity) ? 0 : now - lastActivity
|
|
577
|
+
const dormantAfter = cfg.dormantAfterMin * MIN
|
|
578
|
+
if (s.capture === 'mod') {
|
|
579
|
+
const hb = ms(s.lastHeartbeatAt || s.lastEventAt || s.startedAt)
|
|
580
|
+
// A Claude Code process the daemon saw running recently isn't stale, even
|
|
581
|
+
// if the mod's heartbeat lapsed (the laptop slept, the mod was slow).
|
|
582
|
+
const seen = ms(s.pidAliveAt)
|
|
583
|
+
const pidFresh = !isNaN(seen) && now - seen <= Math.max(cfg.staleAfterSec * 1000, 90000)
|
|
584
|
+
if (!pidFresh && !isNaN(hb) && now - hb > cfg.staleAfterSec * 1000) return 'stale'
|
|
585
|
+
}
|
|
586
|
+
if (s.needsInput) return 'needs_input'
|
|
587
|
+
if (s.inTurn) {
|
|
588
|
+
// Hooks-only capture sees no event when a turn is interrupted (Stop does not
|
|
589
|
+
// fire), so a turn that has been silent for twice the dormancy window is
|
|
590
|
+
// treated as over. The mod sees turn.complete for aborted turns too.
|
|
591
|
+
if (s.capture === 'hooks' && idle >= 2 * dormantAfter) return 'dormant'
|
|
592
|
+
return 'active'
|
|
593
|
+
}
|
|
594
|
+
if (s.backgroundTasks > 0) return 'background'
|
|
595
|
+
return idle >= dormantAfter ? 'dormant' : 'waiting'
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Returns the trigger a checkpoint is due for now, or null.
|
|
599
|
+
export function checkpointDue(s, now, cfg = DEFAULT_CONFIG) {
|
|
600
|
+
if (s.ignored) return null
|
|
601
|
+
const status = computeStatus(s, now, cfg)
|
|
602
|
+
const lastCp = ms(s.lastCheckpointAt)
|
|
603
|
+
const lastActivity = ms(s.lastActivityAt || s.startedAt)
|
|
604
|
+
const gapOk = isNaN(lastCp) || now - lastCp >= cfg.minGapMin * MIN
|
|
605
|
+
const activitySinceCp = (s.turnsSinceCheckpoint || 0) > 0 || (!isNaN(lastActivity) && (isNaN(lastCp) ? !!s.lastActivityAt : lastActivity > lastCp))
|
|
606
|
+
if (status === 'ended' || status === 'stale') {
|
|
607
|
+
if (s.finalCheckpointDone || !activitySinceCp || !gapOk) return null
|
|
608
|
+
if (!s.lastActivityAt) return null
|
|
609
|
+
return s.endReason === 'clear' ? 'clear' : 'session_end'
|
|
610
|
+
}
|
|
611
|
+
const idle = now - lastActivity
|
|
612
|
+
if (idle < cfg.dormantAfterMin * MIN) {
|
|
613
|
+
if (cfg.longRunCheckpointMin && status === 'active' && s.activeSince) {
|
|
614
|
+
const ref = Math.max(ms(s.activeSince), isNaN(lastCp) ? 0 : lastCp)
|
|
615
|
+
if (now - ref >= cfg.longRunCheckpointMin * MIN && gapOk) return 'long_run'
|
|
616
|
+
}
|
|
617
|
+
return null
|
|
618
|
+
}
|
|
619
|
+
if (s.dormantStretch && s.dormantStretch === s.lastActivityAt) return null // one per idle stretch
|
|
620
|
+
if (!gapOk) return null
|
|
621
|
+
if (status === 'dormant' && (s.turnsSinceCheckpoint || 0) >= 1) return 'dormant'
|
|
622
|
+
if (status === 'needs_input' && activitySinceCp) return 'dormant'
|
|
623
|
+
return null
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Applies one wire event to a session record (mutates and returns it).
|
|
627
|
+
// Shared so the mod's own fallback registry and the daemon agree.
|
|
628
|
+
// v0.4.8: a session Leftoff knows nothing about yet: no turn, no name, no
|
|
629
|
+
// checkpoint, and not a resumed conversation. The board folds these into an
|
|
630
|
+
// "N empty sessions" row until their first prompt; the digest and restore skip them.
|
|
631
|
+
export function isEmptySession(s, checkpointCount = 0) {
|
|
632
|
+
if (!s) return true
|
|
633
|
+
return !(Number(s.turns) > 0) && !s.title && !(Number(checkpointCount) > 0) && !s.lastCheckpointId &&
|
|
634
|
+
!(Number(s.historyTurns) > 0) && s.startSource !== 'resume'
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// The shape of the 0.4.6 "Untitled session" card: empty, and no folder or
|
|
638
|
+
// process either. Before 0.4.8 a daemon restart forgot an open session that
|
|
639
|
+
// had no turns yet, and the mod's next heartbeat made a bare record like this.
|
|
640
|
+
export function isGhostSession(s, checkpointCount = 0) {
|
|
641
|
+
return isEmptySession(s, checkpointCount) && !s.cwd && !(Number(s.pid) > 1)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export function applyEvent(s, ev) {
|
|
645
|
+
const at = toIso(ev.at) || toIso(Date.now())
|
|
646
|
+
const d = ev.data || {}
|
|
647
|
+
s.id = s.id || ev.sessionId
|
|
648
|
+
s.capture = ev.capture || s.capture || 'hooks'
|
|
649
|
+
if (ev.machine && !s.machine) s.machine = machineSlug(ev.machine)
|
|
650
|
+
s.startedAt = s.startedAt || at
|
|
651
|
+
s.lastEventAt = at
|
|
652
|
+
const activity = () => { s.lastActivityAt = at; s.finalCheckpointDone = false; if (s.endedAt && ev.type !== 'session.ended') { s.endedAt = null; s.endReason = null; s.closedAt = null } }
|
|
653
|
+
if (d.cwd) s.cwd = d.cwd
|
|
654
|
+
if (d.transcriptPath) s.transcriptPath = d.transcriptPath
|
|
655
|
+
if (d.model) s.model = d.model
|
|
656
|
+
if (d.surface) s.surface = d.surface
|
|
657
|
+
if (d.repo && typeof d.repo === 'object') s.repo = { ...(s.repo || {}), ...stripNulls(d.repo) }
|
|
658
|
+
// A title carrying our own tab marker came from the mod's /rename: it is the
|
|
659
|
+
// tab label, not a name the user chose, so it never overwrites the title.
|
|
660
|
+
if (d.sessionTitle && !parseTabTitle(d.sessionTitle).id && d.sessionTitle !== s.title && !isOwnTabTitle(d.sessionTitle, s)) { s.title = d.sessionTitle; s.titleSource = 'custom' }
|
|
661
|
+
if (d.proc && typeof d.proc === 'object' && Number(d.proc.pid) > 1) {
|
|
662
|
+
s.pid = Number(d.proc.pid)
|
|
663
|
+
s.tty = d.proc.tty || null
|
|
664
|
+
s.pidStartedAt = d.proc.pidStartedAt || null
|
|
665
|
+
s.pidSource = d.proc.source || (s.capture === 'mod' ? 'mod' : 'hook')
|
|
666
|
+
}
|
|
667
|
+
if (d.term && typeof d.term === 'object') s.term = d.term
|
|
668
|
+
else if (d.termEnv && typeof d.termEnv === 'object') s.term = detectTerminal(d.termEnv)
|
|
669
|
+
switch (ev.type) {
|
|
670
|
+
case 'session.opened':
|
|
671
|
+
s.lastHeartbeatAt = at
|
|
672
|
+
if (s.endedAt) { s.endedAt = null; s.endReason = null; s.closedAt = null } // reopened (claude --resume) in a new process
|
|
673
|
+
break
|
|
674
|
+
case 'session.started':
|
|
675
|
+
if (d.parentSessionId && d.parentSessionId !== s.id) s.parentSessionId = d.parentSessionId
|
|
676
|
+
if (d.source === 'resume' || d.source === 'fork' || d.source === 'clear') { s.endedAt = null; s.endReason = null; s.closedAt = null }
|
|
677
|
+
if (d.source === 'resume') s.startSource = 'resume' // a conversation that already had turns before Leftoff saw this process
|
|
678
|
+
if (s.capture === 'mod') s.lastHeartbeatAt = at
|
|
679
|
+
break
|
|
680
|
+
case 'activity':
|
|
681
|
+
activity()
|
|
682
|
+
if (d.kind === 'tool' && d.phase === 'post' && s.needsInput && s.lastNotification === 'permission_prompt') s.needsInput = false
|
|
683
|
+
if (d.kind === 'prompt') {
|
|
684
|
+
s.needsInput = false
|
|
685
|
+
s.question = null
|
|
686
|
+
s.lastNotification = null
|
|
687
|
+
if (d.prompt && !s.firstPrompt) s.firstPrompt = clip(d.prompt, 300)
|
|
688
|
+
if (s.capture === 'hooks') { s.inTurn = true; s.activeSince = s.activeSince || at }
|
|
689
|
+
}
|
|
690
|
+
break
|
|
691
|
+
case 'turn_started':
|
|
692
|
+
activity()
|
|
693
|
+
s.inTurn = true
|
|
694
|
+
s.needsInput = false
|
|
695
|
+
s.question = null
|
|
696
|
+
s.activeSince = s.activeSince || at
|
|
697
|
+
break
|
|
698
|
+
case 'turn_completed':
|
|
699
|
+
activity()
|
|
700
|
+
s.inTurn = false
|
|
701
|
+
s.activeSince = null
|
|
702
|
+
s.needsInput = false
|
|
703
|
+
s.turns = (s.turns || 0) + 1
|
|
704
|
+
s.turnsSinceCheckpoint = (s.turnsSinceCheckpoint || 0) + 1
|
|
705
|
+
s.backgroundTasks = Number(d.backgroundTasks || 0) + Number(d.sessionCrons || 0)
|
|
706
|
+
if (d.lastAssistantPreview) s.lastPreview = clip(d.lastAssistantPreview, 280)
|
|
707
|
+
// Claude ended the turn on a question to you (v0.4.4): the board offers a reply.
|
|
708
|
+
s.question = d.question ? clip(d.question, 400) : null
|
|
709
|
+
s.questionAt = d.question ? at : null
|
|
710
|
+
break
|
|
711
|
+
case 'notification':
|
|
712
|
+
s.lastNotification = d.notificationType || null
|
|
713
|
+
s.lastNotificationMessage = d.message ? clip(d.message, 200) : null
|
|
714
|
+
if (NEEDS_INPUT_NOTIFICATIONS.includes(d.notificationType) || /^elicitation/.test(d.notificationType || '')) {
|
|
715
|
+
if (!s.needsInput) s.needsInputAt = at
|
|
716
|
+
s.needsInput = true
|
|
717
|
+
}
|
|
718
|
+
break
|
|
719
|
+
case 'title_changed':
|
|
720
|
+
if (d.title && stripTabMarker(d.title)) { s.title = stripTabMarker(d.title); s.titleSource = d.titleSource || 'custom' }
|
|
721
|
+
break
|
|
722
|
+
case 'heartbeat':
|
|
723
|
+
s.lastHeartbeatAt = at
|
|
724
|
+
if (typeof d.inTurn === 'boolean' && !d.inTurn && s.inTurn && s.capture === 'mod') s.inTurn = false
|
|
725
|
+
break
|
|
726
|
+
case 'session.ended':
|
|
727
|
+
s.endedAt = at
|
|
728
|
+
s.endReason = d.reason || 'other'
|
|
729
|
+
s.inTurn = false
|
|
730
|
+
break
|
|
731
|
+
}
|
|
732
|
+
return s
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// The question Claude ended its turn on, if it ended on one: the last paragraph
|
|
736
|
+
// of the answer when its last sentence ends with "?" (markdown and closing
|
|
737
|
+
// quotes ignored). Code blocks don't count. -> string or null
|
|
738
|
+
export function trailingQuestion(text) {
|
|
739
|
+
let t = String(text == null ? '' : text).replace(/\r\n?/g, '\n').trim()
|
|
740
|
+
if (!t) return null
|
|
741
|
+
if (/```\s*$/.test(t)) return null
|
|
742
|
+
const paras = t.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean)
|
|
743
|
+
let last = paras[paras.length - 1] || ''
|
|
744
|
+
const bare = last.replace(/[\s*_`)\]"'”’»]+$/u, '')
|
|
745
|
+
if (!/[??]$/.test(bare)) return null
|
|
746
|
+
// A list of options under a lead-in question: keep the lead-in with them.
|
|
747
|
+
if (paras.length > 1 && /^([-*•]|\d+[.)])\s/.test(last) && /[?:]\s*$/.test(paras[paras.length - 2])) last = paras[paras.length - 2] + '\n' + last
|
|
748
|
+
last = last.replace(/^#+\s*/, '').replace(/[*_`]+$/, '')
|
|
749
|
+
return last.length > 400 ? '…' + last.slice(-399) : last
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function stripNulls(o) { const r = {}; for (const [k, v] of Object.entries(o)) if (v != null) r[k] = v; return r }
|
|
753
|
+
|
|
754
|
+
// ---------------------------------------------------------------- checkpoint input (§7.1)
|
|
755
|
+
|
|
756
|
+
export function compileRedactions(patterns = []) {
|
|
757
|
+
const out = []
|
|
758
|
+
for (const p of patterns) {
|
|
759
|
+
try {
|
|
760
|
+
let src = String(p), flags = 'g'
|
|
761
|
+
if (src.startsWith('(?i)')) { src = src.slice(4); flags = 'gi' }
|
|
762
|
+
out.push(new RegExp(src, flags))
|
|
763
|
+
} catch { /* bad pattern: skipped */ }
|
|
764
|
+
}
|
|
765
|
+
return out
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
export function redact(text, patterns) {
|
|
769
|
+
let s = String(text == null ? '' : text)
|
|
770
|
+
for (const re of compileRedactions(patterns)) s = s.replace(re, '[REDACTED]')
|
|
771
|
+
return s
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
|
|
775
|
+
|
|
776
|
+
function relPath(p, cwd) {
|
|
777
|
+
const s = String(p || '')
|
|
778
|
+
if (cwd && s.startsWith(cwd.replace(/\/+$/, '') + '/')) return s.slice(cwd.replace(/\/+$/, '').length + 1)
|
|
779
|
+
return s
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
export function toolTarget(tool, input = {}, cwd) {
|
|
783
|
+
const i = input || {}
|
|
784
|
+
if (i.file_path) return relPath(i.file_path, cwd)
|
|
785
|
+
if (i.notebook_path) return relPath(i.notebook_path, cwd)
|
|
786
|
+
if (i.command) return clip(i.command, 80)
|
|
787
|
+
if (i.pattern) return clip(i.pattern, 60)
|
|
788
|
+
if (i.url) return clip(i.url, 80)
|
|
789
|
+
if (i.path) return relPath(i.path, cwd)
|
|
790
|
+
if (i.description) return clip(i.description, 60)
|
|
791
|
+
return ''
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
export function filesTouched(messages, cwd) {
|
|
795
|
+
const seen = []
|
|
796
|
+
for (const m of messages || []) {
|
|
797
|
+
for (const t of m.toolUses || []) {
|
|
798
|
+
const name = t.tool || t.name
|
|
799
|
+
const fp = t.input && (t.input.file_path || t.input.notebook_path)
|
|
800
|
+
if (EDIT_TOOLS.includes(name) && fp) {
|
|
801
|
+
const r = relPath(fp, cwd)
|
|
802
|
+
if (!seen.includes(r)) seen.push(r)
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return seen
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// messages: [{ role, text, toolUses: [{ tool, input, isError, text }], toolResults? }]
|
|
810
|
+
// (the shape of $.session.messages(); the daemon's transcript parser produces the same)
|
|
811
|
+
export function transcriptSlice(messages, { maxInputChars = 24000, cwd } = {}) {
|
|
812
|
+
const blocks = []
|
|
813
|
+
let total = 0, lastErrorTaken = false
|
|
814
|
+
for (let i = (messages || []).length - 1; i >= 0; i--) {
|
|
815
|
+
const m = messages[i]
|
|
816
|
+
const lines = []
|
|
817
|
+
if (m.role === 'user') {
|
|
818
|
+
if (m.text && m.text.trim()) lines.push('USER: ' + m.text.trim())
|
|
819
|
+
if (!lastErrorTaken) {
|
|
820
|
+
const err = (m.toolResults || []).find((r) => r.isError)
|
|
821
|
+
if (err) { lines.push('LAST ERROR: ' + clip(err.text, 500)); lastErrorTaken = true }
|
|
822
|
+
}
|
|
823
|
+
} else {
|
|
824
|
+
if (m.text && m.text.trim()) lines.push('ASSISTANT: ' + clip(m.text, 1500))
|
|
825
|
+
for (const t of m.toolUses || []) {
|
|
826
|
+
const name = t.tool || t.name
|
|
827
|
+
let line = ' -> ' + name + (toolTarget(name, t.input, cwd) ? ' ' + toolTarget(name, t.input, cwd) : '')
|
|
828
|
+
if (t.isError) {
|
|
829
|
+
line += ' -> error'
|
|
830
|
+
if (!lastErrorTaken && t.text) { line += ': ' + clip(t.text, 500); lastErrorTaken = true }
|
|
831
|
+
}
|
|
832
|
+
lines.push(line)
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
if (!lines.length) continue
|
|
836
|
+
const block = lines.join('\n')
|
|
837
|
+
if (total + block.length > maxInputChars && blocks.length) break
|
|
838
|
+
blocks.push(block)
|
|
839
|
+
total += block.length + 1
|
|
840
|
+
}
|
|
841
|
+
return blocks.reverse().join('\n')
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ---------------------------------------------------------------- checkpoint length (v0.4.6)
|
|
845
|
+
//
|
|
846
|
+
// config summary.length picks how much a checkpoint says. Every length keeps the
|
|
847
|
+
// three lines (Doing / Stopped at / Next) the board, the pane and the digest use;
|
|
848
|
+
// the longer ones add sections below them. `/cp --short` / `/cp --long` override
|
|
849
|
+
// the setting for one checkpoint.
|
|
850
|
+
export const SUMMARY_LENGTHS = {
|
|
851
|
+
short: { label: 'Short', maxTokens: 300, approx: '~300 tokens', blurb: 'Doing / Stopped at / Next. Three lines, nothing else.' },
|
|
852
|
+
informative: { label: 'Informative', maxTokens: 700, approx: '~700 tokens', blurb: 'The three lines, plus key decisions, open questions, files touched and why, and what didn\'t work.' },
|
|
853
|
+
long: { label: 'Long', maxTokens: 1500, approx: '~1500 tokens', blurb: 'Everything in Informative, plus a short narrative recap and the next 3 steps.' },
|
|
854
|
+
}
|
|
855
|
+
export const SUMMARY_LENGTH_NAMES = Object.keys(SUMMARY_LENGTHS)
|
|
856
|
+
export const DEFAULT_SUMMARY_LENGTH = 'informative'
|
|
857
|
+
|
|
858
|
+
// 'Long' / 'medium' / 'default' / junk -> one of SUMMARY_LENGTH_NAMES (or null for junk when strict).
|
|
859
|
+
export function normalizeLength(v, { strict = false } = {}) {
|
|
860
|
+
const k = String(v == null ? '' : v).trim().toLowerCase()
|
|
861
|
+
if (SUMMARY_LENGTHS[k]) return k
|
|
862
|
+
const alias = { s: 'short', brief: 'short', i: 'informative', info: 'informative', medium: 'informative', normal: 'informative', default: 'informative', l: 'long', full: 'long', detailed: 'long' }[k]
|
|
863
|
+
if (alias) return alias
|
|
864
|
+
return strict ? null : DEFAULT_SUMMARY_LENGTH
|
|
865
|
+
}
|
|
866
|
+
export function summaryLength(cfg = DEFAULT_CONFIG, override) {
|
|
867
|
+
if (override) { const o = normalizeLength(override, { strict: true }); if (o) return o }
|
|
868
|
+
return normalizeLength(cfg && cfg.summary && cfg.summary.length)
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// "/cp --long fix the flaky test" -> { length: 'long', note: 'fix the flaky test' }.
|
|
872
|
+
// Recognised anywhere in the args: --short, --informative, --long, --length=<x>,
|
|
873
|
+
// -s / -l. Everything else is the note, as typed.
|
|
874
|
+
export function parseCheckpointArgs(args) {
|
|
875
|
+
let length = null
|
|
876
|
+
const rest = []
|
|
877
|
+
for (const w of String(args || '').trim().split(/\s+/).filter(Boolean)) {
|
|
878
|
+
const m = w.match(/^--length=(\w+)$/i)
|
|
879
|
+
if (m && normalizeLength(m[1], { strict: true })) { length = normalizeLength(m[1], { strict: true }); continue }
|
|
880
|
+
const f = w.match(/^--?(short|informative|info|long|s|l)$/i)
|
|
881
|
+
if (f) { length = normalizeLength(f[1], { strict: true }); continue }
|
|
882
|
+
rest.push(w)
|
|
883
|
+
}
|
|
884
|
+
return { length, note: rest.join(' ') || null }
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const CHECKPOINT_BASE = [
|
|
888
|
+
'You write checkpoints for a developer juggling many coding-agent sessions. Given the recent transcript of ONE session, start with exactly these three lines:',
|
|
889
|
+
'Doing: <what the session was working on, one sentence, concrete nouns: files, features, errors>',
|
|
890
|
+
'Stopped at: <the exact state when it went quiet: what passed, what failed, what is half-done>',
|
|
891
|
+
'Next: <the single next action a human or the agent should take>',
|
|
892
|
+
'If the session is blocked on the human (a question, a permission, a decision), start Next with "YOU:".',
|
|
893
|
+
]
|
|
894
|
+
const INFORMATIVE_SECTIONS = [
|
|
895
|
+
'Then these sections, each a heading line followed by "- " bullets (write "- none" when there is nothing; never invent):',
|
|
896
|
+
'Decisions:',
|
|
897
|
+
'- <a decision made in the session and the reason, one line each, at most 4>',
|
|
898
|
+
'Open questions:',
|
|
899
|
+
'- <an unresolved question or uncertainty, at most 3>',
|
|
900
|
+
'Files:',
|
|
901
|
+
'- <path> — <why it was touched, a few words; at most 6 files>',
|
|
902
|
+
'Didn\'t work:',
|
|
903
|
+
'- <an approach that was tried and abandoned or failed, and why; at most 3>',
|
|
904
|
+
]
|
|
905
|
+
const LONG_SECTIONS = [
|
|
906
|
+
'Then:',
|
|
907
|
+
'Recap: <a narrative paragraph of 4-7 sentences: the goal, how the session got here, the current state>',
|
|
908
|
+
'Next steps:',
|
|
909
|
+
'1. <the next step>',
|
|
910
|
+
'2. <the step after>',
|
|
911
|
+
'3. <the step after that>',
|
|
912
|
+
]
|
|
913
|
+
export const CHECKPOINT_SYSTEMS = {
|
|
914
|
+
short: [...CHECKPOINT_BASE, 'Write only those three lines.', 'Optionally a 4th line "Title: <max 6 words>" if asked for a title.', 'No preamble. No markdown. Under 60 words total.'].join('\n'),
|
|
915
|
+
informative: [...CHECKPOINT_BASE, ...INFORMATIVE_SECTIONS, 'Optionally a final line "Title: <max 6 words>" if asked for a title.', 'No preamble. No other markdown. Under 250 words total.'].join('\n'),
|
|
916
|
+
long: [...CHECKPOINT_BASE, ...INFORMATIVE_SECTIONS, ...LONG_SECTIONS, 'Optionally a final line "Title: <max 6 words>" if asked for a title.', 'No preamble. No other markdown. Under 600 words total.'].join('\n'),
|
|
917
|
+
}
|
|
918
|
+
// The short prompt, as before v0.4.6 (kept under its old name for anything importing it).
|
|
919
|
+
export const CHECKPOINT_SYSTEM = CHECKPOINT_SYSTEMS.short
|
|
920
|
+
|
|
921
|
+
export function buildCheckpointPrompt({ session, previous, messages, cfg = DEFAULT_CONFIG, wantTitle = false, userNote, length }) {
|
|
922
|
+
const len = summaryLength(cfg, length)
|
|
923
|
+
const maxInputChars = (cfg.summarizer && cfg.summarizer.maxInputChars) || 24000
|
|
924
|
+
const cwd = session.cwd
|
|
925
|
+
const files = filesTouched(messages, cwd)
|
|
926
|
+
const branch = session.repo && session.repo.branch
|
|
927
|
+
const prev = previous ? ['Doing: ' + previous.doing, 'Stopped at: ' + previous.stoppedAt, 'Next: ' + previous.next].join('\n') : 'none'
|
|
928
|
+
const head = [
|
|
929
|
+
'Repo: ' + repoName(session) + (branch ? ' (branch ' + branch + ')' : '') + ' · cwd ' + (cwd || '?'),
|
|
930
|
+
'Title: ' + (session.title || '(none yet)'),
|
|
931
|
+
'Previous checkpoint: ' + prev,
|
|
932
|
+
'Files touched since then: ' + (files.length ? files.join(', ') : 'none'),
|
|
933
|
+
]
|
|
934
|
+
if (userNote) head.push('Note from the developer: ' + userNote)
|
|
935
|
+
if (session.needsInput) head.push('State: the session is waiting on the developer (' + (session.lastNotification || 'a prompt') + ').')
|
|
936
|
+
if (wantTitle) head.push('Also write the line: Title: <max 6 words>.')
|
|
937
|
+
const slice = transcriptSlice(messages, { maxInputChars, cwd })
|
|
938
|
+
const patterns = (cfg.redact && cfg.redact.patterns) || []
|
|
939
|
+
const prompt = redact(head.join('\n') + '\nTranscript (oldest→newest, trimmed):\n' + (slice || '(empty)'), patterns)
|
|
940
|
+
return { system: CHECKPOINT_SYSTEMS[len], prompt, filesTouched: files, inputChars: prompt.length, length: len, maxTokens: SUMMARY_LENGTHS[len].maxTokens }
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// The extra sections of an informative / long checkpoint, by the heading a
|
|
944
|
+
// model (or a checkpoint file) uses for them.
|
|
945
|
+
const SECTION_KEYS = {
|
|
946
|
+
'decisions': 'decisions', 'key decisions': 'decisions',
|
|
947
|
+
'open questions': 'questions', 'questions': 'questions',
|
|
948
|
+
'files': 'files', 'files touched': 'files',
|
|
949
|
+
'didn\'t work': 'didntWork', 'didnt work': 'didntWork', 'did not work': 'didntWork', 'what didn\'t work': 'didntWork', 'what didnt work': 'didntWork', 'tried': 'didntWork',
|
|
950
|
+
'recap': 'recap',
|
|
951
|
+
'next steps': 'nextSteps', 'next 3 steps': 'nextSteps',
|
|
952
|
+
}
|
|
953
|
+
export const SECTION_TITLES = { decisions: 'Key decisions', questions: 'Open questions', files: 'Files touched', didntWork: 'What didn\'t work', recap: 'Recap', nextSteps: 'Next steps' }
|
|
954
|
+
const SECTION_ORDER = ['decisions', 'questions', 'files', 'didntWork', 'recap', 'nextSteps']
|
|
955
|
+
const NONE_RE = /^(none|n\/a|nothing|-|—|none yet|no(ne)? (open )?questions?)\.?$/i
|
|
956
|
+
const LIMITS = { decisions: 6, questions: 5, files: 10, didntWork: 5, nextSteps: 5 }
|
|
957
|
+
|
|
958
|
+
// "src/a.js — retry loop" / "`src/a.js`: why" -> { path, why }
|
|
959
|
+
export function parseFileNote(text) {
|
|
960
|
+
const t = String(text || '').trim()
|
|
961
|
+
const m = t.match(/^`?([^`\s][^`]*?)`?\s*(?:—|–|-{1,2}|:)\s+(.+)$/)
|
|
962
|
+
if (m && /[./]/.test(m[1]) && !/\s/.test(m[1].trim())) return { path: m[1].trim(), why: m[2].trim() }
|
|
963
|
+
const p = t.match(/^`?([^`\s]+)`?$/)
|
|
964
|
+
return p ? { path: p[1], why: '' } : { path: '', why: t }
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function emptyExtras() { return { decisions: [], questions: [], files: [], didntWork: [], recap: '', nextSteps: [] } }
|
|
968
|
+
export function hasExtras(cp) {
|
|
969
|
+
return !!cp && (['decisions', 'questions', 'files', 'didntWork', 'nextSteps'].some((k) => Array.isArray(cp[k]) && cp[k].length) || !!cp.recap)
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Section lines (after the three main lines) -> the extras. Shared by the
|
|
973
|
+
// model-reply parser and the checkpoint-file parser.
|
|
974
|
+
function parseSections(lines) {
|
|
975
|
+
const ex = emptyExtras()
|
|
976
|
+
let cur = null
|
|
977
|
+
const push = (k, v) => {
|
|
978
|
+
v = String(v || '').replace(/\*\*/g, '').trim()
|
|
979
|
+
if (!v || NONE_RE.test(v)) return
|
|
980
|
+
if (k === 'recap') { ex.recap = ex.recap ? ex.recap + ' ' + v : v; return }
|
|
981
|
+
if (k === 'files') { const f = parseFileNote(v); if (f.path || f.why) ex.files.push(f); return }
|
|
982
|
+
ex[k].push(v)
|
|
983
|
+
}
|
|
984
|
+
for (const raw of lines) {
|
|
985
|
+
const line = String(raw).trim()
|
|
986
|
+
if (!line) continue
|
|
987
|
+
const h = line.replace(/^#+\s*/, '').replace(/\*\*/g, '').replace(/^_+|_+$/g, '').trim()
|
|
988
|
+
const hm = h.match(/^([A-Za-z' ’0-9]+?)\s*:\s*(.*)$/) || h.match(/^([A-Za-z' ’0-9]+?)$/)
|
|
989
|
+
const key = hm ? SECTION_KEYS[hm[1].replace(/’/g, '\'').trim().toLowerCase()] : null
|
|
990
|
+
if (key && (hm[2] !== undefined || /^#/.test(line) || !cur)) {
|
|
991
|
+
cur = key
|
|
992
|
+
if (hm[2]) push(key, hm[2].replace(/^[-*•]\s*/, ''))
|
|
993
|
+
continue
|
|
994
|
+
}
|
|
995
|
+
if (!cur) continue
|
|
996
|
+
const item = line.match(/^(?:[-*•]|\d+[.)])\s+(.*)$/)
|
|
997
|
+
if (item) push(cur, item[1])
|
|
998
|
+
else if (cur === 'recap') push(cur, line)
|
|
999
|
+
else if (/^(doing|stopped at|next|title|open question|note|resume)\s*:/i.test(h)) cur = null
|
|
1000
|
+
else push(cur, line)
|
|
1001
|
+
}
|
|
1002
|
+
for (const k of Object.keys(LIMITS)) ex[k] = ex[k].slice(0, LIMITS[k])
|
|
1003
|
+
ex.recap = clip(ex.recap, 1600)
|
|
1004
|
+
return ex
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
export function parseCheckpointReply(text) {
|
|
1008
|
+
const out = { doing: '', stoppedAt: '', next: '', title: null, openQuestion: null, parseOk: true }
|
|
1009
|
+
const map = { 'doing': 'doing', 'stopped at': 'stoppedAt', 'next': 'next', 'title': 'title', 'open question': 'openQuestion' }
|
|
1010
|
+
const rest = []
|
|
1011
|
+
for (const raw of String(text || '').split('\n')) {
|
|
1012
|
+
const line = raw.replace(/^[\s*_#>-]+/, '').replace(/\*\*/g, '')
|
|
1013
|
+
const m = line.match(/^(doing|stopped at|next|title|open question)\s*:\s*(.+)$/i)
|
|
1014
|
+
if (m) out[map[m[1].toLowerCase()]] = m[2].trim()
|
|
1015
|
+
else rest.push(raw)
|
|
1016
|
+
}
|
|
1017
|
+
if (!out.doing || !out.stoppedAt || !out.next) {
|
|
1018
|
+
return { doing: clip(text, 600) || '(empty reply)', stoppedAt: out.stoppedAt || '', next: out.next || '', title: out.title, openQuestion: null, parseOk: false, ...emptyExtras() }
|
|
1019
|
+
}
|
|
1020
|
+
if (out.title) out.title = clip(out.title.replace(/^["']|["']$/g, ''), 60)
|
|
1021
|
+
return { ...out, ...parseSections(rest) }
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// No model available: build a checkpoint from the last prompt and the last reply.
|
|
1025
|
+
// Informative / long add what can be read off the transcript without a model:
|
|
1026
|
+
// the files edited (with the tool), the failed tool calls, the questions asked.
|
|
1027
|
+
export function heuristicCheckpoint(messages, session = {}, { length = 'short', cwd } = {}) {
|
|
1028
|
+
const ms_ = messages || []
|
|
1029
|
+
const lastUser = [...ms_].reverse().find((m) => m.role === 'user' && m.text && m.text.trim())
|
|
1030
|
+
const lastAssistant = [...ms_].reverse().find((m) => m.role === 'assistant' && m.text && m.text.trim())
|
|
1031
|
+
let lastError = null
|
|
1032
|
+
for (let i = ms_.length - 1; i >= 0 && !lastError; i--) {
|
|
1033
|
+
const m = ms_[i]
|
|
1034
|
+
const e1 = (m.toolResults || []).find((r) => r.isError)
|
|
1035
|
+
const e2 = (m.toolUses || []).find((t) => t.isError)
|
|
1036
|
+
if (e1) lastError = e1.text
|
|
1037
|
+
else if (e2) lastError = e2.text || (e2.tool + ' failed')
|
|
1038
|
+
if (m === lastAssistant && !lastError) break
|
|
1039
|
+
}
|
|
1040
|
+
const firstSentence = (t) => { const s = clip(t, 400); const m = s.match(/^(.{20,240}?[.!?])(\s|$)/); return m ? m[1] : clip(s, 240) }
|
|
1041
|
+
const doing = lastUser ? 'Working on: “' + clip(lastUser.text.split('\n')[0], 140) + '”' : (session.firstPrompt ? 'Working on: “' + clip(session.firstPrompt, 140) + '”' : 'No prompt captured yet.')
|
|
1042
|
+
const stoppedAt = lastError ? 'Last error: ' + clip(lastError, 200) : lastAssistant ? firstSentence(lastAssistant.text) : (session.lastPreview ? firstSentence(session.lastPreview) : 'No reply captured yet.')
|
|
1043
|
+
let next = 'Resume the session and continue from the last reply.'
|
|
1044
|
+
const tail = lastAssistant ? clip(lastAssistant.text, 4000).slice(-300) : ''
|
|
1045
|
+
const q = tail.match(/([^.!?\n]{8,200}\?)\s*$/)
|
|
1046
|
+
if (session.needsInput) next = 'YOU: answer the pending ' + String(session.lastNotification || 'prompt').replace(/_/g, ' ') + ' in this session.'
|
|
1047
|
+
else if (q) next = 'YOU: ' + clip(q[1], 180)
|
|
1048
|
+
else if (lastError) next = 'Look at the last error and decide whether to retry or change approach.'
|
|
1049
|
+
const base = { doing, stoppedAt, next, title: null, openQuestion: null, parseOk: true }
|
|
1050
|
+
const len = normalizeLength(length)
|
|
1051
|
+
if (len === 'short') return base
|
|
1052
|
+
const ex = emptyExtras()
|
|
1053
|
+
const dir = cwd || session.cwd
|
|
1054
|
+
const seen = new Map()
|
|
1055
|
+
for (const m of ms_) for (const t of m.toolUses || []) {
|
|
1056
|
+
const name = t.tool || t.name
|
|
1057
|
+
const fp = t.input && (t.input.file_path || t.input.notebook_path)
|
|
1058
|
+
if (EDIT_TOOLS.includes(name) && fp) { const r = relPath(fp, dir); if (!seen.has(r)) seen.set(r, name === 'Write' ? 'written' : 'edited') }
|
|
1059
|
+
if (t.isError && ex.didntWork.length < LIMITS.didntWork) ex.didntWork.push(clip(name + (toolTarget(name, t.input, dir) ? ' ' + toolTarget(name, t.input, dir) : '') + ' failed' + (t.text ? ': ' + t.text.split('\n')[0] : ''), 200))
|
|
1060
|
+
}
|
|
1061
|
+
ex.files = [...seen].slice(-LIMITS.files).map(([path, why]) => ({ path, why }))
|
|
1062
|
+
for (const m of ms_) if (m.role === 'assistant' && m.text) { const qq = trailingQuestion(m.text); if (qq && !ex.questions.includes(qq)) ex.questions.push(clip(qq, 200)) }
|
|
1063
|
+
ex.questions = ex.questions.slice(-LIMITS.questions)
|
|
1064
|
+
if (len === 'long') {
|
|
1065
|
+
const firstUser = ms_.find((m) => m.role === 'user' && m.text && m.text.trim())
|
|
1066
|
+
const prompts = ms_.filter((m) => m.role === 'user' && m.text && m.text.trim()).length
|
|
1067
|
+
ex.recap = [
|
|
1068
|
+
firstUser ? 'The session started with: “' + clip(firstUser.text.split('\n')[0], 160) + '”.' : '',
|
|
1069
|
+
prompts > 1 ? 'There were ' + prompts + ' prompts since.' : '',
|
|
1070
|
+
seen.size ? seen.size + ' file' + (seen.size === 1 ? ' was' : 's were') + ' changed.' : '',
|
|
1071
|
+
lastError ? 'The last tool call failed.' : '',
|
|
1072
|
+
'Written without a model, from the transcript alone.',
|
|
1073
|
+
].filter(Boolean).join(' ')
|
|
1074
|
+
ex.nextSteps = [next]
|
|
1075
|
+
}
|
|
1076
|
+
return { ...base, ...ex }
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// §7.4 fallback title: {repo}@{branch} · {first 6 words of the first prompt}
|
|
1080
|
+
export function fallbackTitle(session) {
|
|
1081
|
+
const words = String(session.firstPrompt || '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean).slice(0, 6).join(' ')
|
|
1082
|
+
const branch = session.repo && session.repo.branch
|
|
1083
|
+
return repoName(session) + (branch ? '@' + branch : '') + (words ? ' · ' + words : '')
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
export const TITLE_SYSTEM = 'Write a title of at most 6 words for a coding session, based on its first prompt. Reply with the title only, no quotes, no punctuation at the end.'
|
|
1087
|
+
|
|
1088
|
+
export function cleanTitle(text) {
|
|
1089
|
+
const t = String(text || '').split('\n').map((l) => l.trim()).find(Boolean) || ''
|
|
1090
|
+
return clip(t.replace(/^title\s*:\s*/i, '').replace(/^["'`*]+|["'`*.]+$/g, ''), 60)
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ---------------------------------------------------------------- catch me up (v0.5.0)
|
|
1094
|
+
|
|
1095
|
+
// A model reply -> its bullet lines (at most max), or null when fewer than two.
|
|
1096
|
+
export function parseBulletList(text, max = 6) {
|
|
1097
|
+
const clean = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim()
|
|
1098
|
+
const out = []
|
|
1099
|
+
for (const line of clean.split('\n')) {
|
|
1100
|
+
const m = line.match(/^\s*(?:[-*•]|\d+[.)])\s+(.*\S)\s*$/)
|
|
1101
|
+
if (m) out.push(m[1].replace(/\*\*/g, '').trim())
|
|
1102
|
+
}
|
|
1103
|
+
if (out.length < 2) return null
|
|
1104
|
+
return out.slice(0, max)
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const CATCHUP_WINDOW_WORD = /^(last|since-last|\d+(?:\.\d+)?(?:m|min|h|hr|d|w|wk)|workday|last-workday|today|yesterday|(?:sun|mon|tues?|wed(?:nes)?|thu(?:rs)?|fri|sat(?:ur)?)(?:day)?|\d{4}-\d{2}-\d{2})$/i
|
|
1108
|
+
|
|
1109
|
+
// /cp-catchup [window] [repo ...] [all|here]: "3d leftoff site", "friday", "here".
|
|
1110
|
+
export function parseCatchupArgs(args) {
|
|
1111
|
+
const words = String(args || '').trim().split(/\s+/).filter(Boolean)
|
|
1112
|
+
const o = { since: null, repos: [], here: false, all: false }
|
|
1113
|
+
for (const w of words) {
|
|
1114
|
+
const k = w.replace(/^--?/, '')
|
|
1115
|
+
if (!o.since && CATCHUP_WINDOW_WORD.test(k)) o.since = k
|
|
1116
|
+
else if (/^(all|everything)$/i.test(k)) o.all = true
|
|
1117
|
+
else if (/^(here|this)$/i.test(k)) o.here = true
|
|
1118
|
+
else o.repos.push(...k.split(',').filter(Boolean))
|
|
1119
|
+
}
|
|
1120
|
+
if (o.all) o.repos = []
|
|
1121
|
+
return o
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// ---------------------------------------------------------------- lists (v0.5.1)
|
|
1125
|
+
//
|
|
1126
|
+
// Checklists Claude writes ("- [ ] …", or numbered / bulleted next steps under
|
|
1127
|
+
// a heading), saved as plain Markdown in lists/<repo>/<slug>.md. Item numbers
|
|
1128
|
+
// are stable: an item keeps its number for good (a hidden "<!-- #3 … -->"
|
|
1129
|
+
// after it), new items get the next one, and nothing is renumbered when items
|
|
1130
|
+
// are ticked or a hand edit moves them.
|
|
1131
|
+
|
|
1132
|
+
const LIST_FENCE = /^\s*(```|~~~)/
|
|
1133
|
+
const LIST_CHECK = /^(\s*)(?:[-*+]|\d{1,3}[.)])\s+\[( |x|X)\]\s+(.*\S)\s*$/
|
|
1134
|
+
const LIST_NUM = /^(\s*)(\d{1,3})[.)]\s+(.*\S)\s*$/
|
|
1135
|
+
const LIST_BUL = /^(\s*)[-*+•]\s+(.*\S)\s*$/
|
|
1136
|
+
const LIST_HEAD = /^\s*(?:#{1,6}\s+(.+?)\s*#*|\*\*(.+?)\*\*\s*:?|__(.+?)__\s*:?|(\S[^\n]{0,100}?)\s*:)\s*$/
|
|
1137
|
+
export const LIST_STEP_WORDS = /\b(need to|you'?ll have to|make sure|in order to|do next|to do|how to|instructions|checklist|next(?: steps?)?|steps?|to-?dos?|tasks?|plan|remaining|follow[- ]?ups?|action items?|check-?list|left to do|what'?s left|still to do|open items?|road ?map|phases?|milestones?|punch ?list|before (?:we|you|i) (?:ship|merge|launch))\b/i
|
|
1138
|
+
|
|
1139
|
+
function listItemText(s) {
|
|
1140
|
+
return String(s || '').replace(/\*\*(.+?)\*\*/g, '$1').replace(/__(.+?)__/g, '$1').replace(/\s*<!--.*?-->\s*$/, '').replace(/\s+/g, ' ').trim()
|
|
1141
|
+
}
|
|
1142
|
+
function listMarker(line) {
|
|
1143
|
+
let m = line.match(LIST_CHECK)
|
|
1144
|
+
if (m) return { indent: m[1].replace(/\t/g, ' ').length, done: m[2] !== ' ', checkbox: true, text: m[3] }
|
|
1145
|
+
m = line.match(LIST_NUM)
|
|
1146
|
+
if (m) return { indent: m[1].replace(/\t/g, ' ').length, done: false, checkbox: false, numbered: true, n: Number(m[2]), text: m[3] }
|
|
1147
|
+
m = line.match(LIST_BUL)
|
|
1148
|
+
if (m && !/^[-*_]{3,}$/.test(line.trim())) return { indent: m[1].replace(/\t/g, ' ').length, done: false, checkbox: false, text: m[2] }
|
|
1149
|
+
return null
|
|
1150
|
+
}
|
|
1151
|
+
export function cleanListHeading(h) {
|
|
1152
|
+
return String(h || '').replace(/^#+\s*/, '').replace(/\*\*|__/g, '').replace(/[::]\s*$/, '').replace(/\s+/g, ' ').trim()
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// The lists in one message's text, in order. A checklist ("- [ ]" / "- [x]")
|
|
1156
|
+
// always counts; a numbered or bulleted list counts when the heading or lead-in
|
|
1157
|
+
// line just above it names steps, a plan, todos, next steps and the like.
|
|
1158
|
+
// Lists inside code fences are not lists. Nested lines belong to the item above.
|
|
1159
|
+
export function listsInText(text, { minItems = 2 } = {}) {
|
|
1160
|
+
const lines = String(text || '').replace(/\r\n/g, '\n').split('\n')
|
|
1161
|
+
const out = []
|
|
1162
|
+
let inFence = false, heading = null, headingAt = -10
|
|
1163
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1164
|
+
const line = lines[i]
|
|
1165
|
+
if (LIST_FENCE.test(line)) { inFence = !inFence; continue }
|
|
1166
|
+
if (inFence) continue
|
|
1167
|
+
if (!line.trim()) continue
|
|
1168
|
+
const first = listMarker(line)
|
|
1169
|
+
if (!first) {
|
|
1170
|
+
const h = line.match(LIST_HEAD)
|
|
1171
|
+
if (h) { heading = cleanListHeading(h[1] || h[2] || h[3] || h[4]); headingAt = i } else { heading = null }
|
|
1172
|
+
continue
|
|
1173
|
+
}
|
|
1174
|
+
// a list starts here
|
|
1175
|
+
const base = first.indent
|
|
1176
|
+
const items = [{ text: listItemText(first.text), done: first.done, checkbox: first.checkbox, numbered: !!first.numbered }]
|
|
1177
|
+
let j = i + 1
|
|
1178
|
+
for (; j < lines.length; j++) {
|
|
1179
|
+
const l = lines[j]
|
|
1180
|
+
if (LIST_FENCE.test(l)) break
|
|
1181
|
+
if (!l.trim()) {
|
|
1182
|
+
let k = j + 1
|
|
1183
|
+
while (k < lines.length && !lines[k].trim()) k++
|
|
1184
|
+
const nx = k < lines.length ? listMarker(lines[k]) : null
|
|
1185
|
+
if (nx && nx.indent <= base + 1) { j = k - 1; continue }
|
|
1186
|
+
break
|
|
1187
|
+
}
|
|
1188
|
+
const mk = listMarker(l)
|
|
1189
|
+
const ind = l.match(/^\s*/)[0].replace(/\t/g, ' ').length
|
|
1190
|
+
if (mk && mk.indent <= base + 1) { items.push({ text: listItemText(mk.text), done: mk.done, checkbox: mk.checkbox, numbered: !!mk.numbered }); continue }
|
|
1191
|
+
if (ind > base) {
|
|
1192
|
+
// a nested bullet folds into the item above ("Prepare assets — icon; screenshots");
|
|
1193
|
+
// a wrapped line continues it
|
|
1194
|
+
const last = items[items.length - 1]
|
|
1195
|
+
const t = listItemText(mk ? mk.text : l)
|
|
1196
|
+
if (last && t) last.text = mk ? (last.sub ? last.text + '; ' + t : last.text + ' — ' + t) : last.text + ' ' + t
|
|
1197
|
+
if (last && mk) last.sub = true
|
|
1198
|
+
continue
|
|
1199
|
+
}
|
|
1200
|
+
break
|
|
1201
|
+
}
|
|
1202
|
+
const checks = items.filter((x) => x.checkbox).length
|
|
1203
|
+
const near = heading && headingAt >= i - 3 ? heading : null
|
|
1204
|
+
const kind = checks >= Math.ceil(items.length / 2) ? 'checklist' : items.every((x) => x.numbered) ? 'numbered' : 'bulleted'
|
|
1205
|
+
const counts = kind === 'checklist' || (near && LIST_STEP_WORDS.test(near))
|
|
1206
|
+
const real = items.filter((x) => x.text)
|
|
1207
|
+
if (counts && real.length >= minItems) out.push({ kind, heading: near, items: real.map((x) => ({ text: x.text, done: !!x.done })), line: i })
|
|
1208
|
+
heading = null
|
|
1209
|
+
i = j - 1
|
|
1210
|
+
}
|
|
1211
|
+
return out
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// Every list Claude wrote in a session, oldest first, numbered 1..n.
|
|
1215
|
+
// messages: the $.session.messages() / transcript shape.
|
|
1216
|
+
export function extractChecklists(messages, { minItems = 2, fallback = null } = {}) {
|
|
1217
|
+
const out = []
|
|
1218
|
+
;(messages || []).forEach((m, mi) => {
|
|
1219
|
+
if (!m || m.role !== 'assistant' || !m.text) return
|
|
1220
|
+
for (const c of listsInText(m.text, { minItems })) out.push({ ...c, message: mi, at: m.at || null })
|
|
1221
|
+
})
|
|
1222
|
+
return out.map((c, k) => ({ ...c, n: k + 1, name: listAutoName(c, { fallback }) }))
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// How alike two item lists are (0..1): the share of the shorter one's items
|
|
1226
|
+
// the other also has. A list Claude repeats with some boxes ticked is the same list.
|
|
1227
|
+
const normItem = (t) => String(t || '').toLowerCase().replace(/[`*_~]/g, '').replace(/[^a-z0-9]+/g, ' ').trim()
|
|
1228
|
+
export function listSimilarity(a, b) {
|
|
1229
|
+
const A = (a || []).map((x) => normItem(x.text || x)).filter(Boolean), B = new Set((b || []).map((x) => normItem(x.text || x)).filter(Boolean))
|
|
1230
|
+
if (!A.length || !B.size) return 0
|
|
1231
|
+
const hit = A.filter((t) => B.has(t)).length
|
|
1232
|
+
return hit / Math.min(A.length, B.size)
|
|
1233
|
+
}
|
|
1234
|
+
export function listFingerprint(items) {
|
|
1235
|
+
let h = 2166136261
|
|
1236
|
+
for (const ch of (items || []).map((x) => normItem(x.text || x)).join('|')) { h ^= ch.charCodeAt(0); h = Math.imul(h, 16777619) >>> 0 }
|
|
1237
|
+
return h.toString(16).padStart(8, '0')
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// A list's name from its heading: "Here are the next steps to submit to the
|
|
1241
|
+
// App Store:" -> "Next steps to submit to the App Store". No heading -> the
|
|
1242
|
+
// fallback (the caller passes the session's title), else "Checklist".
|
|
1243
|
+
export function listAutoName(c, { fallback = null } = {}) {
|
|
1244
|
+
let h = cleanListHeading(c && c.heading)
|
|
1245
|
+
h = h.split(/(?<=[.!?])\s+/).pop() // "Build is green. Here are the next steps…" -> the last sentence
|
|
1246
|
+
h = h.replace(/^(?:ok(?:ay)?|sure|great|alright|done)[,.!]?\s+/i, '')
|
|
1247
|
+
h = h.replace(/^(?:so\s+)?(?:here(?:'|’)?s|here is|here are|these are|this is|below (?:is|are))\s+(?:(?:the|my|a|an|your|our|some)\s+)?/i, '')
|
|
1248
|
+
h = h.replace(/^(?:the|my|your|our)\s+/i, '').replace(/[.!]+$/, '').trim()
|
|
1249
|
+
if (h) return clip(h.charAt(0).toUpperCase() + h.slice(1), 60)
|
|
1250
|
+
const f = cleanListHeading(fallback)
|
|
1251
|
+
return f ? clip(f, 48) + ' checklist' : 'Checklist'
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// The Stop hook's cheap check (no model): did this reply contain a list of 3+?
|
|
1255
|
+
// -> { count, heading, kind, name, items } for the last such list, or null.
|
|
1256
|
+
export function listHintFrom(text, { minItems = 3, maxItems = 60, fallback = null } = {}) {
|
|
1257
|
+
const ls = listsInText(text, { minItems })
|
|
1258
|
+
if (!ls.length) return null
|
|
1259
|
+
const c = ls[ls.length - 1]
|
|
1260
|
+
const items = c.items.slice(0, maxItems).map((x) => ({ text: clip(x.text, 300), done: !!x.done }))
|
|
1261
|
+
return { count: c.items.length, heading: c.heading || null, kind: c.kind, name: listAutoName(c, { fallback }), items, fp: listFingerprint(items) }
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
export function listSlug(s, max = 48) {
|
|
1265
|
+
return String(s || '').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, max).replace(/-+$/, '') || 'list'
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const LIST_ORDER = ['name', 'repo', 'repo_key', 'source_session', 'source_machine', 'source_title', 'created', 'updated', 'archived', 'next_id', 'schema']
|
|
1269
|
+
const LIST_ITEM = /^(\s*)[-*+]\s+\[( |x|X)\]\s+(.*?)\s*(?:<!--\s*#(\d+)(?:\s+(\S+))?\s*-->)?\s*$/
|
|
1270
|
+
|
|
1271
|
+
// A list file -> { meta, items: [{ id, text, done, at }], body: [segments] }.
|
|
1272
|
+
// body keeps every line that isn't an item (notes, blank lines) where it was,
|
|
1273
|
+
// so a hand-edited file comes back out the same.
|
|
1274
|
+
export function parseListMd(text) {
|
|
1275
|
+
const { data, body } = parseFrontmatter(text)
|
|
1276
|
+
const meta = {
|
|
1277
|
+
name: data.name == null ? null : String(data.name), repo: data.repo == null ? null : String(data.repo), repoKey: data.repo_key == null ? null : String(data.repo_key),
|
|
1278
|
+
sourceSession: data.source_session || null, sourceMachine: data.source_machine == null ? null : String(data.source_machine), sourceTitle: data.source_title == null ? null : String(data.source_title),
|
|
1279
|
+
created: data.created ? toIso(data.created) : null, updated: data.updated ? toIso(data.updated) : null, archived: data.archived === true, nextId: Number(data.next_id) || 0,
|
|
1280
|
+
}
|
|
1281
|
+
const items = [], segs = []
|
|
1282
|
+
let titleSeen = false
|
|
1283
|
+
for (const raw of String(body || '').replace(/\r\n/g, '\n').replace(/\n+$/, '').split('\n')) {
|
|
1284
|
+
const m = raw.match(LIST_ITEM)
|
|
1285
|
+
if (m && !m[1]) {
|
|
1286
|
+
const it = { id: m[4] ? Number(m[4]) : null, text: m[3].trim(), done: m[2] !== ' ', at: m[5] ? toIso(m[5]) : null }
|
|
1287
|
+
items.push(it); segs.push({ item: it })
|
|
1288
|
+
continue
|
|
1289
|
+
}
|
|
1290
|
+
if (!titleSeen && /^#\s+/.test(raw)) { titleSeen = true; if (!meta.name) meta.name = raw.replace(/^#\s+/, '').trim(); segs.push({ title: true }); continue }
|
|
1291
|
+
segs.push({ line: raw })
|
|
1292
|
+
}
|
|
1293
|
+
return { meta, items, segs }
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Give every item a number (hand-added ones get the next free one).
|
|
1297
|
+
export function numberListItems(list) {
|
|
1298
|
+
// nextId is the next number to give out; numbers are never reused
|
|
1299
|
+
let next = Math.max(list.meta.nextId || 1, ...list.items.map((x) => (x.id || 0) + 1))
|
|
1300
|
+
const seen = new Set()
|
|
1301
|
+
for (const it of list.items) {
|
|
1302
|
+
if (!it.id || seen.has(it.id)) it.id = next++
|
|
1303
|
+
seen.add(it.id)
|
|
1304
|
+
}
|
|
1305
|
+
list.meta.nextId = next
|
|
1306
|
+
return list
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
export function renderListMd(list) {
|
|
1310
|
+
numberListItems(list)
|
|
1311
|
+
const m = list.meta
|
|
1312
|
+
const fm = toFrontmatter({
|
|
1313
|
+
name: m.name || 'List', repo: m.repo || null, repo_key: m.repoKey || null, source_session: m.sourceSession || null, source_machine: m.sourceMachine || null,
|
|
1314
|
+
source_title: m.sourceTitle || null, created: m.created || null, updated: m.updated || null, archived: !!m.archived, next_id: m.nextId, schema: SCHEMA,
|
|
1315
|
+
}, LIST_ORDER)
|
|
1316
|
+
const segs = list.segs && list.segs.length ? list.segs.slice() : [{ title: true }, { line: '' }]
|
|
1317
|
+
// items not placed yet (new ones) go after the last item line
|
|
1318
|
+
const placed = new Set(segs.filter((s) => s.item).map((s) => s.item))
|
|
1319
|
+
const fresh = list.items.filter((it) => !placed.has(it))
|
|
1320
|
+
const live = new Set(list.items)
|
|
1321
|
+
let lastItem = -1
|
|
1322
|
+
segs.forEach((s, k) => { if (s.item) lastItem = k })
|
|
1323
|
+
const at = lastItem >= 0 ? lastItem + 1 : segs.length
|
|
1324
|
+
segs.splice(at, 0, ...fresh.map((it) => ({ item: it })))
|
|
1325
|
+
if (!segs.some((s) => s.title)) segs.unshift({ title: true }, { line: '' })
|
|
1326
|
+
const one = (s) => String(s == null ? '' : s).replace(/\s*\n\s*/g, ' ').replace(/<!--/g, '<!-').trim()
|
|
1327
|
+
const lines = []
|
|
1328
|
+
for (const s of segs) {
|
|
1329
|
+
if (s.title) lines.push('# ' + one(m.name || 'List'))
|
|
1330
|
+
else if (s.item) { if (live.has(s.item)) lines.push('- [' + (s.item.done ? 'x' : ' ') + '] ' + one(s.item.text) + ' <!-- #' + s.item.id + (s.item.at ? ' ' + s.item.at : '') + ' -->') }
|
|
1331
|
+
else lines.push(s.line)
|
|
1332
|
+
}
|
|
1333
|
+
return fm + lines.join('\n').replace(/\n+$/, '') + '\n'
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
export function newList({ name, items = [], repo = null, repoKey = null, sourceSession = null, sourceMachine = null, sourceTitle = null, now = new Date().toISOString() }) {
|
|
1337
|
+
const list = { meta: { name: cleanListHeading(name) || 'List', repo, repoKey, sourceSession, sourceMachine, sourceTitle, created: toIso(now), updated: toIso(now), archived: false, nextId: 0 }, items: [], segs: null }
|
|
1338
|
+
for (const it of items) if (it && String(it.text || '').trim()) list.items.push({ id: null, text: listItemText(it.text), done: !!it.done, at: it.done ? toIso(now) : null })
|
|
1339
|
+
return numberListItems(list)
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// Two copies of one list (a sync conflict, or a hand edit racing the board):
|
|
1343
|
+
// every item from either survives; for an item both have, the later change
|
|
1344
|
+
// (its "at") wins; the name and archived flag come from the copy updated last.
|
|
1345
|
+
export function mergeLists(a, b) {
|
|
1346
|
+
const newer = (ms(b.meta.updated) || 0) > (ms(a.meta.updated) || 0) ? b : a
|
|
1347
|
+
const older = newer === a ? b : a
|
|
1348
|
+
const norm = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
|
|
1349
|
+
const out = { meta: { ...older.meta, ...newer.meta, nextId: Math.max(a.meta.nextId || 0, b.meta.nextId || 0) }, items: [], segs: newer.segs }
|
|
1350
|
+
const byId = new Map()
|
|
1351
|
+
const take = (it) => {
|
|
1352
|
+
let cur = it.id ? byId.get(it.id) : null
|
|
1353
|
+
// Same number, different words: an edit if they still look alike, else two
|
|
1354
|
+
// different items that each copy numbered on its own (both stay; one is renumbered).
|
|
1355
|
+
if (cur && norm(cur.text) !== norm(it.text) && !sameItemWords(cur.text, it.text)) cur = null
|
|
1356
|
+
if (!cur) cur = out.items.find((x) => norm(x.text) === norm(it.text))
|
|
1357
|
+
if (!cur) { const c = { ...it }; out.items.push(c); if (c.id && !byId.has(c.id)) byId.set(c.id, c); else if (c.id) c.id = null; return }
|
|
1358
|
+
if ((ms(it.at) || 0) > (ms(cur.at) || 0)) Object.assign(cur, { text: it.text, done: it.done, at: it.at })
|
|
1359
|
+
}
|
|
1360
|
+
for (const it of newer.items) take(it)
|
|
1361
|
+
for (const it of older.items) take(it)
|
|
1362
|
+
// newer's layout, with its own item objects swapped for the merged ones
|
|
1363
|
+
out.segs = (newer.segs || []).map((s) => (s.item ? { item: out.items.find((x) => x.id === s.item.id && x.id) || out.items.find((x) => norm(x.text) === norm(s.item.text)) || s.item } : s))
|
|
1364
|
+
const seen = new Set()
|
|
1365
|
+
out.segs = out.segs.filter((s) => !s.item || (!seen.has(s.item) && seen.add(s.item)))
|
|
1366
|
+
return numberListItems(out)
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function sameItemWords(a, b) {
|
|
1370
|
+
const wa = new Set(String(a).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean))
|
|
1371
|
+
const wb = [...new Set(String(b).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean))]
|
|
1372
|
+
if (!wa.size || !wb.length) return false
|
|
1373
|
+
return wb.filter((w) => wa.has(w)).length / Math.max(wa.size, wb.length) > 0.5
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
export function listProgress(list) {
|
|
1377
|
+
const total = list.items.length, done = list.items.filter((x) => x.done).length
|
|
1378
|
+
return { done, total, label: done + '/' + total }
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// /cp-todo [name] [--pick [N]] and /cp-done <n…|text> [in <list>]
|
|
1382
|
+
export function parseTodoArgs(args) {
|
|
1383
|
+
const s = String(args || '').trim()
|
|
1384
|
+
const m = s.match(/--pick(?:[=\s]+(\d+))?/)
|
|
1385
|
+
const rest = s.replace(/--pick(?:[=\s]+\d+)?/, '').trim()
|
|
1386
|
+
return { pick: m ? (m[1] ? Number(m[1]) : true) : null, name: rest || null }
|
|
1387
|
+
}
|
|
1388
|
+
export function parseDoneArgs(args) {
|
|
1389
|
+
const s = String(args || '').trim()
|
|
1390
|
+
const undo = /^(--undo|undo|not)\b/i.test(s)
|
|
1391
|
+
const body = s.replace(/^(--undo|undo|not)\s*/i, '')
|
|
1392
|
+
const inList = body.match(/\s+(?:in|from|on)\s+(\S+)\s*$/i)
|
|
1393
|
+
const main = inList ? body.slice(0, inList.index) : body
|
|
1394
|
+
const nums = main.split(/[\s,]+/).filter(Boolean)
|
|
1395
|
+
if (nums.length && nums.every((x) => /^#?\d+$/.test(x))) return { numbers: nums.map((x) => Number(x.replace('#', ''))), text: null, list: inList ? inList[1] : null, undo }
|
|
1396
|
+
return { numbers: [], text: main.trim() || null, list: inList ? inList[1] : null, undo }
|
|
1397
|
+
}
|