@erclx/canon 5.78.0 → 5.79.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/CHANGELOG.md +23 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +12 -0
- package/claude/skills/app-drive/SKILL.md +44 -8
- package/claude/skills/app-drive/references/config.md +18 -8
- package/claude/skills/app-drive/references/drive-record.md +4 -0
- package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
- package/claude/skills/auto-ship/SKILL.md +5 -4
- package/claude/skills/auto-ship/references/draft-mark.md +33 -15
- package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/REQUIREMENT.md +2 -0
- package/claude/skills/git-pr/SKILL.md +2 -0
- package/claude/skills/git-pr/references/final-command.md +34 -2
- package/claude/skills/git-pr/references/pr.md +1 -1
- package/claude/skills/git-ship/REQUIREMENT.md +2 -2
- package/claude/skills/git-ship/SKILL.md +16 -11
- package/claude/skills/git-ship/references/ci-watch.md +28 -4
- package/claude/skills/markdown-craft/references/markdown.md +4 -4
- package/claude/skills/plan-feature/references/plan.md +2 -0
- package/claude/skills/review-address/SKILL.md +2 -1
- package/claude/skills/review-pr/references/key-changes.md +2 -1
- package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
- package/claude/skills/role-worker/SKILL.md +1 -1
- package/claude/skills/task-board/SKILL.md +1 -0
- package/claude/skills/task-board/references/tasks.md +1 -0
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/parallel-features.md +2 -1
- package/package.json +1 -1
- package/src/commands/pr.ts +138 -15
- package/src/commands/sessions.ts +88 -6
- package/src/commands/tasks.ts +41 -2
- package/src/git/files.ts +5 -2
- package/src/indexes/frontmatter.ts +27 -6
- package/src/indexes/render.ts +21 -3
- package/src/markdown/colons.ts +14 -4
- package/src/markdown/heading-case.ts +9 -2
- package/src/pr/bijection.ts +8 -0
- package/src/pr/evidence.ts +38 -9
- package/src/pr/health.ts +63 -15
- package/src/pr/links.ts +160 -0
- package/src/pr/local.ts +17 -4
- package/src/pr/paths.ts +20 -2
- package/src/sessions/stop.ts +145 -0
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/take/encode.ts +16 -42
- package/src/take/frame-clock.ts +46 -0
- package/src/take/screencast.ts +114 -11
- package/src/tasks/holders.ts +198 -0
- package/src/tasks/validate.ts +148 -0
- /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
package/src/pr/health.ts
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
|
+
/** The key listing folders whose listeners skip the health paths. */
|
|
5
|
+
export const PAGE_ONLY_KEY = 'page-only'
|
|
6
|
+
|
|
4
7
|
/** Where a project declares what `canon pr local` checks beyond the page. */
|
|
5
8
|
export const LOCAL_CONFIG_REL = join('canon', 'config', 'local.toml')
|
|
6
9
|
|
|
7
10
|
/**
|
|
8
|
-
* The health paths a project declared
|
|
9
|
-
* `health = []`. `invalid` is a
|
|
10
|
-
*
|
|
11
|
-
* fallback is the failure the
|
|
11
|
+
* The health paths a project declared, with the folders whose listeners skip
|
|
12
|
+
* them. `none` is an absent file or an empty `health = []`. `invalid` is a
|
|
13
|
+
* declaration that exists and cannot be used, which refuses rather than
|
|
14
|
+
* falling back to the page-only probe, since that fallback is the failure the
|
|
15
|
+
* declaration exists to catch.
|
|
12
16
|
*/
|
|
13
17
|
export type HealthDeclaration =
|
|
14
|
-
| {
|
|
15
|
-
|
|
18
|
+
| {
|
|
19
|
+
readonly kind: 'paths'
|
|
20
|
+
readonly paths: readonly string[]
|
|
21
|
+
readonly pageOnly: readonly string[]
|
|
22
|
+
}
|
|
23
|
+
| { readonly kind: 'none'; readonly pageOnly: readonly string[] }
|
|
16
24
|
| { readonly kind: 'invalid'; readonly reason: string }
|
|
17
25
|
|
|
18
26
|
/**
|
|
@@ -28,9 +36,24 @@ function isLocalPath(entry: unknown): entry is string {
|
|
|
28
36
|
)
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
/**
|
|
40
|
+
* A repository-relative folder as whole segments, or undefined when the entry
|
|
41
|
+
* is not one. Absolute paths, drive letters, backslashes, and `..` refuse,
|
|
42
|
+
* since each would name a place outside the repository. A leading `./` and
|
|
43
|
+
* trailing slashes are dropped so the match compares segments alone.
|
|
44
|
+
*/
|
|
45
|
+
function toFolder(entry: unknown): string | undefined {
|
|
46
|
+
if (typeof entry !== 'string') return undefined
|
|
47
|
+
if (entry.startsWith('/') || entry.includes('\\')) return undefined
|
|
48
|
+
if (/^[A-Za-z]:/.test(entry)) return undefined
|
|
49
|
+
const segments = entry.split('/').filter((s) => s !== '' && s !== '.')
|
|
50
|
+
if (segments.length === 0 || segments.includes('..')) return undefined
|
|
51
|
+
return segments.join('/')
|
|
52
|
+
}
|
|
53
|
+
|
|
31
54
|
export function readHealthPaths(root: string): HealthDeclaration {
|
|
32
55
|
const path = join(root, LOCAL_CONFIG_REL)
|
|
33
|
-
if (!existsSync(path)) return { kind: 'none' }
|
|
56
|
+
if (!existsSync(path)) return { kind: 'none', pageOnly: [] }
|
|
34
57
|
|
|
35
58
|
let parsed: Record<string, unknown>
|
|
36
59
|
try {
|
|
@@ -42,15 +65,40 @@ export function readHealthPaths(root: string): HealthDeclaration {
|
|
|
42
65
|
return { kind: 'invalid', reason: `${LOCAL_CONFIG_REL} is not valid TOML` }
|
|
43
66
|
}
|
|
44
67
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
const hasHealth = 'health' in parsed
|
|
69
|
+
const hasPageOnly = PAGE_ONLY_KEY in parsed
|
|
70
|
+
if (!hasHealth && !hasPageOnly) return invalidHealth()
|
|
71
|
+
|
|
72
|
+
let paths: readonly string[] = []
|
|
73
|
+
if (hasHealth) {
|
|
74
|
+
const declared = parsed.health
|
|
75
|
+
if (!Array.isArray(declared) || !declared.every(isLocalPath)) {
|
|
76
|
+
return invalidHealth()
|
|
50
77
|
}
|
|
78
|
+
paths = declared
|
|
51
79
|
}
|
|
52
80
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
81
|
+
let pageOnly: readonly string[] = []
|
|
82
|
+
if (hasPageOnly) {
|
|
83
|
+
const declared = parsed[PAGE_ONLY_KEY]
|
|
84
|
+
const folders = Array.isArray(declared) ? declared.map(toFolder) : []
|
|
85
|
+
if (!Array.isArray(declared) || folders.includes(undefined)) {
|
|
86
|
+
return {
|
|
87
|
+
kind: 'invalid',
|
|
88
|
+
reason: `${LOCAL_CONFIG_REL} needs ${PAGE_ONLY_KEY} as a list of folders relative to the repository, with no leading / and no ..`,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
pageOnly = folders as string[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return paths.length === 0
|
|
95
|
+
? { kind: 'none', pageOnly }
|
|
96
|
+
: { kind: 'paths', paths, pageOnly }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function invalidHealth(): HealthDeclaration {
|
|
100
|
+
return {
|
|
101
|
+
kind: 'invalid',
|
|
102
|
+
reason: `${LOCAL_CONFIG_REL} needs health as a list of paths, each starting with a single /`,
|
|
103
|
+
}
|
|
56
104
|
}
|
package/src/pr/links.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { findBacktickSpans } from '@/markdown/backticks'
|
|
3
|
+
import { KEY_CHANGES, LINE_SUFFIX, readSectionAt } from '@/pr/paths'
|
|
4
|
+
|
|
5
|
+
/** A Key changes link whose anchor hashes a path other than its label's. */
|
|
6
|
+
export interface Mislinked {
|
|
7
|
+
readonly path: string
|
|
8
|
+
readonly anchor: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The fragment GitHub gives a file's diff in a pull request's Files tab, being
|
|
13
|
+
* `diff-` and the SHA-256 of the raw path. Read off a merged pull request's
|
|
14
|
+
* rendered page, so a path holding a space or a bracket hashes as written.
|
|
15
|
+
*/
|
|
16
|
+
export function diffAnchor(path: string): string {
|
|
17
|
+
return `diff-${createHash('sha256').update(path).digest('hex')}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Located {
|
|
21
|
+
readonly text: string
|
|
22
|
+
readonly offset: number
|
|
23
|
+
readonly whole: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The section's text and where it starts, in the body with line endings normalized. */
|
|
27
|
+
function locateSection(body: string, title: string): Located | undefined {
|
|
28
|
+
const whole = body.replace(/\r\n/g, '\n')
|
|
29
|
+
const found = readSectionAt(whole, title)
|
|
30
|
+
return found === undefined ? undefined : { ...found, whole }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Blanks every code span, keeping offsets, so a bracket inside a path never reads as link syntax. */
|
|
34
|
+
function maskSpans(text: string): string {
|
|
35
|
+
let out = text
|
|
36
|
+
for (const span of findBacktickSpans(text)) {
|
|
37
|
+
out =
|
|
38
|
+
out.slice(0, span.start) +
|
|
39
|
+
' '.repeat(span.end - span.start) +
|
|
40
|
+
out.slice(span.end)
|
|
41
|
+
}
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* An inline link: a destination bare or in angle brackets, then an optional
|
|
47
|
+
* quoted title. A form this misses reads as no link, and a span inside it then
|
|
48
|
+
* gets a second link nested in the first.
|
|
49
|
+
*/
|
|
50
|
+
const LINK =
|
|
51
|
+
/\[[^\]]*\]\(\s*(<[^>]*>|[^)\s]*)(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/dg
|
|
52
|
+
const DIFF_DESTINATION = /#diff-[0-9a-f]{64}$/
|
|
53
|
+
|
|
54
|
+
interface Edit {
|
|
55
|
+
readonly start: number
|
|
56
|
+
readonly end: number
|
|
57
|
+
readonly text: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Writes each code span in `## Key changes` that names a changed file as a link
|
|
62
|
+
* to that file's diff, keeping the span as the label.
|
|
63
|
+
*
|
|
64
|
+
* Only an exact changed path is linked. A partial spelling or a folder names no
|
|
65
|
+
* single row in the Files tab, and a link to a guessed row is worse than none.
|
|
66
|
+
*
|
|
67
|
+
* A span that is the whole label of an existing diff link has its destination
|
|
68
|
+
* rewritten, which is how a wrong anchor clears. A span anywhere else inside a
|
|
69
|
+
* link's brackets is left alone, since a link nested in a link breaks the
|
|
70
|
+
* bullet, and the rewrite stays idempotent.
|
|
71
|
+
*
|
|
72
|
+
* `filesUrl` is the pull request's `/files` address, absolute because the body
|
|
73
|
+
* becomes the squash commit message, where a relative link resolves nowhere.
|
|
74
|
+
*/
|
|
75
|
+
export function linkKeyChanges(
|
|
76
|
+
body: string,
|
|
77
|
+
changed: readonly string[],
|
|
78
|
+
filesUrl: string,
|
|
79
|
+
): string {
|
|
80
|
+
const located = locateSection(body, KEY_CHANGES)
|
|
81
|
+
if (located === undefined) return body
|
|
82
|
+
|
|
83
|
+
const { text } = located
|
|
84
|
+
const known = new Set(changed)
|
|
85
|
+
const links = [...maskSpans(text).matchAll(LINK)].map((match) => ({
|
|
86
|
+
start: match.index ?? 0,
|
|
87
|
+
end: (match.index ?? 0) + match[0].length,
|
|
88
|
+
destination: match[1] ?? '',
|
|
89
|
+
destinationStart: match.indices?.[1]?.[0] ?? 0,
|
|
90
|
+
}))
|
|
91
|
+
|
|
92
|
+
const edits: Edit[] = []
|
|
93
|
+
for (const span of findBacktickSpans(text)) {
|
|
94
|
+
const path = span.content.replace(LINE_SUFFIX, '')
|
|
95
|
+
if (!known.has(path)) continue
|
|
96
|
+
const target = `${filesUrl}#${diffAnchor(path)}`
|
|
97
|
+
|
|
98
|
+
const link = links.find((l) => l.start < span.start && span.end <= l.end)
|
|
99
|
+
if (link === undefined) {
|
|
100
|
+
edits.push({
|
|
101
|
+
start: span.start,
|
|
102
|
+
end: span.end,
|
|
103
|
+
text: `[${text.slice(span.start, span.end)}](${target})`,
|
|
104
|
+
})
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const wholeLabel =
|
|
109
|
+
span.start === link.start + 1 && text.startsWith('](', span.end)
|
|
110
|
+
if (!wholeLabel || !DIFF_DESTINATION.test(link.destination)) continue
|
|
111
|
+
edits.push({
|
|
112
|
+
start: link.destinationStart,
|
|
113
|
+
end: link.destinationStart + link.destination.length,
|
|
114
|
+
text: target,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let out = ''
|
|
119
|
+
let cursor = 0
|
|
120
|
+
for (const edit of edits) {
|
|
121
|
+
out += text.slice(cursor, edit.start) + edit.text
|
|
122
|
+
cursor = edit.end
|
|
123
|
+
}
|
|
124
|
+
out += text.slice(cursor)
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
located.whole.slice(0, located.offset) +
|
|
128
|
+
out +
|
|
129
|
+
located.whole.slice(located.offset + text.length)
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const DIFF_LINK = /\[(`+)([^`]+)\1\]\([^)\s]*#(diff-[0-9a-f]{64})\)/g
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Every Key changes link whose label is a changed path and whose anchor hashes
|
|
137
|
+
* another path.
|
|
138
|
+
*
|
|
139
|
+
* A label that is no changed path, such as a bare file name or a function, is
|
|
140
|
+
* left unjudged: it can open the right file under a spelling this cannot map
|
|
141
|
+
* back to a row, and an accusation needs the row.
|
|
142
|
+
*/
|
|
143
|
+
export function findMislinked(
|
|
144
|
+
body: string,
|
|
145
|
+
changed: readonly string[],
|
|
146
|
+
): Mislinked[] {
|
|
147
|
+
const located = locateSection(body, KEY_CHANGES)
|
|
148
|
+
if (located === undefined) return []
|
|
149
|
+
|
|
150
|
+
const known = new Set(changed)
|
|
151
|
+
const out: Mislinked[] = []
|
|
152
|
+
for (const match of located.text.matchAll(DIFF_LINK)) {
|
|
153
|
+
const path = (match[2] ?? '').trim().replace(LINE_SUFFIX, '')
|
|
154
|
+
const anchor = match[3] ?? ''
|
|
155
|
+
if (known.has(path) && diffAnchor(path) !== anchor) {
|
|
156
|
+
out.push({ path, anchor })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out
|
|
160
|
+
}
|
package/src/pr/local.ts
CHANGED
|
@@ -45,6 +45,11 @@ async function firstFailingPath(
|
|
|
45
45
|
return undefined
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** Whether `cwd` is `folder` or inside it, compared on whole path segments. */
|
|
49
|
+
function isUnder(cwd: string, folder: string): boolean {
|
|
50
|
+
return cwd === folder || cwd.startsWith(`${folder}/`)
|
|
51
|
+
}
|
|
52
|
+
|
|
48
53
|
/**
|
|
49
54
|
* Finds the server this worktree is running: the lowest port whose listening
|
|
50
55
|
* process runs inside the worktree, answers an HTTP probe, and passes every
|
|
@@ -53,11 +58,14 @@ async function firstFailingPath(
|
|
|
53
58
|
* lower one is the page by convention. Health is read on the page's own port,
|
|
54
59
|
* since that is the address a reviewer drives, and a page whose backend fails
|
|
55
60
|
* refuses as `unhealthy` naming the lowest page port's first failing path.
|
|
61
|
+
* A port whose listener runs under a `pageOnly` folder, matched on whole
|
|
62
|
+
* segments, is found on the page probe alone.
|
|
56
63
|
*/
|
|
57
64
|
export async function findLocalServer(
|
|
58
65
|
worktreeRoot: string,
|
|
59
66
|
runner: LocalRunner,
|
|
60
67
|
healthPaths: readonly string[] = [],
|
|
68
|
+
pageOnly: readonly string[] = [],
|
|
61
69
|
): Promise<LocalResult> {
|
|
62
70
|
const listeners = await runner.listListeners()
|
|
63
71
|
if (listeners === undefined) {
|
|
@@ -65,17 +73,22 @@ export async function findLocalServer(
|
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
const cwdByPid = new Map<number, string | undefined>()
|
|
68
|
-
const ports = new
|
|
76
|
+
const ports = new Map<number, boolean>()
|
|
77
|
+
const folders = pageOnly.map((folder) => `${worktreeRoot}/${folder}`)
|
|
69
78
|
for (const { pid, port } of listeners) {
|
|
70
79
|
if (!cwdByPid.has(pid)) cwdByPid.set(pid, await runner.cwdOf(pid))
|
|
71
80
|
const cwd = cwdByPid.get(pid)
|
|
72
|
-
if (cwd
|
|
81
|
+
if (cwd === undefined || !belongsTo(cwd, worktreeRoot)) continue
|
|
82
|
+
const skipsHealth = folders.some((folder) => isUnder(cwd, folder))
|
|
83
|
+
ports.set(port, (ports.get(port) ?? false) || skipsHealth)
|
|
73
84
|
}
|
|
74
85
|
|
|
75
86
|
let unhealthy: (HealthFailure & { readonly port: number }) | undefined
|
|
76
|
-
for (const port of [...ports].sort((a, b) => a - b)) {
|
|
87
|
+
for (const [port, skipsHealth] of [...ports].sort(([a], [b]) => a - b)) {
|
|
77
88
|
if (!(await runner.probe(port))) continue
|
|
78
|
-
const failure =
|
|
89
|
+
const failure = skipsHealth
|
|
90
|
+
? undefined
|
|
91
|
+
: await firstFailingPath(port, healthPaths, runner)
|
|
79
92
|
if (failure === undefined) {
|
|
80
93
|
return { kind: 'found', port, url: `http://localhost:${port}` }
|
|
81
94
|
}
|
package/src/pr/paths.ts
CHANGED
|
@@ -77,7 +77,7 @@ const HEADING = /^(#{1,6})\s+(.+?)\s*$/
|
|
|
77
77
|
* A `file.ts:42` or `file.ts:42-58` suffix, which is a reader's click target
|
|
78
78
|
* rather than part of the name.
|
|
79
79
|
*/
|
|
80
|
-
const LINE_SUFFIX = /:\d+(?:-\d+)?$/
|
|
80
|
+
export const LINE_SUFFIX = /:\d+(?:-\d+)?$/
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
83
|
* A span that carries a line suffix and nothing else, such as `:642` beside
|
|
@@ -214,6 +214,21 @@ function disclaimsChange(bullet: string): boolean {
|
|
|
214
214
|
* reports rather than reading as an empty section.
|
|
215
215
|
*/
|
|
216
216
|
export function readSection(body: string, title: string): string | undefined {
|
|
217
|
+
return readSectionAt(body, title)?.text
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The section's text and the character offset it starts at, counted in the body
|
|
222
|
+
* with its line endings normalized to `\n`.
|
|
223
|
+
*
|
|
224
|
+
* The offset is what lets a caller rewrite the section in place. Searching the
|
|
225
|
+
* body for the text instead finds the first copy, so an identical block above
|
|
226
|
+
* the heading would take the edit.
|
|
227
|
+
*/
|
|
228
|
+
export function readSectionAt(
|
|
229
|
+
body: string,
|
|
230
|
+
title: string,
|
|
231
|
+
): { readonly text: string; readonly offset: number } | undefined {
|
|
217
232
|
const lines = body.replace(/\r\n/g, '\n').split('\n')
|
|
218
233
|
const wanted = title.toLowerCase()
|
|
219
234
|
|
|
@@ -246,7 +261,10 @@ export function readSection(body: string, title: string): string | undefined {
|
|
|
246
261
|
out.push(line)
|
|
247
262
|
}
|
|
248
263
|
|
|
249
|
-
|
|
264
|
+
const offset = lines
|
|
265
|
+
.slice(0, start)
|
|
266
|
+
.reduce((sum, line) => sum + line.length + 1, 0)
|
|
267
|
+
return { text: out.join('\n'), offset }
|
|
250
268
|
}
|
|
251
269
|
|
|
252
270
|
/**
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { closeSync, fstatSync, openSync, readSync } from 'node:fs'
|
|
2
|
+
import type { ResolvedSession } from '@/sessions/resolve'
|
|
3
|
+
import { locateTranscript, projectsDir } from '@/sessions/transcript'
|
|
4
|
+
|
|
5
|
+
export interface StopReading {
|
|
6
|
+
/**
|
|
7
|
+
* `usage-limit` means the last assistant entry is a limit stop, passing over
|
|
8
|
+
* a `<synthetic>` entry with no error, such as the one a client restart
|
|
9
|
+
* writes. `unknown` means the read failed or found a limit-worded entry of
|
|
10
|
+
* another shape, so the row may be stopped and nothing here can say.
|
|
11
|
+
*/
|
|
12
|
+
readonly stopped: 'usage-limit' | 'unknown' | null
|
|
13
|
+
readonly stopResets: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const NOT_STOPPED: StopReading = { stopped: null, stopResets: null }
|
|
17
|
+
const UNREADABLE: StopReading = { stopped: 'unknown', stopResets: null }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The largest measured distance from a final stop to the end of its file is
|
|
21
|
+
* under 4 KiB, while a live transcript reaches hundreds of megabytes, so the
|
|
22
|
+
* read stays positional and never loads the file.
|
|
23
|
+
*/
|
|
24
|
+
const WINDOW_BYTES = 1024 * 1024
|
|
25
|
+
|
|
26
|
+
function readTail(path: string): {
|
|
27
|
+
readonly text: string
|
|
28
|
+
readonly cut: boolean
|
|
29
|
+
} {
|
|
30
|
+
const fd = openSync(path, 'r')
|
|
31
|
+
try {
|
|
32
|
+
const { size } = fstatSync(fd)
|
|
33
|
+
const length = Math.min(size, WINDOW_BYTES)
|
|
34
|
+
const start = size - length
|
|
35
|
+
const buffer = Buffer.alloc(length)
|
|
36
|
+
let filled = 0
|
|
37
|
+
while (filled < length) {
|
|
38
|
+
const read = readSync(fd, buffer, filled, length - filled, start + filled)
|
|
39
|
+
if (read === 0) break
|
|
40
|
+
filled += read
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
text: buffer.subarray(0, filled).toString('utf8'),
|
|
44
|
+
cut: start > 0,
|
|
45
|
+
}
|
|
46
|
+
} finally {
|
|
47
|
+
closeSync(fd)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseEntry(line: string): Record<string, unknown> | null {
|
|
52
|
+
try {
|
|
53
|
+
const parsed: unknown = JSON.parse(line)
|
|
54
|
+
return typeof parsed === 'object' && parsed !== null
|
|
55
|
+
? (parsed as Record<string, unknown>)
|
|
56
|
+
: null
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function entryText(message: Record<string, unknown>): string {
|
|
63
|
+
if (!Array.isArray(message.content)) return ''
|
|
64
|
+
return message.content
|
|
65
|
+
.map((block: { text?: unknown } | null) =>
|
|
66
|
+
typeof block?.text === 'string' ? block.text : '',
|
|
67
|
+
)
|
|
68
|
+
.join('\n')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Walks back to the entry that decides the row. No published contract describes
|
|
73
|
+
* the stop entry, so this rests on a measured shape: `error: "rate_limit"`, a
|
|
74
|
+
* `<synthetic>` model, `isApiErrorMessage`, and text naming the limit and its
|
|
75
|
+
* reset.
|
|
76
|
+
*
|
|
77
|
+
* A generated entry with limit wording and any other shape reads `unknown`,
|
|
78
|
+
* since a release renaming the field or the type still parses cleanly and would
|
|
79
|
+
* otherwise read as a session that never stopped. A `<synthetic>` entry with no
|
|
80
|
+
* error and no limit wording is passed over, since a client restart writes one
|
|
81
|
+
* reading `No response requested.` with no model turn behind it.
|
|
82
|
+
*/
|
|
83
|
+
function readEntries(text: string, cut: boolean): StopReading {
|
|
84
|
+
const lines = text.split('\n')
|
|
85
|
+
// The first line of a cut window usually starts mid-entry, and one that
|
|
86
|
+
// happens to parse cannot be told apart from it.
|
|
87
|
+
const first = cut ? 1 : 0
|
|
88
|
+
|
|
89
|
+
for (let index = lines.length - 1; index >= first; index -= 1) {
|
|
90
|
+
const entry = parseEntry(lines[index] ?? '')
|
|
91
|
+
if (entry === null || entry.isSidechain === true) continue
|
|
92
|
+
|
|
93
|
+
const message =
|
|
94
|
+
typeof entry.message === 'object' && entry.message !== null
|
|
95
|
+
? (entry.message as Record<string, unknown>)
|
|
96
|
+
: {}
|
|
97
|
+
const synthetic = message.model === '<synthetic>'
|
|
98
|
+
const generated = synthetic || entry.isApiErrorMessage === true
|
|
99
|
+
const entryWords = generated ? entryText(message) : ''
|
|
100
|
+
const worded = /hit your .* limit/.test(entryWords)
|
|
101
|
+
|
|
102
|
+
if (entry.type !== 'assistant') {
|
|
103
|
+
if (worded) return UNREADABLE
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (entry.error === 'rate_limit') {
|
|
108
|
+
return {
|
|
109
|
+
stopped: 'usage-limit',
|
|
110
|
+
stopResets: /resets (.+)/.exec(entryText(message))?.[1]?.trim() ?? null,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (worded) return UNREADABLE
|
|
114
|
+
if (synthetic && entry.error === undefined) continue
|
|
115
|
+
|
|
116
|
+
return NOT_STOPPED
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return cut ? UNREADABLE : NOT_STOPPED
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reads whether a usage limit stopped one roster row, from the tail of its
|
|
124
|
+
* transcript. A `busy` row reads nothing, since a turn is running and a resume
|
|
125
|
+
* may be in flight.
|
|
126
|
+
*/
|
|
127
|
+
export function readStop(
|
|
128
|
+
row: Pick<ResolvedSession, 'status' | 'sessionId'>,
|
|
129
|
+
dir: string = projectsDir(),
|
|
130
|
+
): StopReading {
|
|
131
|
+
if (row.status === 'busy') return NOT_STOPPED
|
|
132
|
+
if (row.sessionId === null) return UNREADABLE
|
|
133
|
+
|
|
134
|
+
const located = locateTranscript(row.sessionId, dir)
|
|
135
|
+
if (located === null) return UNREADABLE
|
|
136
|
+
|
|
137
|
+
let tail: ReturnType<typeof readTail>
|
|
138
|
+
try {
|
|
139
|
+
tail = readTail(located.transcript)
|
|
140
|
+
} catch {
|
|
141
|
+
return UNREADABLE
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return readEntries(tail.text, tail.cut)
|
|
145
|
+
}
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
readSideFolder,
|
|
22
22
|
readTranscript,
|
|
23
23
|
transcriptFacts,
|
|
24
|
-
} from '@/sessions/
|
|
24
|
+
} from '@/sessions/transcript'
|
|
25
25
|
|
|
26
26
|
/** Bumped when a field changes meaning, so an older import refuses a newer bundle. */
|
|
27
27
|
export const BUNDLE_FORMAT = 1
|
package/src/take/encode.ts
CHANGED
|
@@ -75,18 +75,24 @@ export function openFrameEncoder(out: string, fps: number): FrameEncoder {
|
|
|
75
75
|
// Held so a failure while frames are still being written surfaces at close
|
|
76
76
|
// rather than as an unhandled rejection.
|
|
77
77
|
done.catch(() => {})
|
|
78
|
+
// Rejects once FFmpeg is gone, since a full pipe with no reader never drains.
|
|
79
|
+
const stopped = done.then(() => {
|
|
80
|
+
throw new Error('ffmpeg stopped reading frames')
|
|
81
|
+
})
|
|
82
|
+
stopped.catch(() => {})
|
|
83
|
+
// A write to the dead process's pipe fails here, and `stopped` reports it.
|
|
84
|
+
proc.stdin?.on('error', () => {})
|
|
78
85
|
|
|
79
86
|
return {
|
|
80
|
-
write: (jpeg) =>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}),
|
|
87
|
+
write: async (jpeg) => {
|
|
88
|
+
const stdin = proc.stdin
|
|
89
|
+
if (!stdin || stdin.destroyed) return stopped
|
|
90
|
+
if (stdin.write(jpeg)) return
|
|
91
|
+
await Promise.race([
|
|
92
|
+
new Promise<void>((resolve) => stdin.once('drain', resolve)),
|
|
93
|
+
stopped,
|
|
94
|
+
])
|
|
95
|
+
},
|
|
90
96
|
close: async () => {
|
|
91
97
|
proc.stdin?.end()
|
|
92
98
|
await done
|
|
@@ -97,38 +103,6 @@ export function openFrameEncoder(out: string, fps: number): FrameEncoder {
|
|
|
97
103
|
}
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
/**
|
|
101
|
-
* Re-encodes a recorded clip to the same H.264 output at a constant `fps`, cut
|
|
102
|
-
* at `ms`. Playwright's recorder pads its webm past the last frame by at least
|
|
103
|
-
* a second, so the cut is what ends the clip where the take's clock stopped.
|
|
104
|
-
* A shorter input stays as it is, since `-t` never extends one.
|
|
105
|
-
*/
|
|
106
|
-
export async function transcode(
|
|
107
|
-
input: string,
|
|
108
|
-
out: string,
|
|
109
|
-
fps: number,
|
|
110
|
-
ms: number,
|
|
111
|
-
): Promise<void> {
|
|
112
|
-
const proc = spawn(
|
|
113
|
-
'ffmpeg',
|
|
114
|
-
[
|
|
115
|
-
'-y',
|
|
116
|
-
'-loglevel',
|
|
117
|
-
'error',
|
|
118
|
-
'-i',
|
|
119
|
-
input,
|
|
120
|
-
'-t',
|
|
121
|
-
(ms / 1000).toFixed(3),
|
|
122
|
-
'-r',
|
|
123
|
-
String(fps),
|
|
124
|
-
...OUTPUT,
|
|
125
|
-
out,
|
|
126
|
-
],
|
|
127
|
-
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
|
128
|
-
)
|
|
129
|
-
await finished(proc)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
106
|
/**
|
|
133
107
|
* Counts a clip's frames, which a real-time take cannot know in advance and a
|
|
134
108
|
* camera track has to match one for one.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Places a screencast's frames on the take's monotonic clock. Playwright stamps
|
|
3
|
+
* a frame in wall time, and a time service can step the wall clock mid-take, so
|
|
4
|
+
* a stamp is only ever read against the wall clock of the moment it arrives.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A frame's time on the monotonic clock. A step between the frame's swap and its
|
|
9
|
+
* arrival cannot be told apart from latency, so a forward one lands that frame
|
|
10
|
+
* early and a backward one is capped at the arrival, the latest it can be.
|
|
11
|
+
*/
|
|
12
|
+
export function frameStamp(reading: {
|
|
13
|
+
readonly presentedWall: number
|
|
14
|
+
readonly wallNow: number
|
|
15
|
+
readonly monoNow: number
|
|
16
|
+
}): number {
|
|
17
|
+
const { presentedWall, wallNow, monoNow } = reading
|
|
18
|
+
return Math.min(monoNow, presentedWall - (wallNow - monoNow))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Which frame fills each slot of a clip, as indexes into `stamps`, which holds
|
|
23
|
+
* the frames in arrival order. A slot takes the newest frame stamped at or
|
|
24
|
+
* before it, and slot 0 takes the first frame whenever it was presented. The
|
|
25
|
+
* indexes never fall, so an early stamp cannot reorder the clip. Empty when no
|
|
26
|
+
* frame arrived.
|
|
27
|
+
*/
|
|
28
|
+
export function slotFrames(
|
|
29
|
+
stamps: readonly number[],
|
|
30
|
+
clip: {
|
|
31
|
+
readonly startMs: number
|
|
32
|
+
readonly lengthMs: number
|
|
33
|
+
readonly frameMs: number
|
|
34
|
+
},
|
|
35
|
+
): number[] {
|
|
36
|
+
if (stamps.length === 0) return []
|
|
37
|
+
const slots = Math.round(clip.lengthMs / clip.frameMs)
|
|
38
|
+
const filled: number[] = []
|
|
39
|
+
let at = 0
|
|
40
|
+
for (let slot = 0; slot < slots; slot += 1) {
|
|
41
|
+
const clock = clip.startMs + slot * clip.frameMs
|
|
42
|
+
while ((stamps[at + 1] ?? Infinity) <= clock) at += 1
|
|
43
|
+
filled.push(at)
|
|
44
|
+
}
|
|
45
|
+
return filled
|
|
46
|
+
}
|