@threadcrumbs/common 0.1.3 → 0.1.5
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/NOTICES.md +96 -0
- package/README.md +9 -2
- package/lib/claude-transcript.mjs +41 -5
- package/lib/context-breakdown.mjs +25 -17
- package/lib/jsonl-follower.mjs +15 -1
- package/lib/opencode-store.mjs +89 -23
- package/lib/telemetry-fields.mjs +38 -0
- package/package.json +18 -3
- package/telemetry/README.md +19 -4
- package/telemetry/antigravity.mjs +3 -1
- package/telemetry/claude-code.mjs +15 -8
- package/telemetry/codex.mjs +40 -24
- package/telemetry/cursor.mjs +3 -1
- package/telemetry/hermes.mjs +3 -1
- package/ui/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
- package/ui/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/ui/fonts/inter-tight-latin-ext-wght-normal.woff2 +0 -0
- package/ui/fonts/inter-tight-latin-wght-normal.woff2 +0 -0
- package/ui/fonts/jetbrains-mono-latin-ext-wght-normal.woff2 +0 -0
- package/ui/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- package/ui/fonts.css +48 -0
- package/ui/gauge.css +75 -0
- package/ui/gauge.mjs +14 -0
- package/ui/html.mjs +1 -0
- package/ui/icons.mjs +98 -0
- package/ui/status.css +75 -0
- package/ui/status.mjs +36 -0
- package/ui/theme.css +104 -0
- package/ui/where.css +43 -0
- package/ui/where.mjs +24 -0
package/NOTICES.md
CHANGED
|
@@ -31,3 +31,99 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
|
31
31
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
32
32
|
SOFTWARE.
|
|
33
33
|
```
|
|
34
|
+
|
|
35
|
+
### Fonts
|
|
36
|
+
|
|
37
|
+
`ui/fonts/` includes these fonts as .woff2 files, from the @fontsource packages. Each is used under the SIL Open Font License, Version 1.1, which follows the list.
|
|
38
|
+
|
|
39
|
+
- Inter and Inter Tight: Copyright 2016 and 2022 The Inter Project Authors (https://github.com/rsms/inter)
|
|
40
|
+
- JetBrains Mono: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
44
|
+
-----------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
PREAMBLE
|
|
47
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
48
|
+
development of collaborative font projects, to support the font creation
|
|
49
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
50
|
+
open framework in which fonts may be shared and improved in partnership
|
|
51
|
+
with others.
|
|
52
|
+
|
|
53
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
54
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
55
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
56
|
+
redistributed and/or sold with any software provided that any reserved
|
|
57
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
58
|
+
however, cannot be released under any other type of license. The
|
|
59
|
+
requirement for fonts to remain under this license does not apply
|
|
60
|
+
to any document created using the fonts or their derivatives.
|
|
61
|
+
|
|
62
|
+
DEFINITIONS
|
|
63
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
64
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
65
|
+
include source files, build scripts and documentation.
|
|
66
|
+
|
|
67
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
68
|
+
copyright statement(s).
|
|
69
|
+
|
|
70
|
+
"Original Version" refers to the collection of Font Software components as
|
|
71
|
+
distributed by the Copyright Holder(s).
|
|
72
|
+
|
|
73
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
74
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
75
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
76
|
+
new environment.
|
|
77
|
+
|
|
78
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
79
|
+
writer or other person who contributed to the Font Software.
|
|
80
|
+
|
|
81
|
+
PERMISSION & CONDITIONS
|
|
82
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
83
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
84
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
85
|
+
Software, subject to the following conditions:
|
|
86
|
+
|
|
87
|
+
1) Neither the Font Software nor any of its individual components,
|
|
88
|
+
in Original or Modified Versions, may be sold by itself.
|
|
89
|
+
|
|
90
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
91
|
+
redistributed and/or sold with any software, provided that each copy
|
|
92
|
+
contains the above copyright notice and this license. These can be
|
|
93
|
+
included either as stand-alone text files, human-readable headers or
|
|
94
|
+
in the appropriate machine-readable metadata fields within text or
|
|
95
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
96
|
+
|
|
97
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
98
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
99
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
100
|
+
presented to the users.
|
|
101
|
+
|
|
102
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
103
|
+
Software shall not be used to promote, endorse or advertise any
|
|
104
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
105
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
106
|
+
permission.
|
|
107
|
+
|
|
108
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
109
|
+
must be distributed entirely under this license, and must not be
|
|
110
|
+
distributed under any other license. The requirement for fonts to
|
|
111
|
+
remain under this license does not apply to any document created
|
|
112
|
+
using the Font Software.
|
|
113
|
+
|
|
114
|
+
TERMINATION
|
|
115
|
+
This license becomes null and void if any of the above conditions are
|
|
116
|
+
not met.
|
|
117
|
+
|
|
118
|
+
DISCLAIMER
|
|
119
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
120
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
121
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
122
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
123
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
124
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
125
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
126
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
127
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
128
|
+
|
|
129
|
+
```
|
package/README.md
CHANGED
|
@@ -19,14 +19,21 @@ need it.
|
|
|
19
19
|
| `@threadcrumbs/common/launch` | `present(result, via)`: carries out a harness adapter's answer to "open this thread" by fronting the window of a session already running, handing a deep link to the OS, or running the harness's CLI in a new terminal. When nothing on the machine answers the link, it tries the adapter's `fallback` links, such as Claude Code's thread in VS Code, and failing those returns `{ ok: false, error, missing: { name, download, scheme, also }, terminal }`: the app to install, where to get it, other apps that would do, and whether `via: 'terminal'` would work instead. Also `launch(target)` and `resolveFolder(dir)`. Threadgauge uses it, and Threadnauts still has its own copy |
|
|
20
20
|
| `@threadcrumbs/common/license` | The Threadcrumbs license both apps check offline and the key service signs: `createLicenseKeys`, `publicKeyOf`, `licenseClaims`, `signLicense`, `verifyLicense`, and the unlock rule, `paidThrough` and `unlocks`, plus `renewalDue`. Web APIs only, so the same file runs in a Cloudflare Worker. The format and rules are in [DECISIONS.md](DECISIONS.md#licenses-are-signed-by-threadcrumbs-and-checked-offline). `LICENSE_PUBLIC_KEYS` holds the production keys every app trusts, and never a sandbox key |
|
|
21
21
|
| `@threadcrumbs/common/license/store` | Where the license lives on this machine, shared by every app: `licenseStore()` returns `device()`, `current()`, `save(token)`, and `clear()`, over `license.json` in `threadcrumbsDataDir()`. `current().at` is the time to test unlocks at, and `current()` writes nothing on a machine where no one has entered a key. `trustedKeys({ development })` adds a sandbox key in a development build, and `cloudUrl({ development })` is the key service's address: `https://threadcrumbs.io/api`, or the local Worker at `http://localhost:8787/api` in a development build. Node only, so the Worker never loads it |
|
|
22
|
+
| `@threadcrumbs/common/ui/theme.css` | The Threadcrumbs look as CSS variables: the Quieter backgrounds, lines, and text levels, Threadcrumbs orange, the six status colors and their plates, `--warn`, added and removed, the chart and gauge colors, focus, type sizes, spacing, radii, and the default `--sans`, `--cond`, and `--mono` stacks. Every element keeps one x-height (`font-size-adjust: 0.51`) |
|
|
23
|
+
| `@threadcrumbs/common/ui/fonts.css` | `@font-face` rules for Inter, Inter Tight, and JetBrains Mono, Latin and Latin Extended, upright, with their `.woff2` files in `ui/fonts/`. A bundler copies the files into the app, so they load from its own origin. `tools/ui-fonts.mjs` writes both from the `@fontsource` packages |
|
|
24
|
+
| `@threadcrumbs/common/ui/status` | `STATUSES` (each status's key, name, color, and plate, in the order both apps pin), `STATUS_KEYS`, `URGENCY_ORDER` (for the status row and the waffle), `LIVE_STATUSES`, `statusOf`, the status icons as SVG in `LAMP_ICONS`, and `lampHTML(status)`. `ui/status.css` colors each `[data-st]` and animates the lamps, and reduced motion stops them |
|
|
25
|
+
| `@threadcrumbs/common/ui/icons` | `ICO(name)`, the line icons (organization, folder, worktree, branch, model, key, and one per tool kind); `effortBars` and `effortName`; `harnessMark` (CC, Cx, and the others); and, for tool calls, `KINDS`, `kindOf`, `kindTip`, `kindIconHTML`, `toolKinds`, and `mcpServer` |
|
|
26
|
+
| `@threadcrumbs/common/ui/where` | `whereHTML(thread)`, the organization and repo chips, `whereFullHTML(thread)` with the worktree and branch, and `repoName(name, org)`, which drops a package scope the organization repeats. `ui/where.css` styles the chips, the harness initials, the effort bars, and the tool kind icons |
|
|
27
|
+
| `@threadcrumbs/common/ui/gauge` | `contextGauge({ pct, level }, size)`, the context gauge as SVG, with `levelOf(pct)` (amber from 70%, red from 90%) and `levelClass`. `ui/gauge.css` draws it and gives a picture with a tip a slightly larger hover area (`.i-wrap`) |
|
|
22
28
|
|
|
23
29
|
`launch/` is the only part of this package that starts processes, and `license/store.mjs` is the
|
|
24
|
-
only part that writes a file. The readers and `orgs/` import neither.
|
|
30
|
+
only part that writes a file. The readers and `orgs/` import neither. `ui/` is plain functions
|
|
31
|
+
that return markup, plus CSS and fonts, for a page to draw; it touches no DOM and does no I/O.
|
|
25
32
|
|
|
26
33
|
These are likely to follow, each when an app first needs it from here:
|
|
27
34
|
- **Suite discovery:** the `/api/about` identity format and the port search.
|
|
28
35
|
- **Local-server safety:** the Host and Origin checks and the security policy.
|
|
29
|
-
- **
|
|
36
|
+
- **More shared UI:** the list's lamps row, group heading, and thread row, then the Conversation.
|
|
30
37
|
|
|
31
38
|
## Using it from an app
|
|
32
39
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fsp from 'node:fs/promises'
|
|
2
2
|
import { requestCost } from './anthropic-prices.mjs'
|
|
3
|
-
import { emptyFixed, emptySizes, setFixed, sizeCall, sizeCompaction, sizePrompt, sizeReply, sizeRequest, sizeResult, sizeResumed } from './context-breakdown.mjs'
|
|
3
|
+
import { FIXED_PARTS, emptyFixed, emptySizes, setFixed, sizeCall, sizeCompaction, sizePrompt, sizeReply, sizeRequest, sizeResult, sizeResumed } from './context-breakdown.mjs'
|
|
4
4
|
import { listFiles, parseJsonLine, readNewLines } from './fsutil.mjs'
|
|
5
5
|
import {
|
|
6
6
|
DETAIL_LIMITS,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
clipBlock,
|
|
11
11
|
diffHunk,
|
|
12
12
|
emptyActivity,
|
|
13
|
+
emptySample,
|
|
13
14
|
endCall,
|
|
14
15
|
fileList,
|
|
15
16
|
firstChange,
|
|
@@ -20,9 +21,11 @@ import {
|
|
|
20
21
|
openTool,
|
|
21
22
|
patchLines,
|
|
22
23
|
planItems,
|
|
24
|
+
samplePoint,
|
|
23
25
|
settleCalls,
|
|
24
26
|
startCall,
|
|
25
27
|
textList,
|
|
28
|
+
wordsFull,
|
|
26
29
|
toolFile,
|
|
27
30
|
toolInput,
|
|
28
31
|
toolTarget,
|
|
@@ -32,6 +35,7 @@ import { mcpServer } from './tool-kinds.mjs'
|
|
|
32
35
|
const HEAD_BYTES = 192 * 1024
|
|
33
36
|
const TAIL_BYTES = 64 * 1024
|
|
34
37
|
const TAIL_LIMIT = 4 * 1024 * 1024
|
|
38
|
+
const WORDS_TAIL_BYTES = 512 * 1024
|
|
35
39
|
const NOISE = /<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g
|
|
36
40
|
|
|
37
41
|
const AGENT_TOOLS = new Set(['Agent', 'Task'])
|
|
@@ -40,6 +44,7 @@ const AWAIT_LIMIT = 20
|
|
|
40
44
|
const PEER_BUSY = new Set(['busy', 'waiting'])
|
|
41
45
|
|
|
42
46
|
const MID_TURN = Object.freeze({ handedBack: false, failed: false })
|
|
47
|
+
const INTERRUPTED = /^\[Request interrupted by user/
|
|
43
48
|
|
|
44
49
|
const number = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
|
|
45
50
|
const text = (v) => (typeof v === 'string' ? v : '')
|
|
@@ -70,7 +75,7 @@ export async function readLiveSessions(dir, isAlive = pidAlive) {
|
|
|
70
75
|
return live
|
|
71
76
|
}
|
|
72
77
|
|
|
73
|
-
export function transcriptReader({ skim = false, detail = false, words = false, sizes = false } = {}) {
|
|
78
|
+
export function transcriptReader({ skim = false, detail = false, words = false, sizes = false, tail = false } = {}) {
|
|
74
79
|
const files = new Map()
|
|
75
80
|
|
|
76
81
|
async function advance(entry, file, size) {
|
|
@@ -78,7 +83,9 @@ export function transcriptReader({ skim = false, detail = false, words = false,
|
|
|
78
83
|
const state = entry.state
|
|
79
84
|
if (size === state.offset) return state
|
|
80
85
|
try {
|
|
81
|
-
if (
|
|
86
|
+
if (tail && state.offset === 0 && size > WORDS_TAIL_BYTES) {
|
|
87
|
+
await tailInto(state, file, size, () => emptyState(entry.sidechain, detail, words, sizes))
|
|
88
|
+
} else if (skim && state.offset === 0 && size > HEAD_BYTES + TAIL_BYTES) {
|
|
82
89
|
await skimInto(state, file, size)
|
|
83
90
|
} else {
|
|
84
91
|
const { lines, carry } = await readNewLines(file, state.offset, size, state.carry)
|
|
@@ -115,6 +122,19 @@ export function transcriptReader({ skim = false, detail = false, words = false,
|
|
|
115
122
|
return { read, sweep }
|
|
116
123
|
}
|
|
117
124
|
|
|
125
|
+
async function tailInto(state, file, size, fresh) {
|
|
126
|
+
for (let bytes = WORDS_TAIL_BYTES; ; bytes *= 4) {
|
|
127
|
+
const from = Math.max(0, size - bytes)
|
|
128
|
+
const { lines, carry } = await readNewLines(file, from, size)
|
|
129
|
+
const trial = fresh()
|
|
130
|
+
for (const line of from > 0 ? lines.slice(1) : lines) applyLine(trial, line)
|
|
131
|
+
if (from === 0 || wordsFull(trial.words)) {
|
|
132
|
+
Object.assign(state, trial, { offset: size, carry })
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
118
138
|
async function skimInto(state, file, size) {
|
|
119
139
|
const head = await readNewLines(file, 0, HEAD_BYTES)
|
|
120
140
|
const headEnd = HEAD_BYTES - head.carry.length
|
|
@@ -176,6 +196,7 @@ function emptyState(sidechain, detail, words, sizes) {
|
|
|
176
196
|
cost: null,
|
|
177
197
|
plan: null,
|
|
178
198
|
prompts: 0,
|
|
199
|
+
contextSample: emptySample(),
|
|
179
200
|
uuids: new Set(),
|
|
180
201
|
}
|
|
181
202
|
}
|
|
@@ -250,27 +271,31 @@ function applySystem(detail, record, at) {
|
|
|
250
271
|
}
|
|
251
272
|
}
|
|
252
273
|
|
|
253
|
-
const emptySent = () => ({ system: 0, tools: [], deferred: new Set(), instructions: new Map(), skills: new Map(), skillsOther: 0, memory: new Map(), agents: 0 })
|
|
274
|
+
const emptySent = () => ({ system: 0, tools: [], deferred: new Set(), instructions: new Map(), skills: new Map(), skillsOther: 0, memory: new Map(), agents: 0, seen: new Set() })
|
|
254
275
|
|
|
255
276
|
function applySent(state, record) {
|
|
256
277
|
const sent = state.sent
|
|
257
278
|
const attachment = record.attachment
|
|
258
279
|
if (record.type === 'system' && record.subtype === 'compact_boundary') {
|
|
259
280
|
sizeCompaction(state.sizes)
|
|
260
|
-
state.sent = { ...emptySent(), system: sent.system, tools: sent.tools }
|
|
281
|
+
state.sent = { ...emptySent(), system: sent.system, tools: sent.tools, seen: sent.seen }
|
|
261
282
|
return
|
|
262
283
|
}
|
|
263
284
|
if (record.type === 'api-request-shape') {
|
|
264
285
|
const shape = record.shape || {}
|
|
286
|
+
sent.seen.add('shape')
|
|
265
287
|
sent.system = (Array.isArray(shape.system) ? shape.system : []).reduce((n, part) => n + text(part?.text).length, 0)
|
|
266
288
|
sent.tools = (Array.isArray(shape.tools) ? shape.tools : []).map((tool) => ({ name: text(tool?.name), chars: JSON.stringify(tool ?? null).length }))
|
|
267
289
|
} else if (attachment?.type === 'deferred_tools_delta') {
|
|
290
|
+
sent.seen.add('mcpTools')
|
|
268
291
|
for (const name of [...listOf(attachment.addedNames), ...listOf(attachment.readdedNames)]) sent.deferred.add(text(name))
|
|
269
292
|
for (const name of listOf(attachment.removedNames)) sent.deferred.delete(text(name))
|
|
270
293
|
} else if (attachment?.type === 'mcp_instructions_delta') {
|
|
294
|
+
sent.seen.add('mcpTools')
|
|
271
295
|
listOf(attachment.addedNames).forEach((name, i) => sent.instructions.set(text(name), text(listOf(attachment.addedBlocks)[i]).length))
|
|
272
296
|
for (const name of listOf(attachment.removedNames)) sent.instructions.delete(text(name))
|
|
273
297
|
} else if (attachment?.type === 'skill_listing') {
|
|
298
|
+
sent.seen.add('skills')
|
|
274
299
|
if (attachment.isInitial !== false) {
|
|
275
300
|
sent.skills = new Map()
|
|
276
301
|
sent.skillsOther = 0
|
|
@@ -282,6 +307,7 @@ function applySent(state, record) {
|
|
|
282
307
|
else sent.skillsOther += line.length + 1
|
|
283
308
|
}
|
|
284
309
|
} else if (attachment?.type === 'instructions') {
|
|
310
|
+
sent.seen.add('memory')
|
|
285
311
|
for (const file of listOf(attachment.files)) if (text(file?.path)) sent.memory.set(text(file.path), text(file.content).length)
|
|
286
312
|
} else if (attachment?.type === 'agent_listing_delta') {
|
|
287
313
|
sent.agents = (attachment.isInitial ? 0 : sent.agents) + listOf(attachment.addedLines).reduce((n, line) => n + text(line).length + 1, 0)
|
|
@@ -316,6 +342,8 @@ function fixedOf(sent) {
|
|
|
316
342
|
fixed.mcp = new Map([...servers].map(([name, s]) => [name, { tools: s.names.size || null, chars: s.chars }]))
|
|
317
343
|
fixed.skills = new Map(sent.skills)
|
|
318
344
|
fixed.memory = new Map(sent.memory)
|
|
345
|
+
fixed.complete = sent.seen.has('shape')
|
|
346
|
+
fixed.recorded = new Set(fixed.complete ? FIXED_PARTS : ['system', ...[...sent.seen].filter((part) => part !== 'shape')])
|
|
319
347
|
return fixed
|
|
320
348
|
}
|
|
321
349
|
|
|
@@ -341,6 +369,13 @@ function applyUser(state, record, at) {
|
|
|
341
369
|
if (!failed) countEdits(state, result, call)
|
|
342
370
|
}
|
|
343
371
|
}
|
|
372
|
+
if (INTERRUPTED.test(firstText(content).trim())) {
|
|
373
|
+
state.open.clear()
|
|
374
|
+
state.ending = { handedBack: true, failed: false }
|
|
375
|
+
state.handedBackAt = at
|
|
376
|
+
if (state.detail) settleCalls(state.detail.activity)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
344
379
|
if (!state.ending || state.ending.handedBack) state.turnStartedAt = at
|
|
345
380
|
state.ending = MID_TURN
|
|
346
381
|
const notice = record.isMeta ? IDLE_NOTICE.exec(firstText(content)) : null
|
|
@@ -440,6 +475,7 @@ function applyAssistant(state, record, at) {
|
|
|
440
475
|
for (const key of Object.keys(next)) state.tokens[key] += next[key] - (previous?.[key] || 0)
|
|
441
476
|
state.usageById.set(message.id, next)
|
|
442
477
|
state.context = next.input + next.cacheRead + next.cacheWrite + next.output
|
|
478
|
+
samplePoint(state.contextSample, state.context, { update: Boolean(previous) })
|
|
443
479
|
const cost = requestCost(message.model, usage)
|
|
444
480
|
let added = null
|
|
445
481
|
if (cost != null) {
|
|
@@ -4,8 +4,10 @@ const add = (map, key, n) => map.set(key, (map.get(key) || 0) + n)
|
|
|
4
4
|
const sum = (values) => [...values].reduce((total, n) => total + n, 0)
|
|
5
5
|
const noPending = () => ({ prompts: 0, replies: 0, tools: new Map() })
|
|
6
6
|
|
|
7
|
+
export const FIXED_PARTS = ['system', 'builtInTools', 'mcpTools', 'skills', 'memory']
|
|
8
|
+
|
|
7
9
|
export function emptyFixed() {
|
|
8
|
-
return { system: 0, builtInTools: 0, mcp: new Map(), skills: new Map(), memory: new Map() }
|
|
10
|
+
return { system: 0, builtInTools: 0, mcp: new Map(), skills: new Map(), memory: new Map(), recorded: new Set(), complete: false }
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export function emptySizes() {
|
|
@@ -58,8 +60,8 @@ export function sizeCompaction(sizes) {
|
|
|
58
60
|
sizes.callNames.clear()
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
+
const otherChars = (fixed) => fixed.builtInTools + sum([...fixed.mcp.values()].map((m) => m.chars)) + sum(fixed.skills.values()) + sum(fixed.memory.values())
|
|
64
|
+
const fixedChars = (fixed) => fixed.system + otherChars(fixed)
|
|
63
65
|
|
|
64
66
|
function spread(weights, pending, tokens) {
|
|
65
67
|
const chars = pending.prompts + pending.replies + sum(pending.tools.values())
|
|
@@ -74,9 +76,13 @@ function spread(weights, pending, tokens) {
|
|
|
74
76
|
|
|
75
77
|
const counted = (sizes) => (sizes.first && !sizes.resumed ? sizes.first.input - sizes.first.prompt : 0)
|
|
76
78
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
const measuredText = (sizes) => Boolean(sizes.first?.complete && sizes.first.chars && counted(sizes) > 0)
|
|
80
|
+
|
|
81
|
+
const ratio = (sizes) => (measuredText(sizes) ? counted(sizes) / sizes.first.chars : 1 / CHARS_PER_TOKEN)
|
|
82
|
+
|
|
83
|
+
function fixedNow(sizes) {
|
|
84
|
+
if (measuredText(sizes)) return Math.round(fixedChars(sizes.fixed) * ratio(sizes))
|
|
85
|
+
return counted(sizes) + Math.round((otherChars(sizes.fixed) - sizes.first.other) / CHARS_PER_TOKEN)
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
export function sizeRequest(sizes, input, at) {
|
|
@@ -84,11 +90,10 @@ export function sizeRequest(sizes, input, at) {
|
|
|
84
90
|
if (at) sizes.lastAt = at
|
|
85
91
|
if (sizes.lastInput == null) {
|
|
86
92
|
if (!sizes.firstPrompt) sizes.resumed = true
|
|
87
|
-
sizes.first = { input, prompt: Math.round(sizes.firstPrompt / CHARS_PER_TOKEN), chars: fixedChars(sizes.fixed) }
|
|
93
|
+
sizes.first = { input, prompt: Math.round(sizes.firstPrompt / CHARS_PER_TOKEN), chars: fixedChars(sizes.fixed), other: otherChars(sizes.fixed), complete: sizes.fixed.complete }
|
|
88
94
|
spread(sizes.weights, sizes.pending, Math.min(input, sizes.first.prompt))
|
|
89
95
|
} else if (sizes.compacted) {
|
|
90
|
-
|
|
91
|
-
sizes.weights = { prompts: 0, replies: 0, summary: Math.max(0, input - fixed), tools: new Map() }
|
|
96
|
+
sizes.weights = { prompts: 0, replies: 0, summary: Math.max(0, input - fixedNow(sizes)), tools: new Map() }
|
|
92
97
|
} else if (input > sizes.lastInput) {
|
|
93
98
|
spread(sizes.weights, sizes.pending, input - sizes.lastInput)
|
|
94
99
|
}
|
|
@@ -129,21 +134,24 @@ export function contextBreakdown(sizes, { total, limit = null, reserved = null }
|
|
|
129
134
|
const system = Math.round(fixedChars(sizes.fixed) / CHARS_PER_TOKEN)
|
|
130
135
|
return system > 0 && system < total ? twoParts(base, sizes.weights, system) : null
|
|
131
136
|
}
|
|
132
|
-
|
|
137
|
+
const { recorded } = sizes.fixed
|
|
138
|
+
if (!recorded.size || !sizes.first.chars) return twoParts(base, sizes.weights, fixed)
|
|
133
139
|
const tokens = (n) => Math.round(n * ratio(sizes))
|
|
134
|
-
const {
|
|
140
|
+
const { builtInTools, mcp, skills, memory } = sizes.fixed
|
|
135
141
|
const servers = [...mcp].map(([name, m]) => ({ name, tools: m.tools ?? null, tokens: tokens(m.chars) })).filter((m) => m.tokens > 0)
|
|
136
142
|
const skillItems = [...skills].map(([name, n]) => ({ name, tokens: tokens(n) })).filter((s) => s.tokens > 0)
|
|
137
143
|
const memoryItems = [...memory].map(([path, n]) => ({ path, tokens: tokens(n) })).filter((m) => m.tokens > 0)
|
|
138
|
-
const
|
|
139
|
-
{ id: 'system', tokens: tokens(system), items: [] },
|
|
144
|
+
const others = [
|
|
140
145
|
{ id: 'builtInTools', tokens: tokens(builtInTools), items: [] },
|
|
141
146
|
{ id: 'mcpTools', tokens: sum(servers.map((m) => m.tokens)), items: servers.sort((a, b) => b.tokens - a.tokens) },
|
|
142
147
|
{ id: 'skills', tokens: sum(skillItems.map((s) => s.tokens)), items: skillItems.sort((a, b) => b.tokens - a.tokens) },
|
|
143
148
|
{ id: 'memory', tokens: sum(memoryItems.map((m) => m.tokens)), items: memoryItems },
|
|
144
|
-
].filter((p) => p.
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
147
|
-
const
|
|
149
|
+
].filter((p) => recorded.has(p.id))
|
|
150
|
+
const system = measuredText(sizes) ? tokens(sizes.fixed.system) : fixedNow(sizes) - sum(others.map((p) => p.tokens))
|
|
151
|
+
if (system < 0) return twoParts(base, sizes.weights, fixed)
|
|
152
|
+
const parts = [{ id: 'system', tokens: system, items: [] }, ...others]
|
|
153
|
+
const fixedTotal = sum(parts.map((p) => p.tokens))
|
|
154
|
+
if (fixedTotal >= total) return twoParts(base, sizes.weights, fixed)
|
|
155
|
+
const messages = total - fixedTotal
|
|
148
156
|
return { ...base, parts: [{ id: 'messages', tokens: messages, items: messageItems(sizes.weights, messages) }, ...parts] }
|
|
149
157
|
}
|
package/lib/jsonl-follower.mjs
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import { parseJsonLine, readNewLines } from './fsutil.mjs'
|
|
2
2
|
|
|
3
|
-
export function jsonlFollower({ empty, apply }) {
|
|
3
|
+
export function jsonlFollower({ empty, apply, tail = null }) {
|
|
4
4
|
const files = new Map()
|
|
5
5
|
|
|
6
|
+
async function readTail(file, size) {
|
|
7
|
+
for (let bytes = tail.bytes; ; bytes *= 4) {
|
|
8
|
+
const from = Math.max(0, size - bytes)
|
|
9
|
+
const { lines, carry } = await readNewLines(file, from, size)
|
|
10
|
+
const state = { ...empty(), offset: size, carry }
|
|
11
|
+
for (const line of from > 0 ? lines.slice(1) : lines) {
|
|
12
|
+
const record = parseJsonLine(line)
|
|
13
|
+
if (record) apply(state, record)
|
|
14
|
+
}
|
|
15
|
+
if (from === 0 || tail.enough(state)) return state
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
6
19
|
async function advance(entry, file, size) {
|
|
7
20
|
if (!entry.state || size < entry.state.offset) entry.state = { ...empty(), offset: 0, carry: Buffer.alloc(0) }
|
|
8
21
|
const state = entry.state
|
|
9
22
|
if (size === state.offset) return state
|
|
10
23
|
try {
|
|
24
|
+
if (tail && state.offset === 0 && size > tail.bytes) return (entry.state = await readTail(file, size))
|
|
11
25
|
const { lines, carry } = await readNewLines(file, state.offset, size, state.carry)
|
|
12
26
|
state.offset = size
|
|
13
27
|
state.carry = carry
|
package/lib/opencode-store.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
clip,
|
|
11
11
|
clipBlock,
|
|
12
12
|
emptyActivity,
|
|
13
|
+
emptySample,
|
|
13
14
|
endCall,
|
|
14
15
|
fileList,
|
|
15
16
|
joinedText,
|
|
@@ -19,6 +20,9 @@ import {
|
|
|
19
20
|
openTool,
|
|
20
21
|
planItems,
|
|
21
22
|
planSummary,
|
|
23
|
+
relativePath,
|
|
24
|
+
sampleList,
|
|
25
|
+
samplePoint,
|
|
22
26
|
settleCalls,
|
|
23
27
|
startCall,
|
|
24
28
|
text,
|
|
@@ -127,9 +131,10 @@ export function opencodeStore({ id, name, dbPath, watchDirs, limitsFile, continu
|
|
|
127
131
|
return sessions
|
|
128
132
|
}
|
|
129
133
|
|
|
130
|
-
async function sessionDetail(key) {
|
|
134
|
+
async function sessionDetail(key, { filesOnly = false } = {}) {
|
|
131
135
|
const prefix = `${id}:`
|
|
132
136
|
const sessionId = String(key).startsWith(prefix) ? String(key).slice(prefix.length) : ''
|
|
137
|
+
if (filesOnly) return sessionId ? { id: `${id}:${sessionId}`, sessionId, files: facts.get(`v1:${sessionId}`)?.value.files ?? null } : null
|
|
133
138
|
const file = sessionId ? await dbPath() : ''
|
|
134
139
|
if (!file) return null
|
|
135
140
|
const context = { ...options, facts, seen: new Set(), byModel: new Map(), at: now() }
|
|
@@ -458,6 +463,7 @@ function session(context, row, turn, extra) {
|
|
|
458
463
|
tool: turn.open && turn.tool ? toolNow(turn.tool) : null,
|
|
459
464
|
tokens: turn.tokens ? { ...turn.tokens } : null,
|
|
460
465
|
context: turn.context ? { used: turn.context, limit: context.byModel.get(`${turn.provider}/${turn.model}`) || 0 } : null,
|
|
466
|
+
contextHistory: sampleList(turn.contextSample, turn.context),
|
|
461
467
|
costUsd: turn.cost,
|
|
462
468
|
lines: extra.lines,
|
|
463
469
|
plan: planSummary(extra.plan),
|
|
@@ -504,7 +510,7 @@ function legacySessions(db, tables, context) {
|
|
|
504
510
|
}
|
|
505
511
|
|
|
506
512
|
function legacyTurn(db, row, context) {
|
|
507
|
-
const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, lines: null, tool: null, turnStartedAt: 0 }
|
|
513
|
+
const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, contextSample: emptySample(), lines: null, tool: null, turnStartedAt: 0 }
|
|
508
514
|
const typed = new Set(
|
|
509
515
|
db
|
|
510
516
|
.prepare(`SELECT message_id FROM part WHERE session_id = ? AND data LIKE '%"type":"text"%' AND data NOT LIKE '%"synthetic":true%'`)
|
|
@@ -513,6 +519,7 @@ function legacyTurn(db, row, context) {
|
|
|
513
519
|
)
|
|
514
520
|
let last = null
|
|
515
521
|
let lastAssistant = ''
|
|
522
|
+
const changed = new Set()
|
|
516
523
|
for (const message of db.prepare('SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, rowid').all(row.id)) {
|
|
517
524
|
const data = parse(message.data)
|
|
518
525
|
last = data
|
|
@@ -527,6 +534,7 @@ function legacyTurn(db, row, context) {
|
|
|
527
534
|
for (const diff of data.summary.diffs) {
|
|
528
535
|
turn.lines.added += number(diff?.additions)
|
|
529
536
|
turn.lines.removed += number(diff?.deletions)
|
|
537
|
+
if (text(diff?.file)) changed.add(relativePath(text(diff.file), text(row.directory)))
|
|
530
538
|
}
|
|
531
539
|
}
|
|
532
540
|
continue
|
|
@@ -537,8 +545,12 @@ function legacyTurn(db, row, context) {
|
|
|
537
545
|
if (data.providerID) turn.provider = text(data.providerID)
|
|
538
546
|
if (data.variant) turn.effort = text(data.variant)
|
|
539
547
|
if (data.cost != null) turn.cost = (turn.cost || 0) + number(data.cost)
|
|
540
|
-
if (number(data.tokens?.output) > 0)
|
|
548
|
+
if (number(data.tokens?.output) > 0) {
|
|
549
|
+
turn.context = windowOf(data.tokens)
|
|
550
|
+
samplePoint(turn.contextSample, turn.context)
|
|
551
|
+
}
|
|
541
552
|
}
|
|
553
|
+
turn.files = changed.size || !context.liveSummary ? [...changed].map((file) => ({ path: file })) : null
|
|
542
554
|
const usage = usageFromColumns(row)
|
|
543
555
|
if (usage) {
|
|
544
556
|
turn.tokens = usage.tokens
|
|
@@ -605,13 +617,35 @@ function currentSessions(db, tables, context) {
|
|
|
605
617
|
return out
|
|
606
618
|
}
|
|
607
619
|
|
|
620
|
+
function currentSample(db, sessionId, before) {
|
|
621
|
+
let { contextSample: sample, contextSeq: seq = -1, contextCount: count = 0 } = before || {}
|
|
622
|
+
const kept = sample && db.prepare(`SELECT COUNT(*) AS n, MAX(seq) AS top FROM session_message WHERE session_id = ? AND type = 'assistant' AND seq <= ?`).get(sessionId, seq)
|
|
623
|
+
if (!kept || number(kept.n) !== count || (count && number(kept.top) !== seq)) {
|
|
624
|
+
sample = emptySample()
|
|
625
|
+
seq = -1
|
|
626
|
+
count = 0
|
|
627
|
+
}
|
|
628
|
+
const newer = db.prepare(`SELECT seq, data FROM session_message WHERE session_id = ? AND type = 'assistant' AND seq > ? ORDER BY seq`).all(sessionId, seq)
|
|
629
|
+
for (const [i, message] of newer.entries()) {
|
|
630
|
+
const data = parse(message.data)
|
|
631
|
+
if (i === newer.length - 1 && !data.time?.completed) break
|
|
632
|
+
if (number(data.tokens?.output) > 0) samplePoint(sample, windowOf(data.tokens))
|
|
633
|
+
seq = number(message.seq)
|
|
634
|
+
count++
|
|
635
|
+
}
|
|
636
|
+
return { contextSample: sample, contextSeq: seq, contextCount: count }
|
|
637
|
+
}
|
|
638
|
+
|
|
608
639
|
function currentTurn(db, row, parts, context) {
|
|
609
|
-
const
|
|
640
|
+
const before = context.facts.get(`v2:${row.id}`)?.value
|
|
641
|
+
const sampled = currentSample(db, row.id, before)
|
|
642
|
+
const kept = sampled.contextSample === before?.contextSample ? before : null
|
|
643
|
+
const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, tool: null, plan: null, turnStartedAt: 0, ...sampled }
|
|
610
644
|
turn.prompts = number(db.prepare(`SELECT COUNT(*) AS n FROM session_message WHERE session_id = ? AND type = 'user'`).get(row.id)?.n)
|
|
611
645
|
const lastUser = db.prepare(`SELECT time_created FROM session_message WHERE session_id = ? AND type = 'user' ORDER BY seq DESC LIMIT 1`).get(row.id)
|
|
612
646
|
turn.turnStartedAt = number(row.time_busy) || number(lastUser?.time_created)
|
|
613
647
|
turn.firstPrompt = text(parse(db.prepare(`SELECT data FROM session_message WHERE session_id = ? AND type = 'user' ORDER BY seq ASC LIMIT 1`).get(row.id)?.data).text).trim()
|
|
614
|
-
const assistant = db.prepare(`SELECT id, data FROM session_message WHERE session_id = ? AND type = 'assistant' ORDER BY seq DESC LIMIT 1`).get(row.id)
|
|
648
|
+
const assistant = db.prepare(`SELECT id, seq, data FROM session_message WHERE session_id = ? AND type = 'assistant' ORDER BY seq DESC LIMIT 1`).get(row.id)
|
|
615
649
|
const latest = parse(assistant?.data)
|
|
616
650
|
turn.model = text(latest.model?.id)
|
|
617
651
|
turn.provider = text(latest.model?.providerID)
|
|
@@ -633,36 +667,68 @@ function currentTurn(db, row, parts, context) {
|
|
|
633
667
|
const outcome = text(row.idle_outcome) || (last?.type === 'idle' ? text(parse(last.data).outcome) : '')
|
|
634
668
|
turn.failed = !busy && outcome === 'failed'
|
|
635
669
|
turn.errorText = turn.failed && last?.type === 'idle' ? text(parse(last.data).error?.message) : ''
|
|
636
|
-
const tools = currentTools(db, row.id, assistant, parts, text(row.directory))
|
|
670
|
+
const tools = currentTools(db, row.id, assistant, parts, text(row.directory), kept)
|
|
671
|
+
turn.planSeq = tools.planSeq
|
|
672
|
+
turn.olderTodos = tools.olderTodos
|
|
673
|
+
turn.planRow = tools.planRow
|
|
674
|
+
turn.rowTodos = tools.rowTodos
|
|
637
675
|
if (busy) turn.tool = tools.running
|
|
638
676
|
turn.plan = tools.todos
|
|
639
677
|
return turn
|
|
640
678
|
}
|
|
641
679
|
|
|
642
|
-
|
|
680
|
+
const completedTodos = (parts) => {
|
|
681
|
+
const found = parts.find((p) => p?.name === TODO_TOOL && p.state?.status === 'completed' && Array.isArray(p.state?.input?.todos))
|
|
682
|
+
return found ? found.state.input.todos.map((t) => ({ content: text(t?.content), status: text(t?.status) })) : null
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function rowTodos(db, sessionId, assistant, before) {
|
|
686
|
+
const newest = text(assistant?.id)
|
|
687
|
+
const from = before?.planRow ?? -1
|
|
688
|
+
let older = before?.rowTodos ?? null
|
|
689
|
+
const rows = db
|
|
690
|
+
.prepare(`SELECT data FROM session_part WHERE session_id = ? AND type = 'tool' AND rowid > ? AND message_id != ? AND data LIKE ? ORDER BY rowid DESC`)
|
|
691
|
+
.all(sessionId, from, newest, `%"${TODO_TOOL}"%`)
|
|
692
|
+
for (const row of rows) {
|
|
693
|
+
const found = completedTodos([parse(row.data)])
|
|
694
|
+
if (found) {
|
|
695
|
+
older = found
|
|
696
|
+
break
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const top = db.prepare('SELECT MAX(rowid) AS row FROM session_part WHERE session_id = ? AND message_id != ?').get(sessionId, newest)
|
|
700
|
+
return { rowTodos: older, planRow: top?.row == null ? from : number(top.row) }
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function inlineTodos(db, sessionId, assistant, before) {
|
|
704
|
+
const newest = number(assistant?.seq)
|
|
705
|
+
let older = before?.olderTodos ?? null
|
|
706
|
+
const rows = db
|
|
707
|
+
.prepare(`SELECT data FROM session_message WHERE session_id = ? AND type = 'assistant' AND seq > ? AND seq < ? AND data LIKE ? ORDER BY seq DESC`)
|
|
708
|
+
.all(sessionId, before?.planSeq ?? -1, newest, `%"${TODO_TOOL}"%`)
|
|
709
|
+
for (const row of rows) {
|
|
710
|
+
const found = completedTodos(list(parse(row.data).content).filter((c) => c?.type === 'tool').reverse())
|
|
711
|
+
if (found) {
|
|
712
|
+
older = found
|
|
713
|
+
break
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return { olderTodos: older, planSeq: assistant ? newest - 1 : before?.planSeq ?? -1 }
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function currentTools(db, sessionId, assistant, parts, cwd, before = null) {
|
|
643
720
|
const result = { running: null, todos: null }
|
|
644
721
|
let latest = []
|
|
645
|
-
let recent = []
|
|
646
722
|
if (parts && assistant) {
|
|
647
|
-
latest = db.prepare(`SELECT data FROM session_part WHERE session_id = ? AND message_id = ? AND type = 'tool'`).all(sessionId, assistant.id).map((p) => parse(p.data))
|
|
648
|
-
}
|
|
649
|
-
if (parts) {
|
|
650
|
-
recent = db
|
|
651
|
-
.prepare(`SELECT data FROM session_part WHERE session_id = ? AND type = 'tool' AND data LIKE ? ORDER BY rowid DESC LIMIT 20`)
|
|
652
|
-
.all(sessionId, `%"${TODO_TOOL}"%`)
|
|
653
|
-
.map((p) => parse(p.data))
|
|
723
|
+
latest = db.prepare(`SELECT data FROM session_part WHERE session_id = ? AND message_id = ? AND type = 'tool' ORDER BY rowid`).all(sessionId, assistant.id).map((p) => parse(p.data))
|
|
654
724
|
}
|
|
655
725
|
if (!latest.length && assistant) latest = list(parse(assistant.data).content).filter((c) => c?.type === 'tool')
|
|
656
|
-
if (!recent.length) {
|
|
657
|
-
recent = db
|
|
658
|
-
.prepare(`SELECT data FROM session_message WHERE session_id = ? AND type = 'assistant' AND data LIKE ? ORDER BY seq DESC LIMIT 20`)
|
|
659
|
-
.all(sessionId, `%"${TODO_TOOL}"%`)
|
|
660
|
-
.flatMap((m) => list(parse(m.data).content).filter((c) => c?.type === 'tool').reverse())
|
|
661
|
-
}
|
|
662
726
|
for (const part of latest) {
|
|
663
727
|
if (OPEN_TOOL.has(part.state?.status)) result.running = openTool(text(part.name), number(part.time?.ran) || number(part.time?.created), part.state?.input, cwd)
|
|
664
728
|
}
|
|
665
|
-
const
|
|
666
|
-
|
|
729
|
+
const rows = parts ? rowTodos(db, sessionId, assistant, before) : { rowTodos: null, planRow: -1 }
|
|
730
|
+
const inline = inlineTodos(db, sessionId, assistant, before)
|
|
731
|
+
Object.assign(result, rows, inline)
|
|
732
|
+
result.todos = completedTodos([...latest].reverse()) ?? rows.rowTodos ?? inline.olderTodos
|
|
667
733
|
return result
|
|
668
734
|
}
|
package/lib/telemetry-fields.mjs
CHANGED
|
@@ -160,6 +160,8 @@ export function keepText(list, kind, { id = null, at, text: value }) {
|
|
|
160
160
|
return keepNewest(list, { id, at, text: body }, max)
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
export const wordsFull = (words) => words.replies.length >= DETAIL_LIMITS.replies && words.thoughts.length >= DETAIL_LIMITS.thoughts
|
|
164
|
+
|
|
163
165
|
export const textList = (list) => (list ? list.map(({ at, text: value }) => ({ at, text: value })) : null)
|
|
164
166
|
|
|
165
167
|
export function joinedText(blocks, type, field = type) {
|
|
@@ -219,6 +221,8 @@ export function fileList(files, cwd) {
|
|
|
219
221
|
.sort((a, b) => b.added + b.removed - (a.added + a.removed) || a.path.localeCompare(b.path))
|
|
220
222
|
}
|
|
221
223
|
|
|
224
|
+
export const filePaths = (files, cwd) => [...files.values()].map((f) => ({ path: relativePath(f.path, cwd) }))
|
|
225
|
+
|
|
222
226
|
export const emptyActivity = () => ({ calls: [], tools: new Map() })
|
|
223
227
|
|
|
224
228
|
export function startCall(activity, { id = '', name, target = '', input = null, path = null, line = null, startedAt, running = true }) {
|
|
@@ -316,3 +320,37 @@ export function nextPlan(plan, tool, input) {
|
|
|
316
320
|
}
|
|
317
321
|
return next
|
|
318
322
|
}
|
|
323
|
+
|
|
324
|
+
export const CONTEXT_POINTS = 24
|
|
325
|
+
|
|
326
|
+
export const emptySample = () => ({ stride: 1, seen: 0, points: [], newest: null, newestKept: false })
|
|
327
|
+
|
|
328
|
+
export function samplePoint(sample, value, { update = false } = {}) {
|
|
329
|
+
const point = Math.round(number(value))
|
|
330
|
+
if (point <= 0) return
|
|
331
|
+
if (update && sample.newest != null) {
|
|
332
|
+
if (sample.newestKept) sample.points[sample.points.length - 1] = point
|
|
333
|
+
sample.newest = point
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
sample.newestKept = sample.seen % sample.stride === 0
|
|
337
|
+
if (sample.newestKept) {
|
|
338
|
+
sample.points.push(point)
|
|
339
|
+
if (sample.points.length >= CONTEXT_POINTS * 2) {
|
|
340
|
+
sample.points = sample.points.filter((_, i) => i % 2 === 0)
|
|
341
|
+
sample.stride *= 2
|
|
342
|
+
sample.newestKept = false
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
sample.seen++
|
|
346
|
+
sample.newest = point
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function sampleList(sample, current) {
|
|
350
|
+
if (!sample || !(current > 0)) return null
|
|
351
|
+
const now = Math.round(current)
|
|
352
|
+
const older = sample.newestKept && sample.newest === now ? sample.points.slice(0, -1) : sample.points
|
|
353
|
+
const room = CONTEXT_POINTS - 1
|
|
354
|
+
const picked = older.length <= room ? older : Array.from({ length: room }, (_, i) => older[Math.round((i * (older.length - 1)) / (room - 1))])
|
|
355
|
+
return [...picked, now]
|
|
356
|
+
}
|