thinkpool-pair 0.7.185 → 0.7.187
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/bridge.mjs +52 -11
- package/event-id.mjs +33 -0
- package/package.json +1 -1
- package/session-store.mjs +74 -19
package/bridge.mjs
CHANGED
|
@@ -94,8 +94,8 @@ const flowRedispatch = new Map()
|
|
|
94
94
|
const flowBudgets = new Map()
|
|
95
95
|
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow } from './cross-terminal.mjs'
|
|
96
96
|
import { turnInFlight } from './update-gate.mjs'
|
|
97
|
-
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq } from './session-store.mjs'
|
|
98
|
-
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, buildRecapFromLog, RECAP_CAP } from './event-id.mjs'
|
|
97
|
+
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
|
|
98
|
+
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
99
99
|
import { planMeterLine } from './plan-meters.mjs'
|
|
100
100
|
import { makeThrottledTrack } from './presence.mjs'
|
|
101
101
|
|
|
@@ -929,10 +929,23 @@ const defendFrame = (event, payload) => {
|
|
|
929
929
|
if (Array.isArray(p.events)) {
|
|
930
930
|
p.events = p.events.map((e) => boundEventForBroadcast(e, 100000))
|
|
931
931
|
// …and if a frame is STILL over the limit, drop the OLDEST events until it fits —
|
|
932
|
-
// keep the newest so the client's seqHi still advances to current.
|
|
933
|
-
//
|
|
934
|
-
|
|
935
|
-
|
|
932
|
+
// keep the newest so the client's seqHi still advances to current.
|
|
933
|
+
//
|
|
934
|
+
// C-RG1 (2026-07-10): STAMP what we dropped. This used to end with "classifyReplay
|
|
935
|
+
// accepts the resulting seq jump; older events are covered by the DB transcript
|
|
936
|
+
// snapshot" — both halves were wrong. classifyReplay accepting the jump IS the bug
|
|
937
|
+
// (the client leapt over a hole it couldn't see, and its "load earlier" gate only
|
|
938
|
+
// detects a LEADING gap, never a middle one). And the tx: snapshot never covered
|
|
939
|
+
// this; the durable JSONL archive does. So say what we trimmed and let the client
|
|
940
|
+
// fetch it back. Events dropped here were about to be broadcast, so by construction
|
|
941
|
+
// they're in the archive — the stamp is always recoverable. Raise, never lower: the
|
|
942
|
+
// send site may have already stamped a chunk-cap trim below this one.
|
|
943
|
+
let guard = 0, dropped = 0
|
|
944
|
+
while (p.events.length > 1 && JSON.stringify(p).length > FRAME_SOFT_LIMIT && guard++ < 10000) { p.events.shift(); dropped++ }
|
|
945
|
+
if (dropped) {
|
|
946
|
+
const lo = firstSeq(p.events)
|
|
947
|
+
if (typeof lo === 'number') p.trimmedBefore = Math.max(p.trimmedBefore ?? 0, lo)
|
|
948
|
+
}
|
|
936
949
|
}
|
|
937
950
|
return p
|
|
938
951
|
}
|
|
@@ -2702,17 +2715,30 @@ channel
|
|
|
2702
2715
|
// Chunk the tail into ORDERED under-budget frames (A): the full log on a fresh reload
|
|
2703
2716
|
// (empty cursor) is multi-MB and a single frame would be dropped by Realtime, freezing
|
|
2704
2717
|
// the terminal. chunkReplayEvents splits it so nothing in the recent window is lost;
|
|
2705
|
-
// classifyReplay applies the chunks in arrival order.
|
|
2706
|
-
//
|
|
2718
|
+
// classifyReplay applies the chunks in arrival order.
|
|
2719
|
+
//
|
|
2720
|
+
// C-RG1 (2026-07-10): chunkReplayEvents keeps only the newest REPLAY_MAX_CHUNKS —
|
|
2721
|
+
// a fat lane (measured: 1.7MB / 12 chunks) drops its OLDEST chunks. That hole used
|
|
2722
|
+
// to travel unannounced; the client absorbed the seq jump and could never fetch the
|
|
2723
|
+
// missing stretch back (its "load earlier" gate only sees a LEADING gap). Tell it.
|
|
2724
|
+
// `cursor` is what the client holds — an `ahead` reset replay is a full rebuild, so
|
|
2725
|
+
// it holds nothing (0). The floor keeps a fresh lane whose first turn lands at seq>1
|
|
2726
|
+
// from being stamped (the 2026-07-02 phantom-pill regression).
|
|
2727
|
+
const chunks = chunkReplayEvents(tail)
|
|
2728
|
+
const cursor = ahead ? 0 : from
|
|
2729
|
+
const trimmedBefore = trimmedBeforeSeq(firstSeq(chunks[0] || []), cursor, s.archiveOldestSeq)
|
|
2707
2730
|
// Attach the terminal's last usage/ctx meter to the FIRST replay chunk. usage is
|
|
2708
2731
|
// chrome (filtered out of `events` by both bridge + client), so without this a
|
|
2709
2732
|
// (re)joiner's ctx meter is empty ("—") until the terminal's next turn. The client
|
|
2710
2733
|
// applies `payload.lastUsage` in its code-replay handler (which is `to`-targeted, so
|
|
2711
2734
|
// only the joiner gets it). Gated on `.ctx` — a suppressed meter (correctContext
|
|
2712
2735
|
// nulled it for an unknown non-Claude model) replays nothing, never a misleading %.
|
|
2736
|
+
// trimmedBefore rides the FIRST (oldest) chunk only — the later chunks are contiguous
|
|
2737
|
+
// with it, so a stamp there would read as a second, phantom gap. Same rule as
|
|
2738
|
+
// lastUsage above and hasMore on history-page below.
|
|
2713
2739
|
let firstChunk = true
|
|
2714
|
-
for (const events of
|
|
2715
|
-
bcast('code-replay', { to: payload?.to ?? null, term: id, events, ...(ahead ? { reset: true } : {}), ...(firstChunk && s.lastUsage?.ctx ? { lastUsage: s.lastUsage } : {}) })
|
|
2740
|
+
for (const events of chunks) {
|
|
2741
|
+
bcast('code-replay', { to: payload?.to ?? null, term: id, events, ...(ahead ? { reset: true } : {}), ...(firstChunk && trimmedBefore != null ? { trimmedBefore } : {}), ...(firstChunk && s.lastUsage?.ctx ? { lastUsage: s.lastUsage } : {}) })
|
|
2716
2742
|
firstChunk = false
|
|
2717
2743
|
}
|
|
2718
2744
|
}
|
|
@@ -2735,6 +2761,13 @@ channel
|
|
|
2735
2761
|
.on('broadcast', { event: 'history-page-request' }, ({ payload }) => {
|
|
2736
2762
|
const id = payload?.term
|
|
2737
2763
|
if (!id) return
|
|
2764
|
+
// Multi-bridge rooms (Max's two Macs; the account-supervisor path): only the bridge
|
|
2765
|
+
// that HOLDS this terminal may answer. A foreign bridge used to reply with an empty
|
|
2766
|
+
// page — the client reads the first empty page as "no older history", clears the gap
|
|
2767
|
+
// and kills the load-earlier pill, so recoverable history reads as gone. Targeting
|
|
2768
|
+
// (payload.host) is honoured too, matching term-open / file-put / code-open / flow-*,
|
|
2769
|
+
// but no client stamps it yet — ownership is what actually closes the race.
|
|
2770
|
+
if (!servesHistoryPage({ payloadHost: payload?.host, bridgeName: name, hasSession: sessions.has(id), hasArchive: hasDurableArchive(room, id) })) return
|
|
2738
2771
|
const { events, hasMore } = readDurablePage(room, id, Number(payload?.beforeSeq) || null, 200)
|
|
2739
2772
|
const to = payload?.to ?? null
|
|
2740
2773
|
if (!events.length) { bcast('history-page', { to, term: id, events: [], hasMore: false }); return }
|
|
@@ -2830,7 +2863,15 @@ channel
|
|
|
2830
2863
|
if (/^\/clear\s*$/.test(text)) {
|
|
2831
2864
|
s.pendingRecap = null // /clear means forget context — drop any un-fired carry recap
|
|
2832
2865
|
s.session.sendTurn(text)
|
|
2833
|
-
|
|
2866
|
+
// The seq reset is load-bearing on BOTH ends: the client's clear handler sets
|
|
2867
|
+
// seqHi = 0 (room.jsx), so the counter must restart with it. The durable archive
|
|
2868
|
+
// is append-only and keeps the pre-clear era under colliding seqs — session-store's
|
|
2869
|
+
// epochStart() confines every read to the newest era, so the archive never splices
|
|
2870
|
+
// two conversations. Drop the cached floor with it: the next pushLog re-seeds
|
|
2871
|
+
// archiveOldestSeq from the FIRST post-clear event (the new epoch's floor), instead
|
|
2872
|
+
// of leaving a stale pre-clear floor to mis-aim trimmedBeforeSeq's phantom-pill guard.
|
|
2873
|
+
s.log = []; s.seq = makeSeqCounter(0); s.archiveOldestSeq = null
|
|
2874
|
+
bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
|
|
2834
2875
|
flushSession(room, payload.term, { sessionId: s.session?.sessionId || null, log: [] })
|
|
2835
2876
|
ctlLine('context cleared. You can continue with these answers in mind.')
|
|
2836
2877
|
return
|
package/event-id.mjs
CHANGED
|
@@ -139,6 +139,39 @@ export function chunkReplayEvents(events, budget = REPLAY_BYTE_BUDGET, maxChunks
|
|
|
139
139
|
return chunksRev.reverse().map((c) => c.reverse())
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// ── C-RG1/C-RG4 (2026-07-10): the bridge stamps what it trimmed. ──────────────
|
|
143
|
+
// chunkReplayEvents keeps only the newest maxChunks — on a fat lane the OLDEST chunks
|
|
144
|
+
// are dropped. Until now that hole was unannounced: classifyReplay accepted the seq
|
|
145
|
+
// jump, seqHi leapt past it, and because the client's "load earlier" gate compares its
|
|
146
|
+
// OLDEST loaded seq against the archive floor, a hole in the MIDDLE (client holds
|
|
147
|
+
// 1..50 ∪ 900..2000) never surfaced a pill and was unrecoverable — silently, forever.
|
|
148
|
+
//
|
|
149
|
+
// The bridge is the only party that knows. Return the lowest seq we are ACTUALLY
|
|
150
|
+
// sending when events below it were withheld AND are recoverable from the durable
|
|
151
|
+
// JSONL archive; null when nothing was withheld.
|
|
152
|
+
//
|
|
153
|
+
// Floor-aware on purpose. The seq counter is per-SESSION, not per-terminal, so a fresh
|
|
154
|
+
// lane's first turn legitimately lands at seq > 1 — stamping that would resurrect the
|
|
155
|
+
// 2026-07-02 phantom "load earlier" pill (a button that fetches nothing). `cursor` is
|
|
156
|
+
// what the client already holds (0 = nothing, and an `ahead` cursor-reset replay must
|
|
157
|
+
// pass 0). `archiveOldestSeq` is the durable floor (bridge.mjs entry.archiveOldestSeq).
|
|
158
|
+
// Anything at or below max(cursor, floor-1) is not missing: either the client has it,
|
|
159
|
+
// or the archive never had it and no affordance could ever fetch it.
|
|
160
|
+
export function trimmedBeforeSeq(sentFromSeq, cursor = 0, archiveOldestSeq = null) {
|
|
161
|
+
if (typeof sentFromSeq !== 'number') return null
|
|
162
|
+
const floor = typeof archiveOldestSeq === 'number' ? archiveOldestSeq : 1
|
|
163
|
+
const known = Math.max(cursor || 0, floor - 1)
|
|
164
|
+
return sentFromSeq > known + 1 ? sentFromSeq : null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The lowest transcript seq in an event list (nulls sort out). Chrome events are
|
|
168
|
+
// seq-less (NO_SEQ_KINDS) and must not anchor the stamp.
|
|
169
|
+
export function firstSeq(events) {
|
|
170
|
+
if (!Array.isArray(events)) return null
|
|
171
|
+
for (const e of events) if (e && typeof e.seq === 'number') return e.seq
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
|
|
142
175
|
// Bound a SINGLE event so it can never overflow a broadcast frame on its own. The worst
|
|
143
176
|
// offenders are tool_results carrying inline base64 IMAGES (a 592KB screenshot block) —
|
|
144
177
|
// these can't ride a size-capped Realtime broadcast at all (live OR replay). Truncate
|
package/package.json
CHANGED
package/session-store.mjs
CHANGED
|
@@ -115,17 +115,75 @@ export function seedDurableEvents(room, id, events) {
|
|
|
115
115
|
} catch { /* noop */ }
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
// Does this bridge hold a durable archive for `id`? The capability half of the
|
|
119
|
+
// history-page-request guard (a foreign bridge in a multi-bridge room has no file).
|
|
120
|
+
export function hasDurableArchive(room, id) {
|
|
121
|
+
try { return !!room && !!id && fs.existsSync(eventsLog(room, id)) } catch { return false }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Should THIS bridge answer a history-page-request? (pure — tested in test-history-ab.mjs)
|
|
125
|
+
//
|
|
126
|
+
// Two independent gates, both required:
|
|
127
|
+
// · `payloadHost` — the same targeting guard every other broadcast handler carries
|
|
128
|
+
// (term-open, file-put, code-open, flow-*): a targeted request is for ONE machine.
|
|
129
|
+
// · ownership — no client build to date STAMPS a host on this event, so targeting
|
|
130
|
+
// alone leaves an untargeted request answerable by every bridge in the room. The
|
|
131
|
+
// bridge that doesn't serve the terminal has no archive, replies
|
|
132
|
+
// `{ events: [], hasMore: false }`, and the client reads that first empty page as
|
|
133
|
+
// "no older history" — the load-earlier pill dies and recoverable history looks
|
|
134
|
+
// gone. Only the bridge that actually HOLDS the terminal may speak: a live session,
|
|
135
|
+
// or (a closed lane whose transcript is still on screen) its durable archive.
|
|
136
|
+
export function servesHistoryPage({ payloadHost, bridgeName, hasSession, hasArchive }) {
|
|
137
|
+
if (payloadHost && payloadHost !== bridgeName) return false
|
|
138
|
+
return !!(hasSession || hasArchive)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Epochs: /clear resets the per-session seq counter (bridge.mjs) but the durable
|
|
142
|
+
// archive is append-only and keeps every era. So one file can hold seq 1..500 (pre-clear)
|
|
143
|
+
// followed by seq 1..30 (post-clear) — colliding keys. `findIndex(e.seq >= beforeSeq)`
|
|
144
|
+
// then anchors in the WRONG era and a page splices two conversations together.
|
|
145
|
+
//
|
|
146
|
+
// A seq that does not strictly increase over its predecessor is, by construction, a
|
|
147
|
+
// counter reset: within one bridge run the counter only climbs, and a restart reseeds it
|
|
148
|
+
// from maxSeq(restored log). So the last regression marks the start of the CURRENT epoch,
|
|
149
|
+
// and only that epoch is pageable. Everything older is a conversation the user explicitly
|
|
150
|
+
// cleared — surfacing it under colliding seqs would be wrong twice over.
|
|
151
|
+
//
|
|
152
|
+
// This is read-side only: it needs no migration and fixes archives already on disk
|
|
153
|
+
// (every user's, right now) as well as ones written from here on.
|
|
154
|
+
function epochStart(all) {
|
|
155
|
+
let start = 0, last = -Infinity
|
|
156
|
+
for (let i = 0; i < all.length; i++) {
|
|
157
|
+
const s = all[i]?.seq
|
|
158
|
+
if (typeof s !== 'number') continue
|
|
159
|
+
if (s <= last) { start = i; last = s; continue } // counter went backwards → new era
|
|
160
|
+
last = s
|
|
161
|
+
}
|
|
162
|
+
return start
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readArchive(room, id) {
|
|
166
|
+
const f = eventsLog(room, id)
|
|
167
|
+
if (!fs.existsSync(f)) return null
|
|
168
|
+
const all = []
|
|
169
|
+
for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
|
|
170
|
+
if (!line) continue
|
|
171
|
+
try { all.push(JSON.parse(line)) } catch { /* skip a torn line */ }
|
|
172
|
+
}
|
|
173
|
+
return all
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The archive FLOOR: the lowest transcript seq durably stored for this terminal *in the
|
|
177
|
+
// current epoch*. The client compares its oldest loaded seq against this — only OFFER
|
|
178
|
+
// "load earlier" when there genuinely are older events in the archive (vs a fresh lane
|
|
179
|
+
// whose first turn happens to land at seq > 1, or an evicted-but-unarchived gap).
|
|
180
|
+
// null = no archive.
|
|
122
181
|
export function readDurableOldestSeq(room, id) {
|
|
123
182
|
try {
|
|
124
|
-
const
|
|
125
|
-
if (!
|
|
126
|
-
for (
|
|
127
|
-
if (
|
|
128
|
-
try { const e = JSON.parse(line); if (e && e.seq != null) return e.seq } catch { /* skip torn line */ }
|
|
183
|
+
const all = readArchive(room, id)
|
|
184
|
+
if (!all) return null
|
|
185
|
+
for (let i = epochStart(all); i < all.length; i++) {
|
|
186
|
+
if (all[i] && all[i].seq != null) return all[i].seq
|
|
129
187
|
}
|
|
130
188
|
return null
|
|
131
189
|
} catch { return null }
|
|
@@ -134,22 +192,19 @@ export function readDurableOldestSeq(room, id) {
|
|
|
134
192
|
// Read a backward page: events with transcript seq < beforeSeq (newest `limit` of
|
|
135
193
|
// them, no-seq chrome interleaved by file order preserved). Returns { events, hasMore }.
|
|
136
194
|
// beforeSeq null → the newest `limit` events (the tail). hasMore = there is older still.
|
|
195
|
+
// Never reads below the current epoch — see epochStart.
|
|
137
196
|
export function readDurablePage(room, id, beforeSeq, limit = 200) {
|
|
138
197
|
try {
|
|
139
|
-
const
|
|
140
|
-
if (!
|
|
141
|
-
const
|
|
142
|
-
for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
|
|
143
|
-
if (!line) continue
|
|
144
|
-
try { all.push(JSON.parse(line)) } catch { /* skip a torn line */ }
|
|
145
|
-
}
|
|
198
|
+
const all = readArchive(room, id)
|
|
199
|
+
if (!all) return { events: [], hasMore: false }
|
|
200
|
+
const from = epochStart(all)
|
|
146
201
|
let bound = all.length
|
|
147
202
|
if (beforeSeq != null) {
|
|
148
|
-
const i = all.findIndex((e) => e && e.seq != null && e.seq >= beforeSeq)
|
|
203
|
+
const i = all.findIndex((e, idx) => idx >= from && e && e.seq != null && e.seq >= beforeSeq)
|
|
149
204
|
if (i >= 0) bound = i
|
|
150
205
|
}
|
|
151
|
-
const start = Math.max(
|
|
152
|
-
return { events: all.slice(start, bound), hasMore: start >
|
|
206
|
+
const start = Math.max(from, bound - limit)
|
|
207
|
+
return { events: all.slice(start, Math.max(start, bound)), hasMore: start > from }
|
|
153
208
|
} catch { return { events: [], hasMore: false } }
|
|
154
209
|
}
|
|
155
210
|
|