thinkpool-pair 0.7.175 → 0.7.176
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/event-id.mjs +10 -64
- package/package.json +2 -1
- package/recap.mjs +112 -0
package/event-id.mjs
CHANGED
|
@@ -249,67 +249,13 @@ export function usageReportLine (sessionModel, logEvents) {
|
|
|
249
249
|
return `usage · ${turns} · ${perStr}${total}${cost} · tokens + cost only (no plan/rate-limit meters)`
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
// from the saved log — the people's turns (`you`) + the assistant's own text replies
|
|
263
|
-
// (`assistant` text blocks; thinking + tool_use are dropped, they don't survive a backend
|
|
264
|
-
// hop and are noise) — newest-weighted under a hard char cap, framed so the agent RESUMES
|
|
265
|
-
// rather than re-greets. Pure + import-safe (unit-tested in bridge/recap.test.mjs).
|
|
266
|
-
//
|
|
267
|
-
// capReplayEvents lesson (rooms died once on a multi-MB replay): the char cap keeps this
|
|
268
|
-
// bounded in prose form too — an unbounded recap would be the same bug wearing a text hat.
|
|
269
|
-
export function buildRecapFromLog(log, cap = RECAP_CAP) {
|
|
270
|
-
if (!Array.isArray(log) || !log.length) return ''
|
|
271
|
-
const budget = Math.max(0, Number.isFinite(cap) ? Math.floor(cap) : 0)
|
|
272
|
-
if (!budget) return ''
|
|
273
|
-
// Flatten the log to speaker-labelled turns, in order. `you` = a person in the room;
|
|
274
|
-
// `assistant` = this agent's own earlier reply (its text blocks only).
|
|
275
|
-
const turns = []
|
|
276
|
-
for (const e of log) {
|
|
277
|
-
if (!e || typeof e !== 'object') continue
|
|
278
|
-
if (e.kind === 'you') {
|
|
279
|
-
const t = String(e.text || '').trim()
|
|
280
|
-
if (t) turns.push({ who: 'PERSON', text: t })
|
|
281
|
-
} else if (e.kind === 'assistant') {
|
|
282
|
-
const t = (Array.isArray(e.blocks) ? e.blocks : [])
|
|
283
|
-
.filter((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.trim())
|
|
284
|
-
.map((b) => b.text.trim())
|
|
285
|
-
.join('\n')
|
|
286
|
-
if (t) turns.push({ who: 'YOU', text: t })
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
if (!turns.length) return ''
|
|
290
|
-
// Newest-weighted: walk from the end, keep whole turns until the char budget is spent,
|
|
291
|
-
// then restore chronological order. A single newest turn bigger than the whole budget is
|
|
292
|
-
// still kept (truncated) so the LIVE task is never dropped to the cap.
|
|
293
|
-
const kept = []
|
|
294
|
-
let used = 0
|
|
295
|
-
let omitted = false
|
|
296
|
-
for (let i = turns.length - 1; i >= 0; i--) {
|
|
297
|
-
let line = `${turns[i].who}: ${turns[i].text}`
|
|
298
|
-
if (used + line.length > budget) {
|
|
299
|
-
if (kept.length) { omitted = true; break } // older turns exist but won't fit → note + stop
|
|
300
|
-
line = line.slice(0, budget) // newest turn alone overflows → clip, still include
|
|
301
|
-
}
|
|
302
|
-
kept.push(line)
|
|
303
|
-
used += line.length
|
|
304
|
-
}
|
|
305
|
-
kept.reverse()
|
|
306
|
-
const body = (omitted ? ['[…earlier messages omitted for length…]'] : []).concat(kept).join('\n\n')
|
|
307
|
-
return [
|
|
308
|
-
'[CONTEXT CARRY — you are RESUMING a live conversation after a model/provider switch]',
|
|
309
|
-
"Your session memory was reset by the switch, but this lane's conversation continued and the people already saw everything below. Do NOT re-greet, do NOT re-introduce yourself, and do NOT re-answer anything you already completed. Read the recap, then continue the work exactly where it left off. The LAST PERSON message with no completed answer below is your live task — pick it up.",
|
|
310
|
-
'',
|
|
311
|
-
'--- conversation so far (oldest → newest) ---',
|
|
312
|
-
body,
|
|
313
|
-
'--- end recap ---',
|
|
314
|
-
].join('\n')
|
|
315
|
-
}
|
|
252
|
+
/* The context-carry recap moved to ./recap.mjs (ADR-0010 / S4, 2026-07-09).
|
|
253
|
+
*
|
|
254
|
+
* Why: the WEB CLIENT needs buildRecapFromLog to carry a lane's bridgeless
|
|
255
|
+
* conversation across a bridge wake, and this module imports `node:crypto` —
|
|
256
|
+
* unresolvable in a browser bundle. The pure half lives in an import-free file
|
|
257
|
+
* both sides can load; these re-exports keep every existing bridge-side import
|
|
258
|
+
* (`from './event-id.mjs'`) working untouched, including bridge/recap.test.mjs.
|
|
259
|
+
*
|
|
260
|
+
* recap.mjs must stay in bridge/package.json `files` — event-id.mjs imports it. */
|
|
261
|
+
export { RECAP_CAP, buildRecapFromLog } from './recap.mjs'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.176",
|
|
4
4
|
"description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"turn-stall.mjs",
|
|
17
17
|
"update-gate.mjs",
|
|
18
18
|
"event-id.mjs",
|
|
19
|
+
"recap.mjs",
|
|
19
20
|
"transcript-sanitize.mjs",
|
|
20
21
|
"session-store.mjs",
|
|
21
22
|
"cross-terminal.mjs",
|
package/recap.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/* recap.mjs — the text-layer context carry. PURE. NO IMPORTS. NO NODE BUILTINS.
|
|
2
|
+
*
|
|
3
|
+
* Extracted from event-id.mjs on 2026-07-09 (ADR-0010 / slice S4) for one reason:
|
|
4
|
+
* the WEB CLIENT needs this function now, and event-id.mjs imports `node:crypto`
|
|
5
|
+
* (for stampEvent's randomUUID default), which Vite cannot resolve for a browser
|
|
6
|
+
* bundle. Rather than write a second windower — the spec's §b instruction is
|
|
7
|
+
* literally "Do not write a second windower" — the pure half moves to its own
|
|
8
|
+
* file that both sides import.
|
|
9
|
+
*
|
|
10
|
+
* bridge/event-id.mjs re-exports these (every existing import keeps working)
|
|
11
|
+
* src/pages/code/poolTurn.js imports this file directly
|
|
12
|
+
*
|
|
13
|
+
* ⚠ KEEP THIS FILE FREE OF IMPORTS. The moment it imports `node:*`, the web build
|
|
14
|
+
* breaks. There is no lint rule for this; there is only this comment.
|
|
15
|
+
*
|
|
16
|
+
* ⚠ TARBALL: this file is imported by event-id.mjs, so it MUST appear in
|
|
17
|
+
* bridge/package.json `files`. A publish that omits it boots a bridge that
|
|
18
|
+
* crashes on `import './recap.mjs'`. (feedback_npm_tarball_completeness.)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// Default hard cap for the context-carry recap (chars of transcript body, framing extra).
|
|
22
|
+
export const RECAP_CAP = 20000
|
|
23
|
+
|
|
24
|
+
/* Why a lane is being handed a recap instead of a resumed session. The framing text
|
|
25
|
+
differs because the LIE each one must avoid differs:
|
|
26
|
+
|
|
27
|
+
'switch' — a model/provider switch ended the SDK session. The agent's own memory is
|
|
28
|
+
gone but nothing else happened; the transcript above is entirely its own work.
|
|
29
|
+
|
|
30
|
+
'wake' — ADR-0010 §d. The bridge was ASLEEP. The lane stayed alive and the people
|
|
31
|
+
kept talking to Pool (client-side BYOK, chat-only, no file access). So the recap
|
|
32
|
+
contains turns the agent DID NOT produce and work it DID NOT do. The framing has to
|
|
33
|
+
say that, and has to say that nothing ran on its behalf — otherwise the agent
|
|
34
|
+
assumes the plan below was already executed. That assumption is how "nothing
|
|
35
|
+
autofires" becomes a lie the agent tells itself. */
|
|
36
|
+
const FRAMING = {
|
|
37
|
+
switch: {
|
|
38
|
+
header: '[CONTEXT CARRY — you are RESUMING a live conversation after a model/provider switch]',
|
|
39
|
+
body: "Your session memory was reset by the switch, but this lane's conversation continued and the people already saw everything below. Do NOT re-greet, do NOT re-introduce yourself, and do NOT re-answer anything you already completed. Read the recap, then continue the work exactly where it left off. The LAST PERSON message with no completed answer below is your live task — pick it up.",
|
|
40
|
+
},
|
|
41
|
+
wake: {
|
|
42
|
+
header: '[CONTEXT CARRY — this lane kept talking while your machine was asleep]',
|
|
43
|
+
body: "Your bridge was offline. The lane stayed open and the people in this room kept planning here with Pool — a chat-only assistant with no file access and no tools. Everything below already happened and they have all seen it. NOTHING WAS RUN ON YOUR BEHALF: no commands were executed, no files were changed, and no plan below has been carried out. Do NOT re-greet and do NOT re-answer what is already answered. Read the recap, then wait for the people to tell you what to do with it — the plan below is a plan, not an instruction you have already been given.",
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Context-carry recap for a lane whose SDK memory CAN'T be resumed — a cross-provider
|
|
48
|
+
switch, a bridge-restart restore where canResume() is false, or (ADR-0010) a bridge
|
|
49
|
+
WAKE under a lane that spent the outage in Pool posture. Such a lane re-opens under the
|
|
50
|
+
SAME id with the visible transcript replayed but a FRESH, blank backend — so the agent
|
|
51
|
+
starts amnesiac mid-conversation (the 2026-07-08 lane 109d22e1 "this is the first
|
|
52
|
+
message in my lane" incident). Raw SDK resume can't cross a provider (signed thinking
|
|
53
|
+
blocks + provider-specific tool_use records get rejected by a foreign backend), so we
|
|
54
|
+
carry the conversation at the TEXT layer instead: synthesize a plain-text recap from the
|
|
55
|
+
saved log — the people's turns (`you`) + the assistant's own text replies (`assistant`
|
|
56
|
+
text blocks; thinking + tool_use are dropped, they don't survive a backend hop and are
|
|
57
|
+
noise) — newest-weighted under a hard char cap, framed so the agent RESUMES rather than
|
|
58
|
+
re-greets. Pure + import-safe (unit-tested in bridge/recap.test.mjs).
|
|
59
|
+
|
|
60
|
+
capReplayEvents lesson (rooms died once on a multi-MB replay): the char cap keeps this
|
|
61
|
+
bounded in prose form too — an unbounded recap would be the same bug wearing a text hat.
|
|
62
|
+
|
|
63
|
+
`reason` selects the framing only; the windowing is identical and the default
|
|
64
|
+
('switch') is byte-for-byte what this function returned before the S4 extraction. */
|
|
65
|
+
export function buildRecapFromLog(log, cap = RECAP_CAP, { reason = 'switch' } = {}) {
|
|
66
|
+
if (!Array.isArray(log) || !log.length) return ''
|
|
67
|
+
const budget = Math.max(0, Number.isFinite(cap) ? Math.floor(cap) : 0)
|
|
68
|
+
if (!budget) return ''
|
|
69
|
+
// Flatten the log to speaker-labelled turns, in order. `you` = a person in the room;
|
|
70
|
+
// `assistant` = this agent's own earlier reply (its text blocks only).
|
|
71
|
+
const turns = []
|
|
72
|
+
for (const e of log) {
|
|
73
|
+
if (!e || typeof e !== 'object') continue
|
|
74
|
+
if (e.kind === 'you') {
|
|
75
|
+
const t = String(e.text || '').trim()
|
|
76
|
+
if (t) turns.push({ who: 'PERSON', text: t })
|
|
77
|
+
} else if (e.kind === 'assistant') {
|
|
78
|
+
const t = (Array.isArray(e.blocks) ? e.blocks : [])
|
|
79
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.trim())
|
|
80
|
+
.map((b) => b.text.trim())
|
|
81
|
+
.join('\n')
|
|
82
|
+
if (t) turns.push({ who: 'YOU', text: t })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!turns.length) return ''
|
|
86
|
+
// Newest-weighted: walk from the end, keep whole turns until the char budget is spent,
|
|
87
|
+
// then restore chronological order. A single newest turn bigger than the whole budget is
|
|
88
|
+
// still kept (truncated) so the LIVE task is never dropped to the cap.
|
|
89
|
+
const kept = []
|
|
90
|
+
let used = 0
|
|
91
|
+
let omitted = false
|
|
92
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
93
|
+
let line = `${turns[i].who}: ${turns[i].text}`
|
|
94
|
+
if (used + line.length > budget) {
|
|
95
|
+
if (kept.length) { omitted = true; break } // older turns exist but won't fit → note + stop
|
|
96
|
+
line = line.slice(0, budget) // newest turn alone overflows → clip, still include
|
|
97
|
+
}
|
|
98
|
+
kept.push(line)
|
|
99
|
+
used += line.length
|
|
100
|
+
}
|
|
101
|
+
kept.reverse()
|
|
102
|
+
const body = (omitted ? ['[…earlier messages omitted for length…]'] : []).concat(kept).join('\n\n')
|
|
103
|
+
const frame = FRAMING[reason] || FRAMING.switch
|
|
104
|
+
return [
|
|
105
|
+
frame.header,
|
|
106
|
+
frame.body,
|
|
107
|
+
'',
|
|
108
|
+
'--- conversation so far (oldest → newest) ---',
|
|
109
|
+
body,
|
|
110
|
+
'--- end recap ---',
|
|
111
|
+
].join('\n')
|
|
112
|
+
}
|