Export eta : /export-tout exporte en un clic, sans tokens, un snapshot daté de tout (conversations, statut des conversations, workflows et agents, CI…

Now shown inside Veille (tab Export eta, button « Exporter vers eta », /veille export); /export-eta keeps working (this mod while loaded, else Veille's alias).
Exports Claude Code conversations to the eta repo as masked Markdown through a PR, with no model call. /export-eta [en-cours | lot | close] (and /export when the built-in leaves it free) opens its pane: Lot terminé, En cours, Sélection, Exports.
/export-tout (or /export-eta tout) is the one-click export Julian asked for on 17 Oct: no pane, no token, a toast when it is done: « Exporté : N fichiers, PR #x mergée ». It writes a dated snapshot into the eta repo under sources/claude-control/snapshots/<YYYY-MM-DD-HHMM>/ and lands it with the conversations through one merged PR (same git flow, same masking and final confidentiality check as the other exports):
| File | From |
|---|---|
INDEX.md | written for an AI analyst: what each file is, how to read it, what to look for, held-back files |
status/sessions.md + .json | the desktop app's sessions: state, model, effort, branch, PRs, parent, end-of-turn summary |
status/workflows.md | ~/.claude/projects/*/*/subagents/workflows/*/journal.jsonl: every agent, phase, done / failed / running |
status/ci.md | the ci mod's cache: today and week, runner queue, running pipelines with an ETA (lib/core/estimate), failures |
status/usage.md | usage mod: top conversations / projects / models over 24 h and 7 days, tok/s, quotas (lib/core/usage) |
problemes.md + .json | the Problèmes register: every open problem, fixed this week |
veille.md | veille heartbeat, open alerts, the Fil (conversations waiting), night summary (PRs merged since) |
a-faire.md | À faire items with why and steps |
outputs/ | mods-health, latest audits, manual-checks, the tail of questions.md, tests-reels counts (never accounts or URLs) |
rules/ | global CLAUDE.md, tout.rules_files (e.g. RULES-INVENTORY.md), settings env (secret-looking values masked), mods loaded with versions |
conversations.md | the conversations exported with it (ongoing, finished since the last export) in sources/conversations/ |
Never read, whatever the config says: smtp.json, .credentials.json, auth.json, .netrc, keys (lib/snapshot.mjs forbidden()). A file still holding a secret after masking is held back and listed in INDEX.md. Settings: the tout block of export-eta.config.json (override in ~/.claude/export-eta/config.json); labels tout_*. The jauge's Export button runs /export-tout.
The band's Export conv / Export + enfants +N buttons (and /export-eta brief [enfants]) write ONE Markdown file meant to be pasted into another AI (ChatGPT, Codex, another Claude) so it knows where a conversation stands. Local and clipboard only: no model call, no eta-repo PR. File: ~/.claude/export-eta/briefs/<date>-<titre>.md (-avec-enfants with children), copied to the clipboard (PowerShell Set-Clipboard on Windows); a toast gives the path and « copié ».
Each conversation, in this order:
wrap_up), the app's status line, the latest task list (update_plan with advance / done / start applied, or TodoWrite), Claude's last reply.fin ~HH:MM of a running check from its workflow's last successful runs, lib/core/estimate.mjs), running Workflows (phase i/n, agents done / running / failed, fin ~HH:MM from past runs of the same Workflow) and agents, read by lib/core/running.mjs.origin/main (no fetch), uncommitted files, last 10 commits.gh pr create / glab mr create output, its agents' results, the app's linked PRs): number, title, state, checks, read with gh / glab (skipped when missing).Tool short arg → ok / erreur : first line », AskUserQuestion with its options and answer, compactions and background results as one line. Tool outputs and file contents are never copied.With children, the file starts with a contents list, then one section per sub-conversation (recursive, max_depth), each with the same six parts. Past max_chars_per_conv (60 000) a conversation keeps its head and its most recent messages and says how many older ones were cut. The whole file goes through lib/mask.mjs before it is written; a secret the mask cannot fix stays in the local file and the clipboard is left alone (the toast says so).
Script: node mods/export-eta/exporter/brief.mjs run --session <cli id> [--children] [--cwd <dir>] [--no-clip] [--out <file>] [--json] (--sid local_… works too; info --session <id> prints the child count the band shows). The app's session files are read once and cached by mtime (briefs/.sessions-cache.json). git and gh calls run side by side; a brief takes ~10 s, a lead with 6 children ~35 s on Windows. Settings: the brief block of export-eta.config.json (labels: every heading and word, BRIEF_LABELS in lib/brief.mjs).
The finished batch is also the Export eta tab of the Veille pane (/veille export). That tab reads this mod's ~/.claude/export-eta/pending.json and state.json and its button runs this mod's exporter (exporter/export.mjs run --mode batch): one code path. When this mod is not loaded, veille registers /export-eta as an alias of its tab, so the command keeps working; while it is loaded, this mod serves /export-eta itself.
exporter/export.mjs list | pending | run (not --dry-run) writes ~/.claude/mods-health/export-eta.json through lib/core/health.mjs: error null when the command succeeded, else <command>_failed: <last line>, with credentials in URLs and token-like words masked (healthText). Santé and the Problèmes register read it.
exporter/ (the script; tout.mjs for Exporter tout; brief.mjs for the brief), lib/ (masking, layout, render, eta git flow, snapshot.mjs sections; lib/core/ synced copies, never edited by hand), hooks/ (pane), export-eta.config.json. The eta path lives in ~/.claude/export-eta/config.json, never in this repo. Tests: node --test mods/export-eta/tests/*.test.mjs (tout.test.mjs: snapshot on invented fixtures and a temp fake eta repo; brief.test.mjs: the brief on fixture session files), claude plugin test mods/export-eta.
hooks/register.mjs 235 lines1// export-eta - the pane « Export eta » (/export, /export-eta) and one line above the prompt when a batch of finished
2// conversations is ready. Read-only view of <home> (~/.claude/export-eta), written by exporter/export.mjs; every button
3// runs that script (one code path, the same one the Veille watcher runs). No model call, no text for the model, nothing
4// blocked or rewritten.
5// /export [en-cours | lot | tout | close]
6// /export-tout (= /export-eta tout): « Exporter tout », one click, no pane: the dated snapshot + conversations land in
7// eta through one merged PR; a toast says « Exporté : N fichiers, PR #x mergée ».
8import { bandLine, batchView, historyRows, ongoing, selection } from './view.mjs'
9import { deepMerge, fill } from '../lib/layout.mjs'
10
11const PANE = 'export-eta'
12const D = { cfg: null, sessions: null, pending: null, state: null, tab: 'batch', selected: new Set(), open: new Set(), busy: '', msg: '', dismissed: '', at: 0, home: '' }
13
14const root = ($) => $.plugin.root.replace(/\\/g, '/')
15
16async function homeDir($) {
17 const o = await $.env.get('EXPORT_ETA_HOME')
18 if (o) return o.replace(/\\/g, '/')
19 return ((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '').replace(/\\/g, '/') + '/.claude/export-eta'
20}
21
22async function readJson($, p) {
23 try {
24 if (!(await $.fs.exists(p))) return null
25 return JSON.parse(String(await $.fs.read(p)).replace(/^/, ''))
26 } catch { return null }
27}
28
29async function load($) {
30 D.home = await homeDir($)
31 const base = (await readJson($, root($) + '/export-eta.config.json')) || {}
32 D.cfg = deepMerge(base, (await readJson($, D.home + '/config.json')) || {})
33 D.sessions = await readJson($, D.home + '/sessions.json')
34 D.pending = await readJson($, D.home + '/pending.json')
35 D.state = await readJson($, D.home + '/state.json')
36 try { D.at = await $.clock.now() } catch { D.at = Date.now() }
37}
38
39async function exporter($, args, timeoutMs = 60000) {
40 const node = (D.cfg && D.cfg.node) || 'node'
41 try {
42 const r = await $.process.run([node, root($) + '/exporter/export.mjs', ...args], { timeoutMs })
43 D.msg = ((r.exitCode === 0 ? r.stdout : r.stderr || r.stdout) || '').trim().split('\n').pop().replace(/^export-eta: /, '').slice(0, 300)
44 return r.exitCode === 0
45 } catch (err) {
46 D.msg = String(err && err.message ? err.message : err).slice(0, 300)
47 return false
48 }
49}
50
51async function act($, label, args, timeoutMs) {
52 if (D.busy) return
53 D.busy = label
54 $.ui.invalidate('ui.render')
55 try { await exporter($, args, timeoutMs) } finally { D.busy = ''; await load($).catch(() => undefined); $.ui.invalidate('ui.render') }
56}
57
58const EXPORT_MS = 600000
59const runMode = ($, mode, ids) => act($, mode, ['run', '--mode', mode, ...(ids && ids.length ? ['--ids', ids.join(',')] : [])], EXPORT_MS)
60const refresh = ($) => act($, 'list', ['list'], 120000)
61
62const TOUT_MS = 600000 // the host's ceiling for process.run
63async function exportTout($) {
64 const L = (D.cfg && D.cfg.labels) || {}
65 if (D.busy) { try { $.ui.toast(L.busy || 'export en cours…') } catch { /* no toast */ } return }
66 try { $.ui.toast(L.tout_started || 'Export tout : snapshot et conversations vers eta…') } catch { /* no toast */ }
67 await act($, 'tout', ['tout'], TOUT_MS)
68 try { $.ui.toast(D.msg || L.tout_failed || 'Export tout : fini') } catch { /* no toast */ }
69}
70
71function draw(el, vm, h) {
72 const cfg = D.cfg || {}
73 const L = cfg.labels || {}
74 const C = cfg.colors || {}
75 const T = (text, o = {}) => el.Text({ children: [String(text ?? '')], wrap: 'wrap', ...(o.tone && C[o.tone] ? { color: C[o.tone] } : {}), ...(o.dim ? { dimColor: true } : {}), ...(o.bold ? { bold: true } : {}) })
76 const row = (parts) => el.Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, children: parts.filter(Boolean) })
77 const col = (parts, o = {}) => el.Box({ flexDirection: 'column', ...o, children: parts.filter(Boolean) })
78 const box = (title, parts, tone) => el.Box({ flexDirection: 'column', borderStyle: 'round', borderColor: C[tone || 'dim'], paddingX: 1, marginBottom: 1, children: [title ? T(title, { bold: true, tone }) : null, ...parts].filter(Boolean) })
79 const btn = (label, onPress, primary, key) => el.Button({ children: [label], ...(key ? { key } : {}), ...(primary ? { variant: 'primary' } : { plain: true }), onPress })
80 const link = (href, label) => (href ? (el.Link ? el.Link({ href, children: [label] }) : T(`${label} ${href}`, { dim: true })) : null)
81
82 const tabs = [['batch', L.tab_batch || 'Lot terminé'], ['ongoing', L.tab_ongoing || 'En cours'], ['selection', L.tab_selection || 'Sélection'], ['history', L.tab_history || 'Exports']]
83 const head = col([
84 row(tabs.map(([id, label]) => btn(label, () => h.tab(id), id === D.tab))),
85 vm.configured ? null : T(fill(L.not_configured, { file: `${D.home}/config.json` }), { tone: 'warn' }),
86 D.busy ? T(`${L.busy || 'export en cours…'} (${D.busy})`, { tone: 'accent' }) : null,
87 D.msg ? T(D.msg, { dim: true }) : null,
88 ], { marginBottom: 1 })
89
90 let body
91 if (D.tab === 'batch') {
92 const b = vm.batch
93 body = box(L.tab_batch || 'Lot terminé', b.ready ? [
94 T(b.text, { bold: true, tone: 'warn' }), b.reason ? T(b.reason, { dim: true }) : null,
95 ...b.titles.slice(0, 20).map((t) => T(`· ${t}`, { dim: true })),
96 row([btn(L.export_batch || 'Exporter vers eta', h.batch, true, 'batch-export'), b.auto ? T('auto : la veille l’exporte sans clic', { dim: true }) : null]),
97 ] : [T(b.none, { dim: true }), row([btn(L.export_now || 'Exporter maintenant', h.ongoing, false), btn('↻', h.refresh)])], b.ready ? 'warn' : 'dim')
98 } else if (D.tab === 'ongoing') {
99 body = box(`${L.tab_ongoing || 'En cours'} (${vm.ongoing.length})`, [
100 ...vm.ongoing.slice(0, 40).map((s) => T(`· ${s.project} · ${s.title || s.cli.slice(0, 8)}${s.state === 'live' ? ' (active)' : ''}`)),
101 vm.ongoing.length ? null : T('aucune', { dim: true }),
102 row([btn(L.export_now || 'Exporter maintenant', h.ongoing, true), btn('↻', h.refresh)]),
103 ])
104 } else if (D.tab === 'selection') {
105 const n = D.selected.size
106 body = box(L.tab_selection || 'Sélection', [
107 row([btn(fill(L.export_selection || 'Exporter la sélection ({n})', { n }), h.selection, n > 0), n ? btn('Vider', h.clear) : null, btn('↻', h.refresh)]),
108 ...vm.groups.map((g) => col([
109 btn(`${g.open ? '▾' : '▸'} ${g.project} (${g.count}${g.picked ? `, ${g.picked} choisie(s)` : ''})`, () => h.fold(g.project)),
110 ...(g.open ? g.rows.map((r) => btn(`${r.checked ? '☑' : '☐'} ${r.title} · ${r.when}${r.exported ? ' · exportée' : ''}`, () => h.toggle(r.cli))) : []),
111 g.open && g.more ? T(`… et ${g.more} de plus`, { dim: true }) : null,
112 ])),
113 vm.groups.length ? null : T(D.sessions ? 'aucune conversation terminée' : L.first_scan || 'Lecture…', { dim: true }),
114 ])
115 } else {
116 body = box(L.tab_history || 'Exports', vm.history.length ? vm.history.map((x) => col([
117 row([T(`● ${x.when}`, { tone: x.tone, bold: true }), T(`${x.mode} · ${x.count} conversation(s)${x.files ? ` · ${x.files} fichiers` : ''}${x.dry ? ' · essai' : ''}`), link(x.url, x.pr || 'PR'), x.url ? T(x.merged ? 'mergée' : 'pas mergée', { tone: x.merged ? 'ok' : 'warn' }) : null]),
118 T(`masqués : ${x.masked}`, { dim: true }),
119 x.held.length ? T(`retenues : ${x.held.join(' ; ')}`, { tone: 'warn' }) : null,
120 x.error ? T(x.error, { tone: 'bad' }) : null,
121 ], { marginBottom: 1 })) : [T('aucun export', { dim: true })])
122 }
123 return col([head, body])
124}
125
126function viewModel() {
127 const cfg = D.cfg || {}
128 const sessions = (D.sessions && D.sessions.sessions) || []
129 return {
130 configured: !!((cfg.eta || {}).repo),
131 batch: batchView(D.pending, cfg),
132 ongoing: ongoing(sessions),
133 groups: selection(sessions, D.selected, D.open, (cfg.pane || {}).max_rows || 40),
134 history: historyRows(D.state, (cfg.pane || {}).history || 10),
135 }
136}
137
138// /export-eta brief [enfants]: the brief for another AI of THIS conversation (exporter/brief.mjs), file + clipboard,
139// no pane, no PR. The band's buttons run the same script directly (no wait for the turn to end).
140async function exportBrief($, children) {
141 const node = (D.cfg && D.cfg.node) || 'node'
142 let session = ''
143 try { session = await $.session.id() } catch { /* no session */ }
144 try { $.ui.toast(children ? 'Brief (avec enfants) en cours…' : 'Brief en cours…') } catch { /* no toast */ }
145 try {
146 const r = await $.process.run([node, root($) + '/exporter/brief.mjs', 'run', '--session', session, ...(children ? ['--children'] : [])], { timeoutMs: 300000 })
147 $.ui.toast(((r.stdout || r.stderr || '').trim().split('\n').pop() || `exit ${r.exitCode}`).slice(0, 300))
148 } catch (err) {
149 $.ui.toast(`Brief échoué : ${String(err && err.message ? err.message : err).slice(0, 200)}`)
150 }
151}
152
153async function onCommand($, e) {
154 await load($)
155 const v = String(e.args || '').trim().toLowerCase()
156 if (v === 'close') { await $.ui.close({ id: PANE }); return {} }
157 if (v === 'brief' || v === 'brief enfants' || v === 'brief children') { void exportBrief($, v !== 'brief'); return {} }
158 if (v === 'tout' || v === 'all') { void exportTout($); return {} }
159 await $.ui.open({ id: PANE, title: (D.cfg.labels || {}).pane_title || 'Export eta', focus: true, closeOnEscape: true })
160 if (v === 'en-cours' || v === 'ongoing') { D.tab = 'history'; void runMode($, 'ongoing') }
161 else if (v === 'lot' || v === 'batch') { D.tab = 'history'; void runMode($, 'batch') }
162 else void refresh($)
163 $.ui.invalidate('ui.render')
164 return {}
165}
166
167export function register(on) {
168 on('session.start', async ($, e, next) => {
169 const r = await next(e)
170 await load($).catch(() => undefined)
171 for (const name of ['export-eta', 'export']) {
172 try {
173 await $.command.register({
174 name, immediate: true,
175 description: 'Export eta : exporte les conversations (en cours, sélection, lot terminé) en Markdown masqué vers le dépôt eta, par PR',
176 argumentHint: 'en-cours | lot | tout | brief [enfants] | close',
177 })
178 } catch { /* /export may clash with a built-in: /export-eta stays */ }
179 }
180 try {
181 await $.command.register({
182 name: 'export-tout', immediate: true,
183 description: 'Exporter tout : snapshot daté (conversations, statut, workflows, CI, conso, problèmes, veille, à faire, règles) vers le dépôt eta, une PR mergée, sans tokens',
184 })
185 } catch { /* already registered */ }
186 // The batch notice: pending.json is refreshed by the Veille watcher; if nobody did for a while, refresh it here.
187 const every = Math.max(60, ((D.cfg && D.cfg.pane) || {}).refresh_s || 300) * 1000
188 $.clock.every(every, async () => {
189 await load($).catch(() => undefined)
190 if (!D.busy && (!D.pending || D.at - (D.pending.generated || 0) > every * 2)) await act($, 'pending', ['pending'], 120000)
191 $.ui.invalidate('ui.render')
192 })
193 return r
194 })
195
196 on('command.run', { command: 'export-eta' }, onCommand).catch(() => ({}))
197 on('command.run', { command: 'export' }, onCommand).catch(() => ({}))
198 on('command.run', { command: 'export-tout' }, async ($) => { await load($).catch(() => undefined); void exportTout($); return {} }).catch(() => ({}))
199
200 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
201 if (e.requestId !== PANE) return next(e)
202 const el = $.ui.resolve(e)
203 if (!D.cfg) await load($).catch(() => { D.cfg = {} })
204 return draw(el, viewModel(), {
205 tab: (id) => { D.tab = id; $.ui.invalidate('ui.render') },
206 batch: () => { D.tab = 'history'; void runMode($, 'batch') },
207 ongoing: () => { D.tab = 'history'; void runMode($, 'ongoing') },
208 selection: () => { const ids = [...D.selected]; D.selected.clear(); D.tab = 'history'; void runMode($, 'selection', ids) },
209 clear: () => { D.selected.clear(); $.ui.invalidate('ui.render') },
210 toggle: (cli) => { D.selected.has(cli) ? D.selected.delete(cli) : D.selected.add(cli); $.ui.invalidate('ui.render') },
211 fold: (p) => { D.open.has(p) ? D.open.delete(p) : D.open.add(p); $.ui.invalidate('ui.render') },
212 refresh: () => void refresh($),
213 })
214 })
215
216 // Above the prompt: « 4 conversations terminées — Exporter vers eta » with its button, only while a batch is ready.
217 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
218 const theirs = await next(e)
219 if ((e.props && e.props.hasSurvey) || !D.cfg) return theirs
220 const line = bandLine(D.pending, D.cfg, D.dismissed)
221 if (!line && !D.busy) return theirs
222 const el = $.ui.resolve(e)
223 const C = D.cfg.colors || {}
224 const L = D.cfg.labels || {}
225 const mine = D.busy
226 ? el.Text({ children: [`${L.pane_title || 'Export eta'} : ${L.busy || 'export en cours…'}`], dimColor: true })
227 : el.Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, children: [
228 el.Text({ children: [line.text], ...(C.warn ? { color: C.warn } : {}) }),
229 el.Button({ key: 'band-export', children: [L.export_batch || 'Exporter vers eta'], variant: 'primary', onPress: () => void runMode($, 'batch') }),
230 el.Button({ key: 'band-dismiss', children: ['✕'], plain: true, onPress: () => { D.dismissed = line.key; $.ui.invalidate('ui.render') } }),
231 ] })
232 return theirs ? el.Box({ flexDirection: 'column', children: [theirs, mine] }) : mine
233 }).catch(($, e, next) => next(e))
234}
235hooks/view.mjs 70 lines1// export-eta - view model of the pane and of the line above the prompt. Pure (no $, no I/O), tested with node --test.
2// Inputs: the merged config, <home>/sessions.json, pending.json and state.json (written by exporter/export.mjs).
3import { byProject, fill } from '../lib/layout.mjs'
4
5export function ago(ms) {
6 const m = Math.max(0, Math.round(ms / 60000))
7 if (m < 60) return `${m} min`
8 const h = Math.floor(m / 60)
9 return h < 48 ? `${h} h` : `${Math.floor(h / 24)} j`
10}
11
12export function hm(ms) {
13 if (!ms) return ''
14 const d = new Date(ms)
15 const p = (x) => String(x).padStart(2, '0')
16 return `${p(d.getDate())}/${p(d.getMonth() + 1)} ${p(d.getHours())}:${p(d.getMinutes())}`
17}
18
19export const maskedText = (m) => {
20 const e = Object.entries(m || {})
21 return e.length ? e.map(([k, v]) => `${k} ${v}`).join(', ') : '0'
22}
23
24/** History rows: date, count, PR link, merged, masked counts, held-back files, error. */
25export function historyRows(state, n = 10) {
26 return ((state && state.history) || []).slice(0, n).map((h) => ({
27 at: h.at, when: hm(h.at), mode: h.mode, count: h.count || 0, url: h.url || '', pr: h.pr ? `#${h.pr}` : '',
28 merged: !!h.merged, files: h.files_count || 0, snapshot: h.snapshot || '', masked: maskedText(h.masked), held: (h.held || []).map((x) => x.title || x.cli), error: h.error || '', dry: !!h.dry,
29 tone: h.error && !h.merged ? 'bad' : h.held && h.held.length ? 'warn' : 'ok',
30 }))
31}
32
33/** Ongoing conversations (snapshot list). */
34export function ongoing(sessions) {
35 return (sessions || []).filter((s) => s.state === 'ongoing' || s.state === 'live')
36}
37
38/** Finished conversations grouped by project for the checkbox list; `selected` and `open` are Sets. */
39export function selection(sessions, selected, open, max = 40) {
40 const fin = (sessions || []).filter((s) => s.state === 'finished')
41 return byProject(fin).map((g) => ({
42 project: g.project, open: open.has(g.project), count: g.list.length,
43 picked: g.list.filter((s) => selected.has(s.cli)).length,
44 rows: g.list.slice(0, max).map((s) => ({ cli: s.cli, title: s.title || s.cli.slice(0, 8), when: hm(s.last_activity), checked: selected.has(s.cli), exported: !!s.exported_at })),
45 more: Math.max(0, g.list.length - max),
46 }))
47}
48
49/** The batch tab and the band line. */
50export function batchView(pending, cfg) {
51 const L = cfg.labels || {}
52 if (!pending) return { ready: false, text: '', none: fill(L.batch_none, { n: 0, min: cfg.batch_min ?? 3 }), titles: [] }
53 return {
54 ready: !!pending.ready, count: pending.count || 0, auto: !!pending.auto,
55 text: pending.ready ? pending.text || fill(L.batch_notice, { n: pending.count }) : '',
56 none: fill(L.batch_none, { n: pending.count || 0, min: cfg.batch_min ?? 3 }),
57 reason: pending.reason === 'lead' ? 'un lead et toutes ses sous-conversations sont terminés' : pending.reason === 'count' ? `seuil ${cfg.batch_min ?? 3} atteint` : '',
58 titles: (pending.titles || []).map((t) => `${t.project} · ${t.title || t.cli.slice(0, 8)}`),
59 }
60}
61
62/** The line above the prompt: only when a batch is ready and not dismissed for this batch. */
63export function bandLine(pending, cfg, dismissedKey) {
64 const b = batchView(pending, cfg)
65 if (!b.ready) return null
66 const key = (pending.ids || []).slice().sort().join(',')
67 if (key && key === dismissedKey) return null
68 return { text: b.text, key }
69}
70lib/layout.mjs 140 lines1// export-eta - pure helpers: config merge, session states, paths in the eta repo, index pages, batch detection.
2// No I/O here; exporter/export.mjs does the reading, writing and git.
3
4export function deepMerge(a, b) {
5 if (!b || typeof b !== 'object' || Array.isArray(b)) return b === undefined ? a : b
6 const out = { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}) }
7 for (const [k, v] of Object.entries(b)) out[k] = v && typeof v === 'object' && !Array.isArray(v) ? deepMerge(out[k], v) : v
8 return out
9}
10
11export function fill(s, vars = {}) {
12 return String(s ?? '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] !== undefined ? String(vars[k]) : m))
13}
14
15/** Project of a session: the repo folder name (worktrees under .claude/worktrees count as their repo). */
16export function projectOf(s) {
17 const p = String(s.origin || s.cwd || '').replace(/\\/g, '/').replace(/\/+$/, '')
18 const repo = p.replace(/\/\.claude\/worktrees\/[^/]+.*$/, '').replace(/\/\.worktrees\/[^/]+.*$/, '')
19 return slug(repo.split('/').pop() || 'sans-projet', 60) || 'sans-projet'
20}
21
22export function slug(s, n = 60) {
23 return String(s ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
24 .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, n).replace(/-+$/, '')
25}
26
27/**
28 * State of a session: live (transcript written in the last live_s), finished (archived, or the app's post-turn
29 * summary says one of finished_categories and it is quiet), ongoing (the rest, active in the last ongoing_hours), old.
30 */
31export function stateOf(s, cfg, nowMs) {
32 const live = s.mtime && nowMs - s.mtime < (cfg.live_s ?? 180) * 1000
33 if (live) return 'live'
34 if (s.archived) return 'finished'
35 if ((cfg.finished_categories || ['completed']).includes(s.status_category)) {
36 if (nowMs - (s.last_activity || 0) >= (cfg.finished_quiet_min ?? 5) * 60000) return 'finished'
37 }
38 if (nowMs - (s.last_activity || 0) <= (cfg.ongoing_hours ?? 24) * 3600000) return 'ongoing'
39 return 'old'
40}
41
42export const STATE_LABEL = { live: 'en cours (active)', ongoing: 'en cours', finished: 'terminée', old: 'ancienne' }
43
44/** File name of a session: <YYYY-MM-DD of start>-<title slug>-<first 8 of the session id>.md. */
45export function fileName(s, day) {
46 const id = String(s.cli || '').slice(0, 8)
47 return `${day || 'sans-date'}-${slug(s.title || 'conversation', 50) || 'conversation'}-${id}.md`
48}
49
50/** Path of a session in the eta repo: dest pattern with {project} and {file}. An existing file with the same id wins. */
51export function pathOf(s, cfg, day, existing = []) {
52 const dir = fill(cfg.dest_dir || 'sources/conversations/{project}', { project: s.project || projectOf(s) }).replace(/\/+$/, '')
53 const id = String(s.cli || '').slice(0, 8)
54 const hit = existing.find((f) => f.startsWith(dir + '/') && !f.slice(dir.length + 1).includes('/') && new RegExp(`-${id}\\.md$`).test(f))
55 return hit || `${dir}/${fileName(s, day)}`
56}
57
58/** Part file names: a.md → a.md, a.part2.md, a.part3.md … */
59export function partPath(path, i) { return i ? path.replace(/\.md$/, `.part${i + 1}.md`) : path }
60
61/** Parses the front matter a rendered file starts with. */
62export function frontMatter(md) {
63 const m = String(md || '').match(/^---\n([\s\S]*?)\n---/)
64 if (!m) return null
65 const out = {}
66 for (const line of m[1].split('\n')) {
67 const k = line.match(/^(\w+):\s*(.*)$/)
68 if (!k) continue
69 try { out[k[1]] = JSON.parse(k[2]) } catch { out[k[1]] = k[2] }
70 }
71 return out
72}
73
74const cell = (s) => String(s ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')
75
76/** INDEX.md of one project folder. entries: [{ file, fm }] (file relative to the folder). */
77export function projectIndex(project, entries, L = {}) {
78 const rows = entries.filter((e) => e.fm).sort((a, b) => String(b.fm.last_activity).localeCompare(String(a.fm.last_activity)))
79 return `# ${L.project_index || 'Conversations'} : ${project}\n\n${L.index_note || 'Généré par le mod export-eta ; ne pas éditer à la main (réécrit à chaque export).'}\n\n` +
80 `| ${L.col_conv || 'Conversation'} | ${L.col_state || 'État'} | ${L.col_last || 'Dernière activité'} | ${L.col_msg || 'Dernier message'} |\n|---|---|---|---|\n` +
81 rows.map((e) => `| [${cell(e.fm.title || e.file)}](${encodeURI(e.file)}) | ${cell(e.fm.state)} | ${cell(e.fm.last_activity)} | ${cell(e.fm.last_message)} |`).join('\n') + '\n'
82}
83
84/** Global INDEX.md: one row per project, then the latest conversations across projects. */
85export function globalIndex(projects, L = {}, latestN = 30) {
86 const all = projects.flatMap((p) => p.entries.filter((e) => e.fm).map((e) => ({ ...e, project: p.project })))
87 all.sort((a, b) => String(b.fm.last_activity).localeCompare(String(a.fm.last_activity)))
88 const per = projects.filter((p) => p.entries.length).sort((a, b) => a.project.localeCompare(b.project))
89 return `# ${L.global_index || 'Conversations exportées'}\n\n${L.index_note || 'Généré par le mod export-eta ; ne pas éditer à la main (réécrit à chaque export).'}\n\n` +
90 `| ${L.col_project || 'Projet'} | ${L.col_count || 'Conversations'} | ${L.col_last || 'Dernière activité'} |\n|---|---|---|\n` +
91 per.map((p) => {
92 const last = p.entries.map((e) => (e.fm && e.fm.last_activity) || '').sort().pop() || ''
93 return `| [${cell(p.project)}](${encodeURI(p.project)}/INDEX.md) | ${p.entries.filter((e) => e.fm).length} | ${cell(last)} |`
94 }).join('\n') +
95 `\n\n## ${L.latest || 'Dernières conversations'}\n\n| ${L.col_conv || 'Conversation'} | ${L.col_project || 'Projet'} | ${L.col_state || 'État'} | ${L.col_last || 'Dernière activité'} | ${L.col_msg || 'Dernier message'} |\n|---|---|---|---|---|\n` +
96 all.slice(0, latestN).map((e) => `| [${cell(e.fm.title || e.file)}](${encodeURI(e.project + '/' + e.file)}) | ${cell(e.project)} | ${cell(e.fm.state)} | ${cell(e.fm.last_activity)} | ${cell(e.fm.last_message)} |`).join('\n') + '\n'
97}
98
99/**
100 * The batch waiting for an export: sessions finished since `since` (state.since, set at the first run so old history
101 * is not proposed) and not exported at their current last activity. Ready when ≥ batch_min of them, or when a lead and
102 * all its sub-conversations (app spawnedFrom links) are finished with at least one of them pending.
103 * Returns { ready, reason, ids, count, leads }.
104 */
105export function batchOf(sessions, state, cfg, nowMs) {
106 const since = state.since || 0
107 const exported = state.exported || {}
108 const byId = new Map(sessions.map((s) => [s.sid, s]))
109 const fin = (s) => stateOf(s, cfg, nowMs) === 'finished'
110 const pending = sessions.filter((s) => fin(s) && (s.last_activity || 0) > since && !(cfg.exclude || []).includes(s.cli) &&
111 !(exported[s.cli] && (exported[s.cli].last_activity || 0) >= (s.last_activity || 0)))
112 const pendingIds = new Set(pending.map((s) => s.cli))
113 const kids = {}
114 for (const s of sessions) if (s.parent && byId.has(s.parent)) (kids[s.parent] = kids[s.parent] || []).push(s)
115 const leads = []
116 for (const [sid, ch] of Object.entries(kids)) {
117 const lead = byId.get(sid)
118 const family = [lead, ...ch]
119 if (family.every(fin) && family.some((x) => pendingIds.has(x.cli))) leads.push({ cli: lead.cli, title: lead.title, ids: family.map((x) => x.cli) })
120 }
121 const min = cfg.batch_min ?? 3
122 const ids = [...new Set([...pending.map((s) => s.cli), ...leads.flatMap((l) => l.ids.filter((id) => pendingIds.has(id) || !exported[id]))])]
123 const reason = pending.length >= min ? 'count' : leads.length ? 'lead' : ''
124 return { ready: !!reason && ids.length > 0, reason, ids, count: ids.length, leads }
125}
126
127/** The one line shown in band, Veille and the pane for a ready batch. */
128export function batchText(batch, L = {}) {
129 if (!batch || !batch.ready) return ''
130 return fill(L.batch_notice || '{n} conversations terminées — Exporter vers eta', { n: batch.count })
131}
132
133/** Groups sessions by project for the selection list, newest first. */
134export function byProject(sessions) {
135 const g = {}
136 for (const s of sessions) (g[s.project] = g[s.project] || []).push(s)
137 return Object.entries(g).map(([project, list]) => ({ project, list: list.sort((a, b) => (b.last_activity || 0) - (a.last_activity || 0)) }))
138 .sort((a, b) => (b.list[0].last_activity || 0) - (a.list[0].last_activity || 0))
139}
140