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/view/data.mjs
CHANGED
|
@@ -22,20 +22,23 @@ import { randomBytes } from 'node:crypto';
|
|
|
22
22
|
import { readFileSync } from 'node:fs';
|
|
23
23
|
|
|
24
24
|
import { buildWorkHistory } from '../replay/index.mjs';
|
|
25
|
+
import { logIdHash } from '../replay/saved-sessions.mjs';
|
|
25
26
|
import { normalizeGoalRecord } from '../replay/goals.mjs';
|
|
26
27
|
import { letterHash } from '../replay/ids.mjs';
|
|
27
28
|
import { createRedactor, createSecretsOnlyRedactor } from '../redact.mjs';
|
|
28
29
|
import { createLeakCounter } from './leaks.mjs';
|
|
29
30
|
import { buildTimeline } from '../replay/timeline.mjs';
|
|
30
|
-
import { localDay } from '../replay/views.mjs';
|
|
31
|
+
import { describe, localDay } from '../replay/views.mjs';
|
|
31
32
|
import { privateWordCount, privateWordsNote } from '../private-words.mjs';
|
|
32
33
|
import { eventRow, exportThread, frameAt, frameMoments, sessionGroup, timeNoteOf, whoOf } from './replay-export.mjs';
|
|
33
|
-
import { buildWordIndex, createWordSearch, cutRedacted } from '
|
|
34
|
+
import { buildWordIndex, createWordSearch, cutRedacted } from '../replay/word-index.mjs';
|
|
35
|
+
import { matchWords, similarPrompts, WORD_RULES, wordsOf } from '../replay/words.mjs';
|
|
34
36
|
import { createProblemsRoute } from './problems-route.mjs';
|
|
35
37
|
import { earlierWindow, runProblems, trendCounts } from '../problems/index.mjs';
|
|
36
38
|
import { INSIGHTS_TEXT } from './insights.mjs';
|
|
37
39
|
import { sessionFacts, windowTotals } from './facts.mjs';
|
|
38
40
|
import { sessionText } from './codex-judge.mjs';
|
|
41
|
+
import { suggestWords } from './suggest-words.mjs';
|
|
39
42
|
|
|
40
43
|
/** The most moments a goal page samples per member session. */
|
|
41
44
|
const GOAL_FRAMES = 600;
|
|
@@ -49,11 +52,9 @@ export const SESSIONS_PAGE = 20;
|
|
|
49
52
|
/** The day a session with no recorded time is listed under, after every dated day. */
|
|
50
53
|
export const NO_DAY = 'none';
|
|
51
54
|
|
|
52
|
-
/** The rules behind results the page computes itself, beside the engine's own
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
'view.similar-prompt': 'Two prompts share a large part of their words (the shared words divided by all the words either uses). Similar wording doesn\'t mean similar work.',
|
|
56
|
-
});
|
|
55
|
+
/** The rules behind results the page computes itself, beside the engine's own: the phrase
|
|
56
|
+
* search's (lib/replay/words.mjs). */
|
|
57
|
+
export const VIEW_RULES = WORD_RULES;
|
|
57
58
|
|
|
58
59
|
/** The five evidence words every page uses, in plain words. */
|
|
59
60
|
export const EVIDENCE_KEY = Object.freeze({
|
|
@@ -99,8 +100,6 @@ export function demoNotice(run) {
|
|
|
99
100
|
});
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
const STOP = new Set(['a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'can', 'do', 'for', 'from', 'if', 'in', 'into', 'is', 'it', 'its', 'just', 'me', 'my', 'no', 'not', 'of', 'on', 'or', 'so', 'that', 'the', 'then', 'this', 'to', 'up', 'we', 'with', 'you', 'your', 'i', 'redacted']);
|
|
103
|
-
const MARKERS = /\[redacted:[a-z]+\]/g;
|
|
104
103
|
|
|
105
104
|
/** A goal's key: a letter hash of its raw id, so it never shows the id and the redactor
|
|
106
105
|
* never alters it. */
|
|
@@ -108,10 +107,8 @@ export function goalKey(rawId) {
|
|
|
108
107
|
return `g${letterHash(`goal\u0000${String(rawId)}`, 15)}`;
|
|
109
108
|
}
|
|
110
109
|
|
|
111
|
-
/** Lowercased words of a text
|
|
112
|
-
export
|
|
113
|
-
return (String(text ?? '').replace(MARKERS, ' ').toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}_'-]*/gu) ?? []).filter((w) => w.length >= 2 && !STOP.has(w));
|
|
114
|
-
}
|
|
110
|
+
/** Lowercased words of a text (the engine's phrase search, lib/replay/words.mjs). */
|
|
111
|
+
export { wordsOf };
|
|
115
112
|
|
|
116
113
|
/** A small least-recently-used map: get() refreshes an entry, set() drops the oldest
|
|
117
114
|
* once it holds more than `size`. */
|
|
@@ -181,6 +178,38 @@ function firstWords(text, n = 8) {
|
|
|
181
178
|
}
|
|
182
179
|
|
|
183
180
|
const daysBetween = (from, to) => Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86400000) + 1;
|
|
181
|
+
|
|
182
|
+
/** The shortest start of an id that `matchSession` reads as that id. */
|
|
183
|
+
export const SESSION_PREFIX_MIN = 8;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* matchSession(h, text) -> { key, group, checked } | { matches: [key...] } | { none: true }
|
|
187
|
+
* A session of history `h` by its key, by the id in its own log (a Claude Code session's id, a
|
|
188
|
+
* Codex thread id, which the engine keeps in memory only), or by the start of either,
|
|
189
|
+
* SESSION_PREFIX_MIN characters or more. A start several sessions share returns them all.
|
|
190
|
+
* `checked` is the Problems checks' own rule: a session in a configured repository.
|
|
191
|
+
*/
|
|
192
|
+
export function matchSession(h, text) {
|
|
193
|
+
const want = String(text ?? '').trim().toLowerCase();
|
|
194
|
+
if (!want || want.length > 200) return { none: true };
|
|
195
|
+
const ownIds = [...(h.claudeIds ?? new Map()), ...(h.codexIds ?? new Map())];
|
|
196
|
+
const byKey = new Map(h.sessions.map((s) => [s.key, s]));
|
|
197
|
+
const found = (key) => {
|
|
198
|
+
const s = byKey.get(key);
|
|
199
|
+
return s ? { key, group: sessionGroup(s), checked: sessionGroup(s) === 'configured' } : null;
|
|
200
|
+
};
|
|
201
|
+
// A session that came back from saved results keeps its log's id only as a hash: a full id finds it.
|
|
202
|
+
const savedId = h.savedLogIds?.size ? [...h.savedLogIds].find(([, x]) => x === logIdHash(want))?.[0] : undefined;
|
|
203
|
+
const exact = found(want) ?? found(ownIds.find(([, id]) => id === want)?.[0]) ?? found(savedId);
|
|
204
|
+
if (exact) return exact;
|
|
205
|
+
if (want.length < SESSION_PREFIX_MIN) return { none: true };
|
|
206
|
+
const keys = new Set([...byKey.keys()].filter((k) => k.startsWith(want)));
|
|
207
|
+
for (const [k, id] of ownIds) if (id.startsWith(want) && byKey.has(k)) keys.add(k);
|
|
208
|
+
const list = [...keys].sort();
|
|
209
|
+
if (list.length === 1) return found(list[0]);
|
|
210
|
+
return list.length ? { matches: list } : { none: true };
|
|
211
|
+
}
|
|
212
|
+
|
|
184
213
|
const ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
|
|
185
214
|
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
186
215
|
const OFFSET_RE = /^\d{1,6}$/;
|
|
@@ -263,7 +292,7 @@ function goalSplit(members) {
|
|
|
263
292
|
// The private-word count and note live in lib/private-words.mjs, shared with init.
|
|
264
293
|
export { privateWordCount, privateWordsNote };
|
|
265
294
|
|
|
266
|
-
export function createViewData({ config, roots, from, to, timezone, goalRecord = null, demo = false, selfTest = false, command: ranAs = 'honestweek', buildHistory = buildWorkHistory, cacheSize = REPLAY_CACHE_SIZE, now = () => Date.now(), windowNote = null, insights = null, codexJudge = null, partial = null, onProgress = null, earlierCheck = null, privateCheck = null, earlierProgressive = false, queries = null }) {
|
|
295
|
+
export function createViewData({ config, roots, from, to, timezone, goalRecord = null, demo = false, selfTest = false, command: ranAs = 'honestweek', buildHistory = buildWorkHistory, cacheSize = REPLAY_CACHE_SIZE, now = () => Date.now(), windowNote = null, insights = null, codexJudge = null, settingsOpen = true, partial = null, onProgress = null, earlierCheck = null, privateCheck = null, earlierProgressive = false, queries = null, onChecked = null, savedLoad = null, savedCounts = null }) {
|
|
267
296
|
const wordCount = privateWordCount(config);
|
|
268
297
|
const fullRedactor = createRedactor(config);
|
|
269
298
|
// How the person ran honestweek, for the commands a page names. It goes through the
|
|
@@ -292,7 +321,8 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
|
|
|
292
321
|
const builds = { redacted: slot(), private: slot() };
|
|
293
322
|
const words = { state: 'waiting', ms: null, error: null, search: null, sessions: 0 };
|
|
294
323
|
|
|
295
|
-
// Set when Settings replaces this data: a build still reading
|
|
324
|
+
// Set when Settings replaces this data or the page stops: a build still reading, this
|
|
325
|
+
// window's or the week before's, stops at its next check and runs no more git.
|
|
296
326
|
const signal = { aborted: false };
|
|
297
327
|
const reason = (err) => cutRedacted(fullRedactor.redact(String(err?.message ?? err ?? 'unknown error').split('\n')[0]), 200);
|
|
298
328
|
|
|
@@ -308,6 +338,17 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
|
|
|
308
338
|
// redacted text rather than skeletons; an engine without that option ignores it.
|
|
309
339
|
// The redacted build also records token counts and keeps raw text in memory for the
|
|
310
340
|
// problem checks, which run on it alone; an engine without those options ignores them.
|
|
341
|
+
// Saved sessions whose logs are gone (lib/saved/history.mjs) join both builds, redacted as
|
|
342
|
+
// saved: private text is never saved, so the private build shows them redacted too. The
|
|
343
|
+
// redacted build is the one saved from, so it keeps what the saver exports.
|
|
344
|
+
const savedNow = savedSessions();
|
|
345
|
+
if (savedNow) {
|
|
346
|
+
options.saved = savedNow.sessions;
|
|
347
|
+
// Sessions whose logs haven't changed since they were saved come back unread, in the
|
|
348
|
+
// redacted build only: the private build reads their logs for the text the saved copy hid.
|
|
349
|
+
if (mode === 'redacted' && savedNow.reuse?.size) options.reuse = savedNow.reuse;
|
|
350
|
+
}
|
|
351
|
+
if (mode === 'redacted' && typeof onChecked === 'function') options.saving = true;
|
|
311
352
|
if (mode === 'private') options.privateText = true;
|
|
312
353
|
else Object.assign(options, { hiddenSessions: 'redacted', usage: true, keepRaw: true, ...(typeof onProgress === 'function' ? { onProgress } : {}) });
|
|
313
354
|
// A second copy of a large window may not fit beside the first: it isn't built then, and the
|
|
@@ -329,6 +370,7 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
|
|
|
329
370
|
b.state = 'ready';
|
|
330
371
|
b.ms = now() - b.startedAt;
|
|
331
372
|
if (mode === 'redacted') startWords(h);
|
|
373
|
+
if (mode === 'redacted' && !partial && typeof onChecked === 'function') checkedReady(b);
|
|
332
374
|
},
|
|
333
375
|
(err) => {
|
|
334
376
|
b.state = 'failed';
|
|
@@ -339,6 +381,35 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
|
|
|
339
381
|
return b.promise;
|
|
340
382
|
}
|
|
341
383
|
|
|
384
|
+
// With "saveResults" on (lib/saved/), the whole window's check results go to `onChecked` once
|
|
385
|
+
// its redacted build is ready: the same run of the checks every Problems answer reads, so they
|
|
386
|
+
// run once. A first day shown while the rest loads (`partial`) is never saved. A failure to
|
|
387
|
+
// save is the saver's to report; the page goes on.
|
|
388
|
+
function checkedReady(b) {
|
|
389
|
+
setTimeout(() => {
|
|
390
|
+
if (signal.aborted || builds.redacted !== b) return;
|
|
391
|
+
try {
|
|
392
|
+
const c = problemsRoute.checked(b.h, b.startedAt);
|
|
393
|
+
if (c) onChecked({ h: b.h, builtT: b.startedAt, result: c.result, keys: c.keys, describeStep: (id) => (b.eventsById.has(id) ? describe(b.eventsById.get(id)) : null) });
|
|
394
|
+
} catch {
|
|
395
|
+
/* the saver records its own failure */
|
|
396
|
+
}
|
|
397
|
+
}, 0);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Read once, for both builds and the checks.
|
|
401
|
+
let savedRead;
|
|
402
|
+
function savedSessions() {
|
|
403
|
+
if (savedRead === undefined) {
|
|
404
|
+
try {
|
|
405
|
+
savedRead = typeof savedLoad === 'function' ? savedLoad({ from, to, timezone }) ?? null : null;
|
|
406
|
+
} catch {
|
|
407
|
+
savedRead = null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return savedRead;
|
|
411
|
+
}
|
|
412
|
+
|
|
342
413
|
function startWords(h) {
|
|
343
414
|
words.state = 'building';
|
|
344
415
|
const t0 = now();
|
|
@@ -423,7 +494,7 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
|
|
|
423
494
|
// config lists any private words (the demo always has its made-up one).
|
|
424
495
|
command,
|
|
425
496
|
// One short line on every page; the rest of the note sits behind its "?".
|
|
426
|
-
privateWords: { count: wordCount, note: !demo && wordCount === 0 ? PRIVATE_WORDS_SHORT : null, more: !demo && wordCount === 0 ? privateWordsNote(command, { restart: false }) : null },
|
|
497
|
+
privateWords: { count: wordCount, note: !demo && wordCount === 0 ? PRIVATE_WORDS_SHORT : null, more: !demo && wordCount === 0 ? privateWordsNote(command, { restart: false, settings: settingsOpen }) : null },
|
|
427
498
|
selfTest,
|
|
428
499
|
evidenceKey: EVIDENCE_KEY,
|
|
429
500
|
};
|
|
@@ -572,11 +643,14 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
|
|
|
572
643
|
return launch ? { ...sb, launch } : sb;
|
|
573
644
|
}
|
|
574
645
|
|
|
646
|
+
/** A session that came back as saved (lib/saved/history.mjs): when, by which version, and
|
|
647
|
+
* whether its log is gone. Nothing for any other session, so its answer is what it was. */
|
|
648
|
+
const savedOf = (s) => (s?.saved ? { saved: { at: s.saved.at ?? null, by: s.saved.by ?? null, log: s.saved.log ?? null } } : {});
|
|
575
649
|
const sessionRow = (ctx, key) => {
|
|
576
650
|
const s = ctx.sessionByKey.get(key);
|
|
577
651
|
if (!s) return { session: key, thread: null, tool: null, repo: null, group: null, title: null, firstAt: null, lastAt: null, startedBy: null };
|
|
578
652
|
const label = labelOf(ctx, s.key);
|
|
579
|
-
return { session: s.key, thread: s.thread, tool: s.tool, repo: s.repo, group: sessionGroup(s), title: s.title, ...(label ? { label } : {}), firstAt: s.firstAt, lastAt: s.lastAt, startedBy: startedWith(ctx, s.key) };
|
|
653
|
+
return { session: s.key, thread: s.thread, tool: s.tool, repo: s.repo, group: sessionGroup(s), title: s.title, ...(label ? { label } : {}), firstAt: s.firstAt, lastAt: s.lastAt, startedBy: startedWith(ctx, s.key), ...savedOf(s) };
|
|
580
654
|
};
|
|
581
655
|
|
|
582
656
|
/** The goals a session is a member of, each with that session's place in the goal. */
|
|
@@ -776,73 +850,36 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
|
|
|
776
850
|
return { event: e.id, ...sessionRow(ctx, e.session), t: e.t, at: e.at ?? null, text: cutRedacted(String(e.facts?.text ?? ''), 240), who: whoOf(e, new Map(ctx.h.agents.map((a) => [a.key, a]))), goals: goalsOfSession(ctx, e.session), ...extra };
|
|
777
851
|
}
|
|
778
852
|
|
|
853
|
+
// Phrase search is the engine's (lib/replay/words.mjs): display-only and outside sessions never
|
|
854
|
+
// join a lookup, whichever build answers. These shape its matches into the page's rows.
|
|
779
855
|
function wordSearch(ctx, params) {
|
|
780
|
-
const h = ctx.h;
|
|
781
|
-
// Display-only and outside sessions never join a lookup, whichever build answers.
|
|
782
|
-
const prompts = readableEvents(ctx).filter((e) => e.kind === 'prompt' && typeof e.facts?.text === 'string' && e.facts.text);
|
|
783
856
|
const similarTo = params.get('similar');
|
|
784
857
|
if (similarTo) {
|
|
785
858
|
if (!ID_RE.test(similarTo)) return json(400, { error: 'That prompt id is malformed.' });
|
|
786
|
-
const
|
|
787
|
-
if (!
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
.filter((e) => e.id !== base.id)
|
|
791
|
-
.map((e) => {
|
|
792
|
-
const theirs = new Set(wordsOf(e.facts.text));
|
|
793
|
-
let shared = 0;
|
|
794
|
-
for (const w of theirs) if (mine.has(w)) shared += 1;
|
|
795
|
-
const union = mine.size + theirs.size - shared;
|
|
796
|
-
return { e, score: union ? shared / union : 0 };
|
|
797
|
-
})
|
|
798
|
-
.filter((x) => x.score >= 0.2)
|
|
799
|
-
.sort((a, b) => b.score - a.score || b.e.t - a.e.t)
|
|
800
|
-
.slice(0, 10)
|
|
801
|
-
.map(({ e, score }) => promptRow(ctx, e, { evidence: 'inferred', rule: 'view.similar-prompt', score: { value: Math.round(score * 100) / 100, evidence: 'inferred', rule: 'view.similar-prompt' } }));
|
|
802
|
-
return { similarTo: base.id, similar, goals: [], sessions: [], branches: [], prompts: [], empty: similar.length ? null : 'No prompt in this window shares enough words with that one.', rules: VIEW_RULES, evidenceKey: EVIDENCE_KEY };
|
|
859
|
+
const found = similarPrompts(ctx.h, similarTo);
|
|
860
|
+
if (!found) return json(404, { error: 'No prompt with that id in this window.' });
|
|
861
|
+
const similar = found.map(({ event: e, score }) => promptRow(ctx, e, { evidence: 'inferred', rule: 'view.similar-prompt', score: { value: Math.round(score * 100) / 100, evidence: 'inferred', rule: 'view.similar-prompt' } }));
|
|
862
|
+
return { similarTo, similar, goals: [], sessions: [], branches: [], prompts: [], empty: similar.length ? null : 'No prompt in this window shares enough words with that one.', rules: VIEW_RULES, evidenceKey: EVIDENCE_KEY };
|
|
803
863
|
}
|
|
804
864
|
const query = readQuery(params, { goals: [], sessions: [], branches: [], prompts: [], similar: [] });
|
|
805
865
|
if (query.answer) return query.answer;
|
|
806
|
-
return withQuery(query, wordsFor(ctx, query.text
|
|
866
|
+
return withQuery(query, wordsFor(ctx, query.text));
|
|
807
867
|
}
|
|
808
868
|
|
|
809
|
-
function wordsFor(ctx, q
|
|
869
|
+
function wordsFor(ctx, q) {
|
|
810
870
|
const h = ctx.h;
|
|
811
|
-
const
|
|
812
|
-
const low = q.toLowerCase();
|
|
813
|
-
const has = (text) => want.length > 0 && want.every((w) => String(text ?? '').toLowerCase().includes(w));
|
|
871
|
+
const m = matchWords(h, q);
|
|
814
872
|
const goals = record
|
|
815
|
-
?
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
return inTitle || inId ? [{ key: keys[i], title: g.title, state: g.state, matchedIn: inTitle ? 'title' : 'id', evidence: 'recorded', members: memberCount(g.members) }] : [];
|
|
873
|
+
? m.goals.map(({ index, matchedIn }) => {
|
|
874
|
+
const g = h.goals[index];
|
|
875
|
+
return { key: keys[index], title: g.title, state: g.state, matchedIn, evidence: 'recorded', members: memberCount(g.members) };
|
|
819
876
|
})
|
|
820
877
|
: [];
|
|
821
|
-
const sessions =
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
if (typeof b !== 'string' || !b || !b.toLowerCase().includes(low)) continue;
|
|
827
|
-
if (!branchMap.has(b)) branchMap.set(b, new Map());
|
|
828
|
-
if (!branchMap.get(b).has(e.session)) branchMap.get(b).set(e.session, e.id);
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
const branches = [...branchMap].map(([branch, bySession]) => ({ branch, evidence: 'recorded', sessions: [...bySession].map(([session, event]) => ({ ...sessionRow(ctx, session), event, evidence: 'recorded' })) }));
|
|
832
|
-
const scored = prompts
|
|
833
|
-
.map((e) => {
|
|
834
|
-
const theirs = new Set(wordsOf(e.facts.text));
|
|
835
|
-
const shared = want.filter((w) => theirs.has(w)).length;
|
|
836
|
-
return { e, shared };
|
|
837
|
-
})
|
|
838
|
-
.filter((x) => x.shared > 0)
|
|
839
|
-
.sort((a, b) => b.shared - a.shared || b.e.t - a.e.t)
|
|
840
|
-
.slice(0, 20)
|
|
841
|
-
.map(({ e, shared }) => {
|
|
842
|
-
// The words are in the prompt's text, so the row is recorded; ranking it by how many
|
|
843
|
-
// of them it shares is a named rule's reading, so the score is inferred.
|
|
844
|
-
return promptRow(ctx, e, { evidence: 'recorded', rule: 'view.shared-words', matched: shared === want.length ? 'all your words' : 'some of your words', score: { value: shared, of: want.length, evidence: 'inferred', rule: 'view.shared-words' } });
|
|
845
|
-
});
|
|
878
|
+
const sessions = m.sessions.map((key) => ({ ...sessionRow(ctx, key), evidence: 'recorded', goals: goalsOfSession(ctx, key) }));
|
|
879
|
+
const branches = m.branches.map(({ branch, sessions: bySession }) => ({ branch, evidence: 'recorded', sessions: bySession.map(({ session, event }) => ({ ...sessionRow(ctx, session), event, evidence: 'recorded' })) }));
|
|
880
|
+
// The words are in the prompt's text, so the row is recorded; ranking it by how many of them
|
|
881
|
+
// it shares is a named rule's reading, so the score is inferred.
|
|
882
|
+
const scored = m.prompts.map(({ event: e, shared }) => promptRow(ctx, e, { evidence: 'recorded', rule: 'view.shared-words', matched: shared === m.want.length ? 'all your words' : 'some of your words', score: { value: shared, of: m.want.length, evidence: 'inferred', rule: 'view.shared-words' } }));
|
|
846
883
|
const nothing = !goals.length && !sessions.length && !branches.length && !scored.length;
|
|
847
884
|
return { goals, sessions, branches, prompts: scored, similar: [], empty: nothing ? emptyText() : null, rules: VIEW_RULES, evidenceKey: EVIDENCE_KEY };
|
|
848
885
|
}
|
|
@@ -928,6 +965,16 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
|
|
|
928
965
|
if (launch) data = { ...data, launch };
|
|
929
966
|
ctx.b.replays.set(cacheKey, data);
|
|
930
967
|
}
|
|
968
|
+
// `at` (milliseconds): the thread's state at exactly that moment, read from its timeline
|
|
969
|
+
// rather than the sampled frames, which change only at a step's start. `replay --at` asks
|
|
970
|
+
// for it; the page doesn't, so its answer stays as it was.
|
|
971
|
+
const at = params.get('at');
|
|
972
|
+
if (at !== null) {
|
|
973
|
+
const t = Number(at);
|
|
974
|
+
if (at.trim() === '' || !Number.isFinite(t)) return json(400, { error: 'That moment is malformed.' });
|
|
975
|
+
const tl = h.threadTimeline(threadId);
|
|
976
|
+
return { ...data, atFrame: tl ? frameAt(tl, t) : null };
|
|
977
|
+
}
|
|
931
978
|
return data;
|
|
932
979
|
}
|
|
933
980
|
|
|
@@ -959,6 +1006,7 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
|
|
|
959
1006
|
git: !!r.ref?.sha,
|
|
960
1007
|
verified: r.verified,
|
|
961
1008
|
note: r.note,
|
|
1009
|
+
...(r.saved === true ? { saved: true } : {}),
|
|
962
1010
|
record: r.record == null ? null : joinLinesWhereNeeded(r.record, ctx.redact),
|
|
963
1011
|
}));
|
|
964
1012
|
const result = reads.find((x) => x.part === 'result');
|
|
@@ -1045,7 +1093,8 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
|
|
|
1045
1093
|
// ---- problems: the checks run once on the redacted build; each answer is drawn for its own --
|
|
1046
1094
|
// The long-session check runs only with a limit set in Settings (config.longSessionTokens).
|
|
1047
1095
|
const longSession = Number.isInteger(config?.longSessionTokens) ? config.longSessionTokens : null;
|
|
1048
|
-
|
|
1096
|
+
// A saved session's findings are the ones saved with it (runProblems' `saved`).
|
|
1097
|
+
const problemsRoute = createProblemsRoute({ now, longSessionTokens: longSession, run: (h, o) => runProblems(h, { ...o, saved: savedSessions()?.findings ?? null, savedStatus: savedSessions()?.statuses ?? null }) });
|
|
1049
1098
|
/** The sessions a goal joins, by the redacted build's membership (the same in both builds). */
|
|
1050
1099
|
function membersOfGoal(key) {
|
|
1051
1100
|
const i = indexOfKey.get(String(key ?? ''));
|
|
@@ -1082,6 +1131,14 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
|
|
|
1082
1131
|
return { first: null, whole: Promise.resolve(value), done };
|
|
1083
1132
|
};
|
|
1084
1133
|
if (!w) return settled({ window: null, error: 'no window' });
|
|
1134
|
+
// With every day of it saved, its counts come from the saved check results, and nothing is read.
|
|
1135
|
+
let fromSaved = null;
|
|
1136
|
+
try {
|
|
1137
|
+
fromSaved = typeof savedCounts === 'function' ? savedCounts({ from: w.from, to: w.to, timezone }) : null;
|
|
1138
|
+
} catch {
|
|
1139
|
+
fromSaved = null;
|
|
1140
|
+
}
|
|
1141
|
+
if (fromSaved) return settled({ window: w, ...fromSaved, saved: true, ms: 0 });
|
|
1085
1142
|
// Past the log limit, or more than memory holds beside this window: not read, or only its
|
|
1086
1143
|
// newest days, and the page says which in a few words.
|
|
1087
1144
|
const check = typeof earlierCheck === 'function' ? earlierCheck(w) : null;
|
|
@@ -1089,11 +1146,18 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
|
|
|
1089
1146
|
const wholeFrom = check?.from ?? w.from;
|
|
1090
1147
|
const count = (a, b) => {
|
|
1091
1148
|
const t0 = now();
|
|
1149
|
+
// The days before count their saved sessions whose logs are gone too, as this window does.
|
|
1150
|
+
let back = null;
|
|
1151
|
+
try {
|
|
1152
|
+
back = typeof savedLoad === 'function' ? savedLoad({ from: a, to: b, timezone }) ?? null : null;
|
|
1153
|
+
} catch {
|
|
1154
|
+
back = null;
|
|
1155
|
+
}
|
|
1092
1156
|
return Promise.resolve()
|
|
1093
|
-
.then(() => buildHistory({ config, roots, from: a, to: b, timezone, scope: 'all', updates: true, hiddenSessions: 'redacted', usage: true, keepRaw: true }))
|
|
1157
|
+
.then(() => buildHistory({ config, roots, from: a, to: b, timezone, scope: 'all', updates: true, hiddenSessions: 'redacted', usage: true, keepRaw: true, signal, ...(back ? { saved: back.sessions } : {}) }))
|
|
1094
1158
|
.then(
|
|
1095
1159
|
// Only the counts are kept: the history and the checks' result go with this function.
|
|
1096
|
-
(h) => ({ window: w, ...trendCounts(runProblems(h, { builtT: now(), longSessionTokens: longSession })), ms: now() - t0 }),
|
|
1160
|
+
(h) => ({ window: w, ...trendCounts(runProblems(h, { builtT: now(), longSessionTokens: longSession, ...(back ? { saved: back.findings } : {}) })), ms: now() - t0 }),
|
|
1097
1161
|
(err) => ({ window: w, error: reason(err), ms: now() - t0 }),
|
|
1098
1162
|
);
|
|
1099
1163
|
};
|
|
@@ -1252,7 +1316,20 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
|
|
|
1252
1316
|
return { sessions, totals: Object.fromEntries(Object.entries(totals).map(([k, t]) => [k, { sessions: t.sessions, facts: shown(t.facts) }])) };
|
|
1253
1317
|
}
|
|
1254
1318
|
|
|
1255
|
-
|
|
1319
|
+
/** Settings' "Suggest words": name-shaped words in my configured sessions' prompts and titles.
|
|
1320
|
+
* Always from the redacted build, whichever version the page shows, so a word already hidden
|
|
1321
|
+
* never comes back as a suggestion. */
|
|
1322
|
+
function suggestedWords() {
|
|
1323
|
+
const { h, redact } = context('redacted');
|
|
1324
|
+
// A prompt's shown text is an excerpt, which can miss a later name or cut a code block
|
|
1325
|
+
// open; the whole prompt the engine keeps in memory is scanned instead, redacted first.
|
|
1326
|
+
const promptText = (e) => (typeof e._raw?.text === 'string' ? redact(e._raw.text) : e.facts?.text);
|
|
1327
|
+
const listed = ['names', 'terms', 'codenames'].flatMap((k) => (Array.isArray(config?.redaction?.[k]) ? config.redaction[k] : []));
|
|
1328
|
+
const labels = (Array.isArray(config?.repos) ? config.repos : []).flatMap((r) => (typeof r?.label === 'string' ? r.label.split(/[^\p{L}\p{N}]+/u) : []));
|
|
1329
|
+
return { words: suggestWords({ sessions: h.sessions, events: h.events, include: (s) => sessionGroup(s) === 'configured', exclude: [...listed, ...labels], promptText }) };
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const ROUTES = { '/api/suggest-words': suggestedWords, '/api/home': home, '/api/sessions': sessionsList, '/api/problems': problems, '/api/insights': insightsRoute, '/api/facts': factsRoute, '/api/lookup': lookup, '/api/words': wordSearch, '/api/goal': goal, '/api/replay': replay, '/api/record': recordRoute, '/api/search': search, ...(selfTest ? { '/api/selftest': selftestInfo } : {}) };
|
|
1256
1333
|
|
|
1257
1334
|
/** Answer one data request. `params` is the request's URLSearchParams. */
|
|
1258
1335
|
async function route(path, params) {
|
|
@@ -1306,7 +1383,14 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
|
|
|
1306
1383
|
signal.aborted = true;
|
|
1307
1384
|
}
|
|
1308
1385
|
|
|
1309
|
-
|
|
1386
|
+
/** Settles once "search everywhere" has read the logs, or failed to (after start()). */
|
|
1387
|
+
const searchReady = () => words.promise ?? Promise.resolve();
|
|
1388
|
+
|
|
1389
|
+
/** matchSession over the redacted build, for the commands in this process only: no route
|
|
1390
|
+
* answers it, so a log's own id never reaches the page. Null until that build is ready. */
|
|
1391
|
+
const sessionMatch = (text) => (builds.redacted.state === 'ready' ? matchSession(builds.redacted.h, text) : null);
|
|
1392
|
+
|
|
1393
|
+
return { start, stop, status, route, leakCheck, codexWork, searchReady, sessionMatch, routes: ['/api/status', ...Object.keys(ROUTES)] };
|
|
1310
1394
|
}
|
|
1311
1395
|
|
|
1312
1396
|
/** The text a tool call's result record holds, from either harness's record shape. */
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// lib/view/own-week.mjs: the person's own week, as `honestweek view` reads it and the find,
|
|
2
|
+
// replay, problems and goals commands (lib/ask.mjs) read it too: the config the lookup finds,
|
|
3
|
+
// the window the options or the config's "history" name, the log folders, and the goal list.
|
|
4
|
+
// Read-only: it opens the config and the goal list, and sizes the logs by their files alone.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { HISTORY_LIMIT_MB, loadConfig } from '../config.mjs';
|
|
10
|
+
import { findConfig } from '../config-lookup.mjs';
|
|
11
|
+
import { currentCommand } from '../invocation.mjs';
|
|
12
|
+
import { normalizeGoalRecord } from '../replay/goals.mjs';
|
|
13
|
+
import { defaultRoots } from '../replay/sources.mjs';
|
|
14
|
+
import { localDateInTimezone } from '../resolve-week.mjs';
|
|
15
|
+
import { withSavedDays } from '../saved/history.mjs';
|
|
16
|
+
import { savedDirOf, savedOptions } from '../saved/store.mjs';
|
|
17
|
+
import { logFiles, planWindow } from './window.mjs';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_DAYS = 7;
|
|
20
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
21
|
+
|
|
22
|
+
/** A plain message for a setting that can't be read: `view` prints it and stops. */
|
|
23
|
+
export class SetupError extends Error {}
|
|
24
|
+
export const setup = (message) => new SetupError(message);
|
|
25
|
+
|
|
26
|
+
export const validDate = (s) => DATE_RE.test(s) && !Number.isNaN(Date.parse(`${s}T00:00:00Z`)) && new Date(`${s}T00:00:00Z`).toISOString().slice(0, 10) === s;
|
|
27
|
+
export const spanDays = (a, b) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86400000) + 1;
|
|
28
|
+
export const shiftDay = (day, n) => new Date(Date.parse(`${day}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10);
|
|
29
|
+
|
|
30
|
+
export function validTimezone(tz) {
|
|
31
|
+
try {
|
|
32
|
+
new Intl.DateTimeFormat('en-US', { timeZone: tz });
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The window to read: --from/--to, or the last --days days (default 7) ending today. */
|
|
40
|
+
export function resolveViewWindow(values, timezone, now = Date.now()) {
|
|
41
|
+
const hasRange = '--from' in values || '--to' in values;
|
|
42
|
+
if (hasRange && '--days' in values) throw setup('view: use --days, or --from with --to, not both.');
|
|
43
|
+
if (hasRange) {
|
|
44
|
+
const from = values['--from'];
|
|
45
|
+
const to = values['--to'];
|
|
46
|
+
if (from === undefined || to === undefined) throw setup('view: --from and --to go together. Give both, or use --days.');
|
|
47
|
+
for (const [name, v] of [['--from', from], ['--to', to]]) if (!validDate(v)) throw setup(`view: ${name} must be a date written YYYY-MM-DD (got ${JSON.stringify(v)}).`);
|
|
48
|
+
if (from > to) throw setup(`view: --from (${from}) is after --to (${to}).`);
|
|
49
|
+
return { from, to };
|
|
50
|
+
}
|
|
51
|
+
let days = DEFAULT_DAYS;
|
|
52
|
+
if ('--days' in values) {
|
|
53
|
+
const v = values['--days'];
|
|
54
|
+
if (!/^\d{1,4}$/.test(v) || Number(v) < 1 || Number(v) > 3660) throw setup(`view: --days must be a whole number from 1 to 3660 (got ${JSON.stringify(v)}).`);
|
|
55
|
+
days = Number(v);
|
|
56
|
+
}
|
|
57
|
+
const today = localDateInTimezone(new Date(now), timezone).toISOString().slice(0, 10);
|
|
58
|
+
return { from: shiftDay(today, -(days - 1)), to: today };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* readGoalList(path) -> the parsed goal list. Throws a setup error with a plain message
|
|
63
|
+
* when the file is missing, can't be read as JSON, is the goals page's registry, or doesn't fit.
|
|
64
|
+
*/
|
|
65
|
+
export function readGoalList(path) {
|
|
66
|
+
if (!existsSync(path)) throw setup(`view: no goal list at ${path}. Fix the path, or leave the goal list out; search and replay work without one.`);
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
70
|
+
} catch {
|
|
71
|
+
// Never the parser's message: it quotes the file's first characters, and this check takes
|
|
72
|
+
// any path. A folder or an unreadable file reads the same as a file that isn't JSON.
|
|
73
|
+
throw setup(`view: ${path} isn't a goal list (not valid JSON).`);
|
|
74
|
+
}
|
|
75
|
+
const looksLikeRegistry = parsed && typeof parsed === 'object' && !Array.isArray(parsed.goals) && (parsed.objectives !== undefined || parsed.projectToObjective !== undefined);
|
|
76
|
+
if (looksLikeRegistry) {
|
|
77
|
+
throw setup(`view: ${path} looks like the goals page's list (its "objectives"), which honestweek build reads. view reads a different file, a goal list: { "goals": [{ "id": "g-1", "title": "Ship the parser" }], "events": [] }. The README's goal list section shows the format.`);
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
normalizeGoalRecord(parsed);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
throw setup(`view: ${path} isn't a goal list: ${String(err?.message ?? err).replace(/^replay: /, '')}`);
|
|
83
|
+
}
|
|
84
|
+
return parsed;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The config's limit on log data a saved choice loads, in MB. */
|
|
88
|
+
export const limitOf = (config) => config?.historyLimitMB ?? HISTORY_LIMIT_MB.default;
|
|
89
|
+
|
|
90
|
+
export const absolute = (cwd, p) => (isAbsolute(p) ? p : resolve(cwd, p));
|
|
91
|
+
|
|
92
|
+
/** The person's own week: the config, the window and the goal list the options and the config
|
|
93
|
+
* name. Throws a setup error with a plain message when one of them can't be read. The find,
|
|
94
|
+
* replay, problems and goals commands (lib/ask.mjs) read the same week. */
|
|
95
|
+
export function ownWeek({ values, cwd, env, now }) {
|
|
96
|
+
const configPath = findConfig({ cwd, flag: values['--config'] }).path;
|
|
97
|
+
if (!existsSync(configPath)) throw setup(`view: no config at ${configPath}. Run ${currentCommand()} view in a folder with no config to set one up in your browser (or ${currentCommand()} init in a terminal), or ${currentCommand()} view --demo to look around a made-up week first.`);
|
|
98
|
+
let config;
|
|
99
|
+
try {
|
|
100
|
+
config = loadConfig(configPath);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
throw setup(`view: ${err.message}`);
|
|
103
|
+
}
|
|
104
|
+
const timezone = values['--timezone'] ?? config.week?.timezone;
|
|
105
|
+
if (!validTimezone(timezone)) throw setup(`view: ${JSON.stringify(timezone)} isn't a timezone this machine knows. Use an IANA name such as Europe/Paris or UTC.`);
|
|
106
|
+
const roots = defaultRoots(env);
|
|
107
|
+
// The flags win for one run. With none, a saved choice is sized to fit (the last week, or up to
|
|
108
|
+
// 7 days, always whole); with no saved choice either, it's the last 7 days, as it always was.
|
|
109
|
+
// Either way a window of more than one day loads its newest day first.
|
|
110
|
+
const flagged = ['--days', '--from', '--to'].some((f) => f in values);
|
|
111
|
+
// With each day's history saved, a saved day with no log left counts too: "all history" reaches
|
|
112
|
+
// back to the oldest one, and the limit counts what it brings back.
|
|
113
|
+
const files = savedOptions(config)?.history ? withSavedDays(logFiles(roots, timezone), savedDirOf(dirname(configPath))) : undefined;
|
|
114
|
+
const plan = !flagged && config.history ? planWindow(config.history, { roots, timezone, now: now(), maxBytes: limitOf(config) * 1024 * 1024, ...(files ? { files } : {}) }) : null;
|
|
115
|
+
const { from, to } = plan ?? resolveViewWindow(values, timezone, now());
|
|
116
|
+
const goalsPath = values['--goals'] ? absolute(cwd, values['--goals']) : config.goalsFile ?? null;
|
|
117
|
+
const goalRecord = goalsPath ? readGoalList(goalsPath) : null;
|
|
118
|
+
return { config, configPath, roots, from, to, timezone, goalRecord, windowNote: plan?.note ?? null, ...(files ? { files } : {}) };
|
|
119
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// lib/view/page-link.mjs: which page, query and step a one-time address from `view` may open
|
|
2
|
+
// (issue 198), so an agent or a person can hand someone a link straight to a session or a step.
|
|
3
|
+
//
|
|
4
|
+
// Only view's own pages are accepted, never a path: nothing here joins a path from the request,
|
|
5
|
+
// and the server serves a fixed list of files anyway. The query is parsed and written back
|
|
6
|
+
// encoded, with plain keys only. The part after `#` is the replay page's thread and step, so it
|
|
7
|
+
// takes ids only: letters, digits and `_ . ~ : -`, which also keeps out an `&c=` that would
|
|
8
|
+
// pose as the one-time code key.js reads from that same part of the address. Search is the one
|
|
9
|
+
// page with a part of its own: the id of a search this run keeps in memory (#q=<id>~l or ~w,
|
|
10
|
+
// as search.js writes it), and no query, since its address never holds what was typed.
|
|
11
|
+
|
|
12
|
+
/** The pages a link may open. Setup has its own flow, and the self-test its own address. */
|
|
13
|
+
export const LINK_PAGES = Object.freeze(['problems.html', 'replay.html', 'search.html', 'goal.html', 'settings.html']);
|
|
14
|
+
|
|
15
|
+
const KEY = /^[A-Za-z]{1,24}$/;
|
|
16
|
+
const FRAGMENT = /^[A-Za-z0-9_.~:-]{1,300}$/;
|
|
17
|
+
/** Search's own part after `#`: a search id this run gave out (QUERY_ID_RE in data.mjs). */
|
|
18
|
+
const SEARCH_FRAGMENT = /^q=q[a-p]{16}(?:~[lw])?$/;
|
|
19
|
+
|
|
20
|
+
/** The page's own ids, as `ID` in lib/view/assets/common.js has them (a test keeps the two the same). */
|
|
21
|
+
export const PAGE_IDS = Object.freeze({
|
|
22
|
+
thread: /^th-[a-p]{4,64}$/,
|
|
23
|
+
session: /^[a-z]{2,4}-[a-p]{4,64}$/,
|
|
24
|
+
event: /^[a-z]{2,4}-[a-p]{4,64}(?:[.:][A-Za-z0-9_-]{1,80}){0,6}$/,
|
|
25
|
+
goal: /^(?:[a-z]{1,4}-)?[a-p]{4,64}$/,
|
|
26
|
+
});
|
|
27
|
+
const isId = (kind, v) => typeof v === 'string' && v.length <= 300 && PAGE_IDS[kind].test(v);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* replayPage({ session, thread, event }) -> 'replay.html?session=<id>#<thread>~<step>' | null
|
|
31
|
+
* A session's replay, or one step of it, built the way the Problems page builds its own links:
|
|
32
|
+
* ids only, so null for anything that isn't one of the page's ids.
|
|
33
|
+
*/
|
|
34
|
+
export function replayPage({ session, thread = null, event = null } = {}) {
|
|
35
|
+
if (!isId('session', session)) return null;
|
|
36
|
+
const t = isId('thread', thread) ? thread : '';
|
|
37
|
+
const e = isId('event', event) ? `~${event}` : '';
|
|
38
|
+
return `replay.html?session=${encodeURIComponent(session)}${t || e ? `#${t}${e}` : ''}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* zoomPage(finding) -> 'replay.html?session=<id>#<thread>~zoom~<finding key>' | null
|
|
43
|
+
* The replay zoomed to the steps a finding's check recorded, in its own thread's lane when it has
|
|
44
|
+
* one, else its first: the same link the Problems page builds (zoomHref in assets/problems.js).
|
|
45
|
+
*/
|
|
46
|
+
export function zoomPage(f) {
|
|
47
|
+
const lanes = f?.zoom?.lanes;
|
|
48
|
+
if (!/^pf-[a-p]{12}$/.test(f?.key ?? '') || !Array.isArray(lanes) || !lanes.length) return null;
|
|
49
|
+
const lane = lanes.find((l) => l.thread === f.thread) ?? lanes[0];
|
|
50
|
+
if (!isId('session', lane?.session) || !isId('thread', lane?.thread)) return null;
|
|
51
|
+
return `replay.html?session=${encodeURIComponent(lane.session)}#${lane.thread}~zoom~${f.key}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A goal's page, 'goal.html#<key>', or null. */
|
|
55
|
+
export const goalPage = (key) => (isId('goal', key) ? `goal.html#${key}` : null);
|
|
56
|
+
|
|
57
|
+
/** The Problems page narrowed to one session's findings, 'problems.html?session=<key>', or null. */
|
|
58
|
+
export const problemsPage = (session) => (isId('session', session) ? `problems.html?session=${encodeURIComponent(session)}` : null);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* pageLink(text) -> { page } | { error }
|
|
62
|
+
* `page` is what follows the server's address: a page name, an optional `?query` and an
|
|
63
|
+
* optional `#fragment`, or '' for the Problems page. `error` is one plain line.
|
|
64
|
+
*/
|
|
65
|
+
export function pageLink(text) {
|
|
66
|
+
const raw = String(text ?? '').trim().replace(/^\/+/, '');
|
|
67
|
+
if (!raw) return { page: '' };
|
|
68
|
+
const hashAt = raw.indexOf('#');
|
|
69
|
+
const head = hashAt < 0 ? raw : raw.slice(0, hashAt);
|
|
70
|
+
const fragment = hashAt < 0 ? '' : raw.slice(hashAt + 1);
|
|
71
|
+
const queryAt = head.indexOf('?');
|
|
72
|
+
const name = queryAt < 0 ? head : head.slice(0, queryAt);
|
|
73
|
+
if (!LINK_PAGES.includes(name)) return { error: `${JSON.stringify(name.slice(0, 80))} isn't one of view's pages: ${LINK_PAGES.join(', ')}.` };
|
|
74
|
+
const params = new URLSearchParams(queryAt < 0 ? '' : head.slice(queryAt + 1));
|
|
75
|
+
for (const key of params.keys()) {
|
|
76
|
+
if (!KEY.test(key)) return { error: `${JSON.stringify(key.slice(0, 40))} isn't a key view's pages read.` };
|
|
77
|
+
}
|
|
78
|
+
const query = params.toString();
|
|
79
|
+
if (name === 'search.html') {
|
|
80
|
+
if (query) return { error: "Search takes no query: its address never holds what was typed. Copy a search's address from the page, #q= and all." };
|
|
81
|
+
if (fragment && !SEARCH_FRAGMENT.test(fragment)) return { error: "Search's part after # can only be a search's own id, as the page's address shows it (#q=<id>~w)." };
|
|
82
|
+
} else if (fragment && !FRAGMENT.test(fragment)) return { error: 'the part after # can hold only ids (letters, digits, and _ . ~ : -).' };
|
|
83
|
+
return { page: `${name}${query ? `?${query}` : ''}${fragment ? `#${fragment}` : ''}` };
|
|
84
|
+
}
|