honestweek 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/README.md +252 -197
- package/SKILL.md +25 -74
- package/bin/honestweek.mjs +81 -24
- package/flows/client.md +19 -0
- package/flows/digest.md +11 -0
- package/flows/mine.md +18 -0
- package/flows/view.md +45 -0
- package/flows/weekly.md +29 -0
- package/lib/ask.mjs +1142 -0
- package/lib/build.mjs +12 -3
- package/lib/config-lookup.mjs +179 -0
- package/lib/config.mjs +20 -0
- package/lib/demo/week.mjs +3 -0
- package/lib/digest-carry.mjs +3 -2
- package/lib/digest-store.mjs +3 -2
- package/lib/digest.mjs +21 -14
- package/lib/discover.mjs +28 -22
- package/lib/emit/index.mjs +18 -6
- package/lib/harvest.mjs +19 -1
- package/lib/history.mjs +10 -2
- package/lib/init.mjs +241 -55
- package/lib/mine.mjs +18 -8
- package/lib/preview.mjs +66 -13
- package/lib/private-words.mjs +25 -5
- package/lib/problems/index.mjs +60 -7
- package/lib/prompt-lane.mjs +14 -13
- package/lib/prompt-store.mjs +2 -1
- package/lib/prompts.mjs +13 -7
- package/lib/replay/assemble.mjs +25 -8
- package/lib/replay/index.mjs +200 -12
- package/lib/replay/lookup.mjs +6 -3
- package/lib/replay/saved-sessions.mjs +315 -0
- package/lib/replay/views.mjs +12 -1
- package/lib/{view → replay}/word-index.mjs +3 -3
- package/lib/replay/words.mjs +122 -0
- package/lib/repo-identity.mjs +81 -13
- package/lib/saved/checks.mjs +381 -0
- package/lib/saved/history.mjs +235 -0
- package/lib/saved/saver.mjs +111 -0
- package/lib/saved/store.mjs +256 -0
- package/lib/status.mjs +288 -0
- package/lib/validate.mjs +12 -3
- package/lib/view/assets/common.css +2 -0
- package/lib/view/assets/common.js +20 -2
- package/lib/view/assets/problems.js +4 -3
- package/lib/view/assets/replay.js +3 -1
- package/lib/view/assets/search.js +1 -1
- package/lib/view/assets/sessions.js +1 -0
- package/lib/view/assets/settings.html +16 -0
- package/lib/view/assets/settings.js +108 -4
- package/lib/view/assets/setup.html +7 -0
- package/lib/view/assets/setup.js +9 -0
- package/lib/view/codex-judge.mjs +1 -1
- package/lib/view/data.mjs +160 -76
- package/lib/view/own-week.mjs +119 -0
- package/lib/view/page-link.mjs +84 -0
- package/lib/view/problems-route.mjs +29 -3
- package/lib/view/replay-export.mjs +1 -1
- package/lib/view/selftest/clickthrough.js +9 -5
- package/lib/view/server.mjs +3 -1
- package/lib/view/settings.mjs +102 -34
- package/lib/view/setup.mjs +27 -14
- package/lib/view/suggest-words.mjs +67 -0
- package/lib/view.mjs +82 -133
- package/lib/worktrees.mjs +31 -18
- package/package.json +2 -1
package/lib/replay/index.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { AGENT_ADDRESS_RE } from '../redaction-patterns.mjs';
|
|
|
20
20
|
import { attributionRoots } from '../worktrees.mjs';
|
|
21
21
|
import { localDateRangeInstants } from '../resolve-week.mjs';
|
|
22
22
|
import { hostTimezone } from '../config.mjs';
|
|
23
|
-
import { assemble, compareEvents } from './assemble.mjs';
|
|
23
|
+
import { assemble, compareEvents, threadsOf } from './assemble.mjs';
|
|
24
24
|
import { parseClaudeSource } from './claude.mjs';
|
|
25
25
|
import { codexOrigin, parseCodexSource } from './codex.mjs';
|
|
26
26
|
import { isBuiltinToolName, LOOKUP_RULES, RULES, UPDATE_RULES } from './classify.mjs';
|
|
@@ -35,6 +35,7 @@ import { collapse, createWatch, LIMITS, redactClip, redactThenCut } from './pars
|
|
|
35
35
|
import { defaultRoots, enumerateClaudeSources, enumerateCodexSources } from './sources.mjs';
|
|
36
36
|
import { buildTimeline } from './timeline.mjs';
|
|
37
37
|
import { createViews } from './views.mjs';
|
|
38
|
+
import { exportSession, fileFingerprint, honestweekVersion, idHash, labelHash, logIdHash, redactionPrint, restoreSaved, SAVED_SESSION_SCHEMA, savedAsParsed, tokenHash } from './saved-sessions.mjs';
|
|
38
39
|
|
|
39
40
|
export { parseLookup } from './lookup.mjs';
|
|
40
41
|
|
|
@@ -49,8 +50,9 @@ export const HISTORY_SCHEMA_VERSION = 1;
|
|
|
49
50
|
* scope 'configured' (default) or 'all'
|
|
50
51
|
* quietMs shortest gap reported as a quiet interval (default 20 minutes)
|
|
51
52
|
* git false skips the git outcome lookups (tests of parsing alone)
|
|
52
|
-
* signal an optional { aborted } checked between files
|
|
53
|
-
* aborted, the build stops with an
|
|
53
|
+
* signal an optional { aborted } checked between files, before any git read, and once
|
|
54
|
+
* more after reading Codex's thread names: once aborted, the build stops with an
|
|
55
|
+
* error at the next check and runs no more git (none at all if it hadn't begun)
|
|
54
56
|
* goals an optional goal record, { goals: [{ id, title, state?, source?,
|
|
55
57
|
* observations?, results?, decisions? }], events?: [{ eventId, goalId,
|
|
56
58
|
* type, at }] }. With it, the history gains `goals` (which sessions did
|
|
@@ -101,8 +103,23 @@ export const HISTORY_SCHEMA_VERSION = 1;
|
|
|
101
103
|
* sessions get none, and spans, quiet intervals and end states read as without
|
|
102
104
|
* it. Only `honestweek view` asks for it, for its own screen. Without it the
|
|
103
105
|
* history doesn't change.
|
|
106
|
+
* saved sessions an earlier run saved (lib/replay/saved-sessions.mjs), each with
|
|
107
|
+
* `savedAt`, `version`, `redaction` (the private-word settings' fingerprint) and
|
|
108
|
+
* `log` ('gone' when none of its log files is on disk). One saved by this version
|
|
109
|
+
* with this config's private words would come out of the redactor unchanged, so it
|
|
110
|
+
* isn't redacted again; any other is:
|
|
111
|
+
* each one the build doesn't read from a log joins the history as if it had, with
|
|
112
|
+
* `saved: { at, by, log }` on its session record. Its steps can't be checked against
|
|
113
|
+
* their log lines. Without it the history doesn't change.
|
|
114
|
+
* saving true for a redacted build whose sessions will be saved: the history gains
|
|
115
|
+
* exportSession(key), not enumerable, so no output changes.
|
|
116
|
+
* reuse keys of sessions in `saved` whose logs are on disk and unchanged since they were
|
|
117
|
+
* saved: each comes back from `saved` instead of being read again, its steps still
|
|
118
|
+
* checked against its log lines. One that may have launched a session this build
|
|
119
|
+
* reads (it was running in the two minutes before) is read after all, since the
|
|
120
|
+
* launch is matched by a command's text, which isn't saved.
|
|
104
121
|
*/
|
|
105
|
-
export async function buildWorkHistory({ config, from, to, timezone, roots, scope = 'configured', quietMs, git = true, goals: goalInput, privateText = false, hiddenSessions = 'skeleton', usage = false, keepRaw = false, updates = false, signal = null, onProgress = null } = {}) {
|
|
122
|
+
export async function buildWorkHistory({ config, from, to, timezone, roots, scope = 'configured', quietMs, git = true, goals: goalInput, privateText = false, hiddenSessions = 'skeleton', usage = false, keepRaw = false, updates = false, signal = null, onProgress = null, saved = null, saving = false, reuse = null } = {}) {
|
|
106
123
|
if (!config?.identity) throw new Error('replay: a normalized config is required.');
|
|
107
124
|
if (hiddenSessions !== 'skeleton' && hiddenSessions !== 'redacted') throw new Error(`replay: hiddenSessions is 'skeleton' or 'redacted', not ${JSON.stringify(hiddenSessions)}.`);
|
|
108
125
|
// Private sessions keep their content: on this screen only (privateText), or redacted.
|
|
@@ -147,7 +164,42 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
|
|
|
147
164
|
// One file reachable through two roots (overlapping or repeated roots) is one source.
|
|
148
165
|
const seenKeys = new Set();
|
|
149
166
|
const all = [...claude.sources, ...codex.sources].filter((s) => !seenKeys.has(s.key) && seenKeys.add(s.key));
|
|
167
|
+
// Saved sessions (lib/replay/saved-sessions.mjs): each one this build doesn't read from a log
|
|
168
|
+
// comes back with its strings redacted again with this build's settings; its step facts pass
|
|
169
|
+
// the backstop below with everything else.
|
|
170
|
+
let savedList = [];
|
|
171
|
+
// Sessions given back unchanged rather than read again: only ones `saved` holds.
|
|
172
|
+
// A session with a log file its saved copy doesn't name (a sub-agent or child thread that came
|
|
173
|
+
// later) has changed, and is read.
|
|
174
|
+
const reusable = new Set();
|
|
175
|
+
// A build that shows private text reads every log: a saved copy is redacted.
|
|
176
|
+
if (Array.isArray(saved) && reuse && privateText !== true) {
|
|
177
|
+
const wanted = new Set(reuse);
|
|
178
|
+
const onDisk = new Map();
|
|
179
|
+
for (const s of all) onDisk.set(s.sessionKey, [...(onDisk.get(s.sessionKey) ?? []), s.key]);
|
|
180
|
+
for (const x of saved) {
|
|
181
|
+
if (!x || typeof x.key !== 'string' || !wanted.has(x.key) || !Array.isArray(x.sources)) continue;
|
|
182
|
+
const savedKeys = new Set(x.sources.map((s) => s.key));
|
|
183
|
+
if ((onDisk.get(x.key) ?? []).every((k) => savedKeys.has(k))) reusable.add(x.key);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (Array.isArray(saved) && saved.length) {
|
|
187
|
+
const readKeys = new Set(all.filter((s) => !reusable.has(s.sessionKey)).map((s) => s.sessionKey));
|
|
188
|
+
const print = redactionPrint(config);
|
|
189
|
+
const sameRedaction = (x) => x.version === honestweekVersion() && typeof x.redaction === 'string' && x.redaction === print;
|
|
190
|
+
const deepSaved = (x) => ({ ...deep({ ...x, events: [] }), events: x.events.map((e) => ({ ...deep({ ...e, facts: null }), facts: e.facts ?? {} })) });
|
|
191
|
+
for (const x of saved) {
|
|
192
|
+
if (x?.schema !== SAVED_SESSION_SCHEMA || typeof x.key !== 'string' || !Array.isArray(x.events) || !Array.isArray(x.sources) || readKeys.has(x.key) || savedList.some((y) => y.key === x.key)) continue;
|
|
193
|
+
// One saved by this version with these private words would come out of the redactor the same,
|
|
194
|
+
// so it isn't passed through it again; any other is.
|
|
195
|
+
savedList.push(sameRedaction(x) ? { ...x, redacted: true } : { ...deepSaved(x), redacted: false });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// With saved sessions in the build, ids two sessions share are compared as the hashes saved.
|
|
199
|
+
let hashIds = savedList.length > 0;
|
|
200
|
+
const repoOfSaved = (x) => (x.repo?.label ? config.repos.find((c) => labelHash(c.label) === x.repo.label) ?? null : null);
|
|
150
201
|
const sessionRepo = new Map();
|
|
202
|
+
for (const x of savedList) sessionRepo.set(x.key, repoOfSaved(x));
|
|
151
203
|
for (const s of all) {
|
|
152
204
|
if (s.role !== 'session') continue;
|
|
153
205
|
sessionRepo.set(s.sessionKey, matchRepo(s.cwd, config));
|
|
@@ -161,8 +213,12 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
|
|
|
161
213
|
return true;
|
|
162
214
|
});
|
|
163
215
|
keep.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
216
|
+
// The sources of sessions given back unchanged are set aside, unread; their files stay known, so
|
|
217
|
+
// a step is still checked against its log line.
|
|
218
|
+
const setAside = keep.filter((s) => reusable.has(s.sessionKey));
|
|
219
|
+
if (setAside.length) for (let i = keep.length - 1; i >= 0; i -= 1) if (reusable.has(keep[i].sessionKey)) keep.splice(i, 1);
|
|
164
220
|
|
|
165
|
-
const files = new Map(keep.map((s) => [s.key, s.file]));
|
|
221
|
+
const files = new Map([...keep, ...setAside].map((s) => [s.key, s.file]));
|
|
166
222
|
const privateSources = new Set();
|
|
167
223
|
// Sources whose content is cut to a skeleton: the private ones, unless privateText or
|
|
168
224
|
// hiddenSessions 'redacted' keeps it.
|
|
@@ -178,7 +234,11 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
|
|
|
178
234
|
}
|
|
179
235
|
};
|
|
180
236
|
progress(0);
|
|
181
|
-
|
|
237
|
+
// Each log file's fingerprint as it was before this build read it, for a session to be saved
|
|
238
|
+
// with: one that grows while the build runs then reads as changed next time, not as saved.
|
|
239
|
+
const readPrints = new Map();
|
|
240
|
+
const readSource = async (s) => {
|
|
241
|
+
if (saving === true) readPrints.set(s.file, fileFingerprint(s.file));
|
|
182
242
|
const repo = sessionRepo.get(s.sessionKey) ?? null;
|
|
183
243
|
const isPrivate = !repo || repo.role === 'display';
|
|
184
244
|
if (isPrivate) privateSources.add(s.key);
|
|
@@ -207,12 +267,87 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
|
|
|
207
267
|
}
|
|
208
268
|
parsed.push({ source: s, ctx, result });
|
|
209
269
|
progress(parsed.length);
|
|
270
|
+
};
|
|
271
|
+
for (const s of keep) {
|
|
272
|
+
await readSource(s);
|
|
210
273
|
if (signal?.aborted) break;
|
|
211
274
|
}
|
|
212
|
-
//
|
|
275
|
+
// A session a program started is matched to the step that launched it by that step's command,
|
|
276
|
+
// which a saved session doesn't keep. A session given back unchanged that was running in the two
|
|
277
|
+
// minutes before one this build read began is read from its log after all.
|
|
278
|
+
if (setAside.length && !signal?.aborted) {
|
|
279
|
+
const starts = parsed.filter((q) => q.source.role === 'session' && q.result.joins?.programStart).map((q) => Date.parse(q.result.stats.firstAt ?? '')).filter(Number.isFinite);
|
|
280
|
+
const pull = new Set();
|
|
281
|
+
for (const x of savedList) {
|
|
282
|
+
if (!reusable.has(x.key)) continue;
|
|
283
|
+
const first = Date.parse(x.record?.firstAt ?? '');
|
|
284
|
+
const last = Date.parse(x.record?.lastAt ?? x.record?.firstAt ?? '');
|
|
285
|
+
if (starts.some((t0) => Number.isFinite(first) && first <= t0 + LAUNCH_SLACK_MS && (Number.isFinite(last) ? last : first) >= t0 - LAUNCH_LOOKBACK_MS)) pull.add(x.key);
|
|
286
|
+
}
|
|
287
|
+
for (const s of setAside) if (pull.has(s.sessionKey)) await readSource(s);
|
|
288
|
+
if (pull.size) {
|
|
289
|
+
savedList = savedList.filter((x) => !pull.has(x.key));
|
|
290
|
+
// With none left, the shared ids aren't hashed after all, and the sessions saved from this
|
|
291
|
+
// build say so.
|
|
292
|
+
hashIds = savedList.length > 0;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (savedList.length) {
|
|
296
|
+
const savedSlugs = new Map();
|
|
297
|
+
// The goal ids and goal-event ids a saved session's text named, by their hashes.
|
|
298
|
+
const tokens = new Map([...(goalRecord?.entries ?? []).map((x) => x.eventId), ...(goalRecord?.goals ?? []).map((g) => g.id)].map((x) => [tokenHash(x), x]));
|
|
299
|
+
for (const x of savedList) {
|
|
300
|
+
const repo = sessionRepo.get(x.key) ?? null;
|
|
301
|
+
if (!repo && scope !== 'all') {
|
|
302
|
+
skipped.outsideConfiguredRepos += 1;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const isPrivate = !repo || repo.role === 'display';
|
|
306
|
+
const entries = savedAsParsed(x, {
|
|
307
|
+
resolveToken: (h) => (isPrivate ? null : tokens.get(h) ?? null),
|
|
308
|
+
// The session's repository's name on GitHub, read from git only for one git may read.
|
|
309
|
+
// Asked once per repository in a build, however many saved sessions name its pull requests.
|
|
310
|
+
repoSlug: () => {
|
|
311
|
+
if (!git || !repo || repo.role === 'display') return null;
|
|
312
|
+
if (!savedSlugs.has(repo.label)) {
|
|
313
|
+
let slug = null;
|
|
314
|
+
try {
|
|
315
|
+
slug = originSlug(repo.resolvedPath ?? repo.path);
|
|
316
|
+
} catch {
|
|
317
|
+
slug = null;
|
|
318
|
+
}
|
|
319
|
+
savedSlugs.set(repo.label, slug);
|
|
320
|
+
}
|
|
321
|
+
return savedSlugs.get(repo.label);
|
|
322
|
+
},
|
|
323
|
+
ctxOf: (source, s) => ({ redact, isPrivate, privateText: privateText === true, hiddenSessions, usage: usage === true, keepRaw: false, updates: updates === true, cwd: null, repo: repo ? { label: repo.label, role: repo.role, path: repo.resolvedPath ?? repo.path } : null, agentKey: s.agent ?? (source.role === 'session' ? `${source.key}:main` : source.key), sidechainAgentKey: null, parentAgentKey: null, watch: null }),
|
|
324
|
+
});
|
|
325
|
+
for (const e of entries) {
|
|
326
|
+
if (isPrivate) privateSources.add(e.source.key);
|
|
327
|
+
if (x.log !== 'gone' && files.has(e.source.key)) e.source.file = files.get(e.source.key);
|
|
328
|
+
parsed.push(e);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// In the order a build that read every one of them would have.
|
|
332
|
+
parsed.sort((x, y) => (x.source.key < y.source.key ? -1 : x.source.key > y.source.key ? 1 : 0));
|
|
333
|
+
for (const q of parsed) {
|
|
334
|
+
if (q.source.saved) continue;
|
|
335
|
+
const j = q.result.joins ?? {};
|
|
336
|
+
for (const u of j.uuids ?? []) u.uuid = idHash(u.uuid);
|
|
337
|
+
for (const m of [...(j.sentMessages ?? []), ...(j.receivedMessages ?? [])]) m.msgId = idHash(m.msgId);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// A stop lands here, before git is read, and once more after reading Codex's thread names,
|
|
341
|
+
// the one pause left: everything after that runs without one.
|
|
213
342
|
if (signal?.aborted) throw Object.assign(new Error('replay: the build was stopped.'), { code: 'ABORTED' });
|
|
214
343
|
|
|
215
344
|
const a = assemble({ parsed, quietMs });
|
|
345
|
+
if (savedList.length) {
|
|
346
|
+
restoreSaved(a, savedList.filter((x) => a.sessionsByKey.has(x.key)));
|
|
347
|
+
// A link between sessions that came back as saved can join threads assembly kept apart.
|
|
348
|
+
a.threads = threadsOf(a.sessionsByKey, a.links, new Map(parsed.map((p) => [p.source.key, p.source.sessionKey])));
|
|
349
|
+
}
|
|
350
|
+
const savedOf = new Map(savedList.map((x) => [x.key, x]));
|
|
216
351
|
|
|
217
352
|
// Git: only for sessions in featured or reference repos.
|
|
218
353
|
let gitPart = { events: [], sources: [], notes: [] };
|
|
@@ -232,6 +367,7 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
|
|
|
232
367
|
// redacted one by one where it was read (lib/replay/codex.mjs), so the home-path rule doesn't
|
|
233
368
|
// take it here.
|
|
234
369
|
const codexSources = new Set(parsed.filter((p) => p.source.tool === 'codex').map((p) => p.source.key));
|
|
370
|
+
const alreadyRedacted = new Set(savedList.filter((x) => x.redacted === true).flatMap((x) => x.sources.map((s) => s.key)));
|
|
235
371
|
for (const e of a.events) {
|
|
236
372
|
if (hiddenSources.has(e.source)) {
|
|
237
373
|
e.facts = pick(e.facts, PRIVATE_FACTS);
|
|
@@ -248,7 +384,7 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
|
|
|
248
384
|
for (const key of PRIVATE_HASHES) delete e.facts[key];
|
|
249
385
|
}
|
|
250
386
|
const addresses = codexSources.has(e.source) ? ADDRESS_FACTS.filter((k) => typeof e.facts[k] === 'string' && AGENT_ADDRESS_RE.test(e.facts[k])).map((k) => [k, e.facts[k]]) : [];
|
|
251
|
-
e.facts = deep(e.facts);
|
|
387
|
+
if (!alreadyRedacted.has(e.source)) e.facts = deep(e.facts);
|
|
252
388
|
for (const [k, v] of addresses) e.facts[k] = v;
|
|
253
389
|
}
|
|
254
390
|
// The progress-update rule labels events only with the updates option.
|
|
@@ -267,7 +403,9 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
|
|
|
267
403
|
|
|
268
404
|
// Sessions and agents as plain records.
|
|
269
405
|
const codexNames = await codexThreadNames(r.codex ?? [], new Set(parsed.filter((p) => p.source.tool === 'codex' && p.source.role === 'session').map((p) => p.source.threadId)));
|
|
406
|
+
if (signal?.aborted) throw Object.assign(new Error('replay: the build was stopped.'), { code: 'ABORTED' });
|
|
270
407
|
const titleOf = (sessKey) => {
|
|
408
|
+
if (savedOf.has(sessKey)) return savedOf.get(sessKey).record?.title ?? null;
|
|
271
409
|
const main = parsed.find((p) => p.source.key === sessKey);
|
|
272
410
|
if (main?.source.tool === 'codex') return redactClip(codexNames.get(main.source.threadId) ?? null, SHORT, redact);
|
|
273
411
|
const titles = main?.result.stats.titles ?? [];
|
|
@@ -294,6 +432,8 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
|
|
|
294
432
|
repoRole: s.repo?.role ?? null,
|
|
295
433
|
private: s.isPrivate,
|
|
296
434
|
title: s.isPrivate && !showHidden ? null : titleOf(s.key),
|
|
435
|
+
// A session that came back as saved says when, by which version, and whether its log is gone.
|
|
436
|
+
...(savedOf.has(s.key) && savedOf.get(s.key).log === 'gone' ? { saved: savedMeta(savedOf.get(s.key)) } : {}),
|
|
297
437
|
firstAt: s.firstAt ?? null,
|
|
298
438
|
lastAt: s.lastAt ?? null,
|
|
299
439
|
endState: s.endState ?? 'no-work-records',
|
|
@@ -351,7 +491,7 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
|
|
|
351
491
|
};
|
|
352
492
|
if (privateText === true) h.privateText = true;
|
|
353
493
|
if (hiddenSessions === 'redacted') h.hiddenSessions = 'redacted';
|
|
354
|
-
if (usage === true) h.usage = { calls: usageCalls(parsed) };
|
|
494
|
+
if (usage === true) h.usage = { calls: usageCalls(parsed, { hashIds: hashIds || saving === true }) };
|
|
355
495
|
const views = createViews(h);
|
|
356
496
|
|
|
357
497
|
// Goal membership and reverse lookup read the raw joins the adapters kept (moved onto
|
|
@@ -425,6 +565,15 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
|
|
|
425
565
|
const id = p.source.fileId ?? p.source.threadId;
|
|
426
566
|
if (typeof id === 'string' && id) harnessIds.set(id.toLowerCase(), p.source.sessionKey);
|
|
427
567
|
}
|
|
568
|
+
// A saved session's own id is saved only as a hash: the goal list's own words are hashed to
|
|
569
|
+
// find the ones that cite it.
|
|
570
|
+
const savedIds = new Map(savedList.filter((x) => x.logId && readable.has(x.key)).map((x) => [x.logId, x.key]));
|
|
571
|
+
if (savedIds.size) {
|
|
572
|
+
for (const word of new Set(JSON.stringify(goalInput).match(/[0-9A-Za-z][0-9A-Za-z_-]{7,}/g) ?? [])) {
|
|
573
|
+
const key = savedIds.get(logIdHash(word));
|
|
574
|
+
if (key && !harnessIds.has(word.toLowerCase())) harnessIds.set(word.toLowerCase(), key);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
428
577
|
const found = goalMembership({ record: goalRecord, index: refIndex(), watched: liveJoins('watched'), harnessIds, readable, firstAtOf, window: h.window, redact: redactor.redact });
|
|
429
578
|
h.goals = redactor.deepRedact(found);
|
|
430
579
|
goalByRawId = new Map(goalRecord.goals.map((g, i) => [g.id, h.goals[i]]));
|
|
@@ -462,6 +611,7 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
|
|
|
462
611
|
|
|
463
612
|
// Each event by its id, built on the first record() call, so a lookup doesn't scan every
|
|
464
613
|
// event. The first event with an id wins, as a search from the front would find it.
|
|
614
|
+
const savedSourceSession = new Map(savedList.flatMap((x) => x.sources.map((s) => [s.key, x.key])));
|
|
465
615
|
let eventById = null;
|
|
466
616
|
/** Re-read the original record behind a reference and check its digest. */
|
|
467
617
|
function record(refOrEventId) {
|
|
@@ -473,6 +623,7 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
|
|
|
473
623
|
return refs.map((ref) => {
|
|
474
624
|
if (ref.sha) return { ref, verified: null, note: 'a git object; inspect it with git show', record: null };
|
|
475
625
|
const file = files.get(ref.src);
|
|
626
|
+
if (!file && savedSourceSession.has(ref.src) && savedOf.get(savedSourceSession.get(ref.src))?.log === 'gone') return { ref, verified: null, note: savedNote(savedOf.get(savedSourceSession.get(ref.src))), record: null, saved: true };
|
|
476
627
|
if (!file) return { ref, verified: false, note: 'source not part of this history', record: null };
|
|
477
628
|
const bytes = readBytesAt(file, ref.off, ref.len);
|
|
478
629
|
if (!bytes) return { ref, verified: false, note: 'the record could not be re-read (file shorter or moved)', record: null };
|
|
@@ -520,7 +671,23 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
|
|
|
520
671
|
// Each Claude Code session's own id (its file's name), by session key, in memory only, so a
|
|
521
672
|
// caller can find files another tool keyed by that id. Not enumerable: no output changes.
|
|
522
673
|
Object.defineProperty(history, 'claudeIds', { value: new Map(parsed.filter((p) => p.source.tool === 'claude-code' && p.source.role === 'session' && typeof p.source.fileId === 'string').map((p) => [p.source.sessionKey, p.source.fileId.toLowerCase()])), enumerable: false });
|
|
674
|
+
// Each log file this history read, by source key, in memory only, so a caller can tell later
|
|
675
|
+
// whether a session's log is still on disk (lib/saved/checks.mjs keeps only a hash of the path).
|
|
676
|
+
Object.defineProperty(history, 'sourceFiles', { value: new Map(files), enumerable: false });
|
|
523
677
|
// The same for each Codex session: its thread id from session_meta, in memory only.
|
|
678
|
+
// Each saved session's own id, as the hash it was saved under, in memory only.
|
|
679
|
+
// The sessions that came back unread, their logs unchanged: their saved copies stand as they are.
|
|
680
|
+
// Each log file's fingerprint from before this build read it (a build made with `saving` only).
|
|
681
|
+
Object.defineProperty(history, 'sourcePrints', { value: new Map(readPrints), enumerable: false });
|
|
682
|
+
Object.defineProperty(history, 'reusedSessions', { value: new Set(savedList.filter((x) => reusable.has(x.key)).map((x) => x.key)), enumerable: false });
|
|
683
|
+
Object.defineProperty(history, 'savedLogIds', { value: new Map(savedList.filter((x) => x.logId).map((x) => [x.key, x.logId])), enumerable: false });
|
|
684
|
+
if (saving === true && privateText !== true) {
|
|
685
|
+
const savable = new Set(parsed.filter((p) => !p.source.saved).map((p) => p.source.sessionKey));
|
|
686
|
+
Object.defineProperty(history, 'exportSession', {
|
|
687
|
+
value: (key) => (savable.has(key) ? exportSession({ key, parsed, a, sessions, links: h.links, usage: h.usage?.calls ?? [], redact, hashed: hashIds, ranOutside: (e) => typeof e._workdir === 'string' && !workdirInRepo(e.session, e._workdir), fileStat: (f) => readPrints.get(f) ?? null }) : null),
|
|
688
|
+
enumerable: false,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
524
691
|
Object.defineProperty(history, 'codexIds', { value: new Map(parsed.filter((p) => p.source.tool === 'codex' && p.source.role === 'session' && typeof p.source.threadId === 'string').map((p) => [p.source.sessionKey, p.source.threadId.toLowerCase()])), enumerable: false });
|
|
525
692
|
return history;
|
|
526
693
|
}
|
|
@@ -641,7 +808,22 @@ async function codexThreadNames(roots, ids) {
|
|
|
641
808
|
return out;
|
|
642
809
|
}
|
|
643
810
|
|
|
644
|
-
|
|
811
|
+
/** How long before a session began, and after, a launch step that started it can sit. */
|
|
812
|
+
const LAUNCH_LOOKBACK_MS = 2 * 60 * 1000;
|
|
813
|
+
const LAUNCH_SLACK_MS = 5000;
|
|
814
|
+
|
|
815
|
+
/** When and by which version a saved session was saved, and whether its log is gone. */
|
|
816
|
+
function savedMeta(x) {
|
|
817
|
+
return { at: typeof x.savedAt === 'string' ? x.savedAt : null, by: typeof x.version === 'string' ? x.version : null, log: x.log === 'gone' ? 'gone' : 'on-disk' };
|
|
818
|
+
}
|
|
819
|
+
/** What a saved step's log line says instead of the record. */
|
|
820
|
+
function savedNote(x) {
|
|
821
|
+
// The day it was saved is on the session's own label, in the page's timezone.
|
|
822
|
+
const m = x ? savedMeta(x) : { log: 'gone' };
|
|
823
|
+
return m.log === 'gone' ? "saved from a log that's no longer on disk, so this step can't be checked against its log line" : "saved; this run didn't read its log again";
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function usageCalls(parsed, { hashIds = false } = {}) {
|
|
645
827
|
const lastAt = (p) => p.result.stats?.lastAt ?? '';
|
|
646
828
|
const earlier = (p, q) => lastAt(p) < lastAt(q) || (lastAt(p) === lastAt(q) && p.source.key < q.source.key);
|
|
647
829
|
const byThread = new Map(parsed.filter((p) => p.source.tool === 'codex').map((p) => [p.source.threadId, p]));
|
|
@@ -671,7 +853,10 @@ function usageCalls(parsed) {
|
|
|
671
853
|
};
|
|
672
854
|
const keeps = (p, q) => (p.source.tool === 'codex' && depth(p) !== depth(q) ? depth(p) < depth(q) : earlier(p, q));
|
|
673
855
|
const owner = new Map(); // tool, fork family and call id -> the parsed file that keeps it
|
|
674
|
-
|
|
856
|
+
// A saved session's call ids are hashes already; with saved sessions in the build, every other
|
|
857
|
+
// call id is compared as its hash too, so a call two files repeat still counts once.
|
|
858
|
+
const idOf = (c) => (c.hashed ? c.id : hashIds && c.id != null ? idHash(c.id) : c.id);
|
|
859
|
+
const keyOf = (p, c) => (p.source.tool === 'codex' ? `codex\u0000${family(p)}\u0000${idOf(c)}` : `${p.source.tool}\u0000${idOf(c)}`);
|
|
675
860
|
for (const p of parsed) {
|
|
676
861
|
for (const c of p.result.usage ?? []) {
|
|
677
862
|
if (c.id == null) continue;
|
|
@@ -683,7 +868,10 @@ function usageCalls(parsed) {
|
|
|
683
868
|
for (const p of parsed) {
|
|
684
869
|
for (const c of p.result.usage ?? []) {
|
|
685
870
|
if (c.id != null && owner.get(keyOf(p, c)) !== p) continue;
|
|
686
|
-
|
|
871
|
+
const row = { session: p.source.sessionKey, agent: c.agent, source: p.source.key, tool: p.source.tool, lines: [...c.lines].sort((a, b) => a - b), t: c.t, input: c.input, cacheWrite: c.cacheWrite, cacheRead: c.cacheRead, output: c.output, ...(c.ttl ? { ttl: c.ttl } : {}) };
|
|
872
|
+
// The call's id as it's saved, kept out of the row's own fields so no output changes.
|
|
873
|
+
if (hashIds && c.id != null) Object.defineProperty(row, 'idHash', { value: idOf(c), enumerable: false });
|
|
874
|
+
out.push(row);
|
|
687
875
|
}
|
|
688
876
|
}
|
|
689
877
|
return out.sort((a, b) => (a.t ?? 0) - (b.t ?? 0) || (a.source < b.source ? -1 : a.source > b.source ? 1 : a.lines[0] - b.lines[0]));
|
package/lib/replay/lookup.mjs
CHANGED
|
@@ -189,11 +189,14 @@ export function createReferenceIndex({ events, readable, joins, repoSlugOf, work
|
|
|
189
189
|
for (const e of events) {
|
|
190
190
|
if (!readable.has(e.session)) continue;
|
|
191
191
|
const f = e.facts ?? {};
|
|
192
|
-
|
|
192
|
+
// A saved step (lib/replay/saved-sessions.mjs) brings its command's pull requests as they were
|
|
193
|
+
// read when it was saved, since the command's text isn't kept.
|
|
194
|
+
const commandRefs = Array.isArray(e._prRefs) ? e._prRefs : typeof e._command === 'string' ? null : undefined;
|
|
195
|
+
if (e.kind === 'action' && f.category === 'shell' && commandRefs !== undefined && !didNotRun(e)) {
|
|
193
196
|
// A Codex call that ran in a folder outside the session's repository acts on
|
|
194
197
|
// whatever repository that folder holds, which the record doesn't say.
|
|
195
|
-
const ranOutside = typeof e._workdir === 'string' && !workdirInRepo(e.session, e._workdir);
|
|
196
|
-
for (const r of prRefsInCommand(e._command)) {
|
|
198
|
+
const ranOutside = commandRefs ? e._ranOutside === true : typeof e._workdir === 'string' && !workdirInRepo(e.session, e._workdir);
|
|
199
|
+
for (const r of commandRefs ?? prRefsInCommand(e._command)) {
|
|
197
200
|
if (!r.repoKnown) continue;
|
|
198
201
|
if (!r.repo && ranOutside) continue;
|
|
199
202
|
const slug = r.repo ? repoSlug(r.repo) : repoSlugOf(e.session);
|