switchroom 0.16.47 → 0.17.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/dist/agent-scheduler/index.js +3 -1
- package/dist/auth-broker/index.js +24 -8
- package/dist/cli/drive-write-pretool.mjs +0 -5
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +1358 -1030
- package/dist/cli/ui/index.html +84 -12
- package/dist/host-control/main.js +53 -17
- package/dist/vault/approvals/kernel-server.js +4 -1
- package/dist/vault/broker/server.js +201 -56
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +1 -1
- package/profiles/_base/start.sh.hbs +54 -3
- package/skills/switchroom-architecture/telegram.md +8 -15
- package/skills/switchroom-cli/SKILL.md +4 -5
- package/skills/telegram-test-harness/SKILL.md +1 -1
- package/telegram-plugin/README.md +18 -29
- package/telegram-plugin/bridge/bridge.ts +1 -41
- package/telegram-plugin/bridge/tool-filter.ts +3 -4
- package/telegram-plugin/dist/bridge/bridge.js +8 -43
- package/telegram-plugin/dist/gateway/gateway.js +682 -773
- package/telegram-plugin/dist/server.js +8 -43
- package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
- package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
- package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
- package/telegram-plugin/gateway/gateway.ts +518 -624
- package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
- package/telegram-plugin/gateway/inbound-spool.ts +108 -10
- package/telegram-plugin/gateway/model-command.ts +51 -3
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
- package/telegram-plugin/gateway/represent-guard.ts +28 -11
- package/telegram-plugin/gateway/status-pin-store.ts +124 -45
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
- package/telegram-plugin/history.ts +5 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
- package/telegram-plugin/registry/subagents-schema.ts +126 -1
- package/telegram-plugin/registry/turns-schema.ts +65 -1
- package/telegram-plugin/session-tail.ts +26 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -2
- package/telegram-plugin/status-query-telemetry.ts +100 -0
- package/telegram-plugin/stream-reply-handler.ts +15 -16
- package/telegram-plugin/subagent-watcher.ts +182 -30
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
- package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
- package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
- package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
- package/telegram-plugin/tests/model-command.test.ts +54 -1
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
- package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
- package/telegram-plugin/tests/registry-turns.test.ts +67 -0
- package/telegram-plugin/tests/represent-guard.test.ts +42 -6
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
- package/telegram-plugin/tests/session-tail.test.ts +10 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
- package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
- package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
- package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
- package/telegram-plugin/tests/tool-filter.test.ts +1 -3
- package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
- package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
- package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
- package/telegram-plugin/tool-activity-summary.ts +19 -0
- package/telegram-plugin/turn-flush-safety.ts +16 -1
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
- package/telegram-plugin/worker-activity-feed.ts +75 -15
- package/vendor/hindsight-memory/CHANGELOG.md +24 -0
- package/vendor/hindsight-memory/README.md +5 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
- package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
- package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +74 -1
- package/vendor/hindsight-memory/scripts/retain.py +8 -1
- package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
- package/vendor/hindsight-memory/settings.json +4 -0
- package/vendor/hindsight-memory/tests/test_client.py +130 -0
- package/vendor/hindsight-memory/tests/test_config.py +47 -0
- package/vendor/hindsight-memory/tests/test_content.py +18 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
- package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
- package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
- package/telegram-plugin/silent-reply.ts +0 -58
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
- package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
- package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flagship harness for the prose-silent background-worker visibility fix
|
|
3
|
+
* (PR 2 of 2). This is the automated proxy for the live-gateway test the
|
|
4
|
+
* operator demanded: it wires the REAL `startSubagentWatcher` to the REAL
|
|
5
|
+
* `createWorkerActivityFeed` on ONE shared virtual clock, mirroring the
|
|
6
|
+
* gateway's `onProgress → workerActivityFeed.update` and
|
|
7
|
+
* `onFinish → workerActivityFeed.finish` wiring, and drives a sub-agent JSONL
|
|
8
|
+
* that contains ONLY tool_use events (a long `Bash` run) — NO `sub_agent_text`.
|
|
9
|
+
*
|
|
10
|
+
* It asserts the four outcomes the fix must deliver:
|
|
11
|
+
* (a) the `🛠 Worker` feed PAINTS a message (gets a messageId) within the
|
|
12
|
+
* expected window even though the worker never emitted prose,
|
|
13
|
+
* (b) the message keeps UPDATING via the heartbeat while the worker is alive,
|
|
14
|
+
* (c) NO false stall is flagged during the Bash window (the worker mid-`Bash`
|
|
15
|
+
* is not "stalled" just because its JSONL went quiet), and
|
|
16
|
+
* (d) the 300s-style silent-stall TERMINAL synthesis still releases the
|
|
17
|
+
* deferred-completion gate at the end (onStallTerminal + onFinish fire,
|
|
18
|
+
* and the feed finalizes) — the load-bearing completion path is intact.
|
|
19
|
+
*
|
|
20
|
+
* The pre-fix behaviour this locks against: a background worker that dives
|
|
21
|
+
* straight into a long `Bash` fired one tool tick (before firstPaintMin, so the
|
|
22
|
+
* paint was held), then no further JSONL lines — and the heartbeat skipped
|
|
23
|
+
* `messageId == null` handles, so the worker showed NOTHING for its entire run
|
|
24
|
+
* while ALSO being falsely flagged "stall detected (idle 60s)".
|
|
25
|
+
*
|
|
26
|
+
* Uses vitest (no bun:sqlite): no registry DB is wired, so the gate-release
|
|
27
|
+
* proxy is the `onStallTerminal` + `onFinish` callbacks — the exact signals the
|
|
28
|
+
* gateway's handback delivery and deferred-completion release consume.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
32
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
33
|
+
import {
|
|
34
|
+
createWorkerActivityFeed,
|
|
35
|
+
type WorkerActivityView,
|
|
36
|
+
} from '../worker-activity-feed.js'
|
|
37
|
+
import * as fs from 'fs'
|
|
38
|
+
|
|
39
|
+
function buildJSONL(...lines: object[]): string {
|
|
40
|
+
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
|
|
41
|
+
}
|
|
42
|
+
function subAgentUserMsg(promptText: string) {
|
|
43
|
+
return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
|
|
44
|
+
}
|
|
45
|
+
function bashToolUse(id: string, command: string) {
|
|
46
|
+
return { type: 'assistant', message: { content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] } }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface FakeBot {
|
|
50
|
+
sent: Array<{ chatId: string; text: string }>
|
|
51
|
+
edits: Array<{ messageId: number; text: string }>
|
|
52
|
+
sendMessage: (chatId: string, text: string) => Promise<{ message_id: number }>
|
|
53
|
+
editMessageText: (chatId: string, messageId: number, text: string) => Promise<unknown>
|
|
54
|
+
}
|
|
55
|
+
function makeFakeBot(): FakeBot {
|
|
56
|
+
let nextId = 5000
|
|
57
|
+
const fb: FakeBot = {
|
|
58
|
+
sent: [],
|
|
59
|
+
edits: [],
|
|
60
|
+
sendMessage: async (chatId, text) => {
|
|
61
|
+
fb.sent.push({ chatId, text })
|
|
62
|
+
return { message_id: nextId++ }
|
|
63
|
+
},
|
|
64
|
+
editMessageText: async (_chatId, messageId, text) => {
|
|
65
|
+
fb.edits.push({ messageId, text })
|
|
66
|
+
return {}
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
return fb
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Flush the feed's async chains (microtasks + the awaited fake bot). */
|
|
73
|
+
const flush = async (): Promise<void> => {
|
|
74
|
+
for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function makeHarness() {
|
|
78
|
+
const agentId = 'prose-silent-bash-01'
|
|
79
|
+
const chatId = 'chat-42'
|
|
80
|
+
let currentTime = 1000
|
|
81
|
+
|
|
82
|
+
const stallCalls: Array<{ agentId: string; idleMs: number }> = []
|
|
83
|
+
const stallTerminalCalls: Array<{ agentId: string }> = []
|
|
84
|
+
const finishCalls: Array<{ agentId: string; outcome: string }> = []
|
|
85
|
+
|
|
86
|
+
const agentDir = '/home/user/.switchroom/agents/myagent'
|
|
87
|
+
const sessionId = 'mock-session'
|
|
88
|
+
const projectsRoot = `${agentDir}/.claude/projects`
|
|
89
|
+
const projectDir = `${projectsRoot}/mock-cwd`
|
|
90
|
+
const sessionDir = `${projectDir}/${sessionId}`
|
|
91
|
+
const subagentsDir = `${sessionDir}/subagents`
|
|
92
|
+
const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
|
|
93
|
+
const fileContents = new Map<string, Buffer>()
|
|
94
|
+
// Start with ONLY the dispatch user message — no tool, no prose. The Bash
|
|
95
|
+
// tool_use is appended post-boot so it fires onProgress on a live (non-
|
|
96
|
+
// historical) entry, exactly like a worker that starts then dives into Bash.
|
|
97
|
+
fileContents.set(jsonlPath, Buffer.from(buildJSONL(subAgentUserMsg('run the integration suite')), 'utf-8'))
|
|
98
|
+
|
|
99
|
+
let lastOpenedPath: string | null = null
|
|
100
|
+
const mockFs = {
|
|
101
|
+
existsSync: ((p: fs.PathLike) => {
|
|
102
|
+
const ps = String(p)
|
|
103
|
+
if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
|
|
104
|
+
return fileContents.has(ps)
|
|
105
|
+
}) as typeof fs.existsSync,
|
|
106
|
+
readdirSync: ((p: fs.PathLike) => {
|
|
107
|
+
const ps = String(p)
|
|
108
|
+
if (ps === projectsRoot) return ['mock-cwd']
|
|
109
|
+
if (ps === projectDir) return [sessionId]
|
|
110
|
+
if (ps === sessionDir) return ['subagents']
|
|
111
|
+
if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
|
|
112
|
+
return []
|
|
113
|
+
}) as unknown as typeof fs.readdirSync,
|
|
114
|
+
statSync: ((p: fs.PathLike) => ({ size: fileContents.get(String(p))?.length ?? 0 }) as fs.Stats) as typeof fs.statSync,
|
|
115
|
+
openSync: ((p: fs.PathLike) => { lastOpenedPath = String(p); return 42 }) as unknown as typeof fs.openSync,
|
|
116
|
+
closeSync: (() => { lastOpenedPath = null }) as typeof fs.closeSync,
|
|
117
|
+
readSync: ((
|
|
118
|
+
_fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
|
|
119
|
+
): number => {
|
|
120
|
+
const content = lastOpenedPath != null ? fileContents.get(lastOpenedPath) : undefined
|
|
121
|
+
if (!content) return 0
|
|
122
|
+
const pos = position ?? 0
|
|
123
|
+
const src = content.slice(pos, pos + length)
|
|
124
|
+
;(src as Buffer).copy(buf as Buffer, offset)
|
|
125
|
+
return src.length
|
|
126
|
+
}) as unknown as typeof fs.readSync,
|
|
127
|
+
watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// One shared interval list drives BOTH the watcher (rescan + checkStalls)
|
|
131
|
+
// and the feed (heartbeat) off the same virtual clock.
|
|
132
|
+
const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
|
|
133
|
+
let nextRef = 1
|
|
134
|
+
const sharedSetInterval = (fn: () => void, ms: number): unknown => {
|
|
135
|
+
const ref = nextRef++
|
|
136
|
+
intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
|
|
137
|
+
return { ref }
|
|
138
|
+
}
|
|
139
|
+
const sharedClearInterval = (h: unknown): void => {
|
|
140
|
+
const { ref } = h as { ref: number }
|
|
141
|
+
const idx = intervals.findIndex((i) => i.ref === ref)
|
|
142
|
+
if (idx !== -1) intervals.splice(idx, 1)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const bot = makeFakeBot()
|
|
146
|
+
const feed = createWorkerActivityFeed({
|
|
147
|
+
bot,
|
|
148
|
+
now: () => currentTime,
|
|
149
|
+
firstPaintMinMs: 8000,
|
|
150
|
+
heartbeatTickMs: 6000,
|
|
151
|
+
minEditIntervalMs: 2500,
|
|
152
|
+
setInterval: sharedSetInterval,
|
|
153
|
+
clearInterval: sharedClearInterval,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const watcher = startSubagentWatcher({
|
|
157
|
+
agentDir,
|
|
158
|
+
stallThresholdMs: 60_000, // tight active-loop threshold
|
|
159
|
+
silentSynthesisStallThresholdMs: 300_000, // widened window (also long-runner window)
|
|
160
|
+
silentStallTerminalMs: 300_000, // post-stall terminal-synthesis window (unchanged)
|
|
161
|
+
rescanMs: 500,
|
|
162
|
+
now: () => currentTime,
|
|
163
|
+
setInterval: sharedSetInterval,
|
|
164
|
+
clearInterval: sharedClearInterval,
|
|
165
|
+
fs: mockFs,
|
|
166
|
+
onStall: (id, idleMs) => stallCalls.push({ agentId: id, idleMs }),
|
|
167
|
+
onStallTerminal: (id) => stallTerminalCalls.push({ agentId: id }),
|
|
168
|
+
onFinish: ({ agentId: id, outcome, description, resultText, toolCount, durationMs }) => {
|
|
169
|
+
finishCalls.push({ agentId: id, outcome })
|
|
170
|
+
// Mirror the gateway's terminal wiring: force the worker feed's recap.
|
|
171
|
+
void feed.finish(id, {
|
|
172
|
+
description,
|
|
173
|
+
lastTool: null,
|
|
174
|
+
toolCount,
|
|
175
|
+
latestSummary: resultText,
|
|
176
|
+
elapsedMs: durationMs,
|
|
177
|
+
state: outcome === 'failed' ? 'failed' : 'done',
|
|
178
|
+
})
|
|
179
|
+
},
|
|
180
|
+
// Mirror the gateway's onProgress → workerActivityFeed.update wiring for a
|
|
181
|
+
// background worker (worker-feed owns the progress beat).
|
|
182
|
+
onProgress: ({ agentId: id, description, latestSummary, elapsedMs, lastTool, toolCount, progressLine }) => {
|
|
183
|
+
const view: WorkerActivityView = {
|
|
184
|
+
description,
|
|
185
|
+
lastTool,
|
|
186
|
+
toolCount,
|
|
187
|
+
// Mirror the gateway's step-line precedence: the friendly tool label
|
|
188
|
+
// on tool ticks, else the narrative (the unified-cards fix — a
|
|
189
|
+
// tools-only worker must grow real steps, not freeze on "starting…").
|
|
190
|
+
latestSummary: progressLine != null && progressLine.length > 0 ? progressLine : latestSummary,
|
|
191
|
+
elapsedMs,
|
|
192
|
+
state: 'running',
|
|
193
|
+
}
|
|
194
|
+
void feed.update(id, chatId, view)
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const advance = (ms: number): void => {
|
|
199
|
+
currentTime += ms
|
|
200
|
+
for (;;) {
|
|
201
|
+
intervals.sort((a, b) => a.fireAt - b.fireAt)
|
|
202
|
+
const next = intervals[0]
|
|
203
|
+
if (!next || next.fireAt > currentTime) break
|
|
204
|
+
next.fireAt += next.ms
|
|
205
|
+
next.fn()
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const appendBash = (): void => {
|
|
210
|
+
const existing = fileContents.get(jsonlPath) ?? Buffer.from('')
|
|
211
|
+
const line = JSON.stringify(bashToolUse('tool-bash-1', 'npm run test:integration')) + '\n'
|
|
212
|
+
fileContents.set(jsonlPath, Buffer.concat([existing, Buffer.from(line, 'utf-8')]))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
agentId, bot, feed, watcher, advance, appendBash, flush,
|
|
217
|
+
stallCalls, stallTerminalCalls, finishCalls,
|
|
218
|
+
unmarkHistorical: () => {
|
|
219
|
+
const e = watcher.getRegistry().get(agentId)
|
|
220
|
+
if (e) e.historical = false
|
|
221
|
+
},
|
|
222
|
+
registry: () => watcher.getRegistry(),
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
describe('prose-silent background worker — end-to-end visibility harness (PR 2)', () => {
|
|
227
|
+
it('paints, keeps updating, does not falsely stall, and still releases the terminal gate', async () => {
|
|
228
|
+
const h = makeHarness()
|
|
229
|
+
|
|
230
|
+
// Boot: register the entry (historical=true at boot). Flip it live to model
|
|
231
|
+
// an entry discovered post-boot (the only case progress/stall fire).
|
|
232
|
+
h.advance(500)
|
|
233
|
+
h.unmarkHistorical()
|
|
234
|
+
|
|
235
|
+
// The worker dives into a long Bash. The single tool_use fires onProgress —
|
|
236
|
+
// BEFORE firstPaintMin — so the feed HOLDS the paint (this is the trap).
|
|
237
|
+
h.appendBash()
|
|
238
|
+
h.advance(500) // poll reads the Bash line → onProgress(tool) → feed.update
|
|
239
|
+
await h.flush()
|
|
240
|
+
const entry = h.registry().get(h.agentId)
|
|
241
|
+
expect(entry?.lastTool?.name).toBe('Bash')
|
|
242
|
+
expect(entry?.toolCount).toBe(1)
|
|
243
|
+
// Held: elapsed (~1s) < firstPaintMin (8s), no paint yet.
|
|
244
|
+
expect(h.bot.sent).toHaveLength(0)
|
|
245
|
+
expect(h.feed.messageIdOf(h.agentId)).toBeNull()
|
|
246
|
+
|
|
247
|
+
// (a) PAINT: with NO further JSONL events, the heartbeat performs the first
|
|
248
|
+
// paint once the held handle is past firstPaintMin. Pre-fix this never
|
|
249
|
+
// happened (heartbeat skipped messageId==null) → the worker was invisible.
|
|
250
|
+
h.advance(12_000) // ~12.5s since dispatch — past firstPaintMin, heartbeats fire
|
|
251
|
+
await h.flush()
|
|
252
|
+
expect(h.bot.sent).toHaveLength(1)
|
|
253
|
+
expect(h.feed.messageIdOf(h.agentId)).not.toBeNull()
|
|
254
|
+
expect(h.bot.sent[0].chatId).toBe('chat-42')
|
|
255
|
+
expect(h.bot.sent[0].text).toContain('🛠 **Worker**')
|
|
256
|
+
|
|
257
|
+
// (b) KEEPS UPDATING: later heartbeats edit the message with a climbing
|
|
258
|
+
// `· Ns` suffix so the still-alive worker visibly advances.
|
|
259
|
+
h.advance(30_000)
|
|
260
|
+
await h.flush()
|
|
261
|
+
expect(h.bot.edits.length).toBeGreaterThanOrEqual(1)
|
|
262
|
+
expect(h.bot.edits[h.bot.edits.length - 1].text).toMatch(/· \d+/)
|
|
263
|
+
|
|
264
|
+
// (c) NO FALSE STALL during the Bash window: the worker's last tool is a
|
|
265
|
+
// long-runner, so the tight 60s active-loop threshold does NOT apply. We
|
|
266
|
+
// are now well past 60s of JSONL silence with zero stall flagged.
|
|
267
|
+
h.advance(60_000) // total idle ~102s — far past the old 60s misfire point
|
|
268
|
+
await h.flush()
|
|
269
|
+
expect(h.stallCalls).toHaveLength(0)
|
|
270
|
+
// The feed is still live and updating during this window.
|
|
271
|
+
expect(h.bot.edits.length).toBeGreaterThanOrEqual(2)
|
|
272
|
+
|
|
273
|
+
// The stall IS eventually flagged once past the widened silent-synthesis
|
|
274
|
+
// window (the worker genuinely never wrote another line / turn_end).
|
|
275
|
+
h.advance(260_000) // total idle > 300s → stall flagged
|
|
276
|
+
await h.flush()
|
|
277
|
+
expect(h.stallCalls).toHaveLength(1)
|
|
278
|
+
expect(h.stallCalls[0].agentId).toBe(h.agentId)
|
|
279
|
+
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
280
|
+
|
|
281
|
+
// (d) TERMINAL GATE RELEASE: 300s past the stall, the silent-stall terminal
|
|
282
|
+
// synthesis fires — onStallTerminal + onFinish — and the feed finalizes.
|
|
283
|
+
// This is the untouched, load-bearing completion path.
|
|
284
|
+
h.advance(310_000)
|
|
285
|
+
await h.flush()
|
|
286
|
+
expect(h.stallTerminalCalls).toHaveLength(1)
|
|
287
|
+
expect(h.finishCalls).toHaveLength(1)
|
|
288
|
+
expect(h.finishCalls[0].agentId).toBe(h.agentId)
|
|
289
|
+
// The feed's terminal recap edited the existing worker message to done.
|
|
290
|
+
const lastEdit = h.bot.edits[h.bot.edits.length - 1]
|
|
291
|
+
expect(lastEdit.text).toContain('done')
|
|
292
|
+
// Handle dropped after finish (gateway unpins independently).
|
|
293
|
+
expect(h.feed.messageIdOf(h.agentId)).toBeNull()
|
|
294
|
+
})
|
|
295
|
+
})
|
|
@@ -192,6 +192,25 @@ export function formatFeedElapsed(ms: number): string {
|
|
|
192
192
|
return `${m}m${(s % 60).toString().padStart(2, '0')}s`
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Minimum time the CURRENT step must have been running before its own
|
|
197
|
+
* `· <elapsed>` suffix appears on the `→` line. Under this, no suffix — a
|
|
198
|
+
* fresh step reads cleaner without a timer, and the header total already
|
|
199
|
+
* carries the turn/worker elapsed.
|
|
200
|
+
*/
|
|
201
|
+
export const STEP_TIMER_MIN_MS = 10_000
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Live-suffix for the in-progress step line: the STEP's OWN elapsed (time
|
|
205
|
+
* since that step started — NOT the turn/worker total, which lives in the
|
|
206
|
+
* header). Empty string until the step has run ≥ STEP_TIMER_MIN_MS, so the
|
|
207
|
+
* suffix never duplicates the header total on a young step.
|
|
208
|
+
*/
|
|
209
|
+
export function formatStepSuffix(stepElapsedMs: number): string {
|
|
210
|
+
if (stepElapsedMs < STEP_TIMER_MIN_MS) return ''
|
|
211
|
+
return ` · ${formatFeedElapsed(stepElapsedMs)}`
|
|
212
|
+
}
|
|
213
|
+
|
|
195
214
|
// ─── Truncation pipeline (the single correctness-critical primitive) ────────
|
|
196
215
|
//
|
|
197
216
|
// Per RAW line, in this EXACT order:
|
|
@@ -190,7 +190,22 @@ export function decideTurnFlush(input: FlushDecisionInput): FlushDecision {
|
|
|
190
190
|
if (input.replyCalled) return { kind: 'skip', reason: 'reply-called' }
|
|
191
191
|
|
|
192
192
|
if (input.chatId == null) return { kind: 'skip', reason: 'no-inbound-chat' }
|
|
193
|
-
|
|
193
|
+
// #2798 — join whole authored assistant text blocks with a PARAGRAPH break,
|
|
194
|
+
// not a single newline. Each `capturedText` element is one complete
|
|
195
|
+
// `content[i].text` block (session-tail.ts `projectAssistantTextBlocks`), so
|
|
196
|
+
// the boundary between two elements is a paragraph boundary. Joining with a
|
|
197
|
+
// lone `\n` collapses adjacent blocks into one run — on the Bot API 10.1
|
|
198
|
+
// rich-markdown path (#2669) a single newline is a soft break, so the blocks
|
|
199
|
+
// render as an undifferentiated wall-of-text. `\n\n` is the GFM paragraph
|
|
200
|
+
// separator; the gateway send path then wedges visible spacers into those
|
|
201
|
+
// gaps via addParagraphSpacers (mirroring the reply path, #2692) so the
|
|
202
|
+
// paragraphs render with real separation.
|
|
203
|
+
//
|
|
204
|
+
// The silent-marker guards below are unaffected by this change:
|
|
205
|
+
// isSilentFlushMarker length-guards the whole joined string; the composite /
|
|
206
|
+
// trailing-marker guards split on '\n' and filter empty lines, so the extra
|
|
207
|
+
// blank line an '\n\n' join introduces is discarded before matching.
|
|
208
|
+
const joined = input.capturedText.join('\n\n').trim()
|
|
194
209
|
if (joined.length === 0) return { kind: 'skip', reason: 'empty-text' }
|
|
195
210
|
if (isSilentFlushMarker(joined)) return { kind: 'skip', reason: 'silent-marker' }
|
|
196
211
|
// Composite silent noise — e.g. "Sent.\nNO_REPLY\nNO_REPLY" accumulated
|
|
@@ -17,15 +17,14 @@
|
|
|
17
17
|
* Runs under CI `uat-gate`; the full live MTProto run needs the test-harness
|
|
18
18
|
* agent + a vault session, so locally this self-skips green (no driver).
|
|
19
19
|
*
|
|
20
|
-
* Scope caveat: this end-to-end scenario
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* live smoke-test of the happy path, not the source of truth.
|
|
20
|
+
* Scope caveat: this end-to-end scenario exercises PR-2's upgrade code path
|
|
21
|
+
* when the harness model delivers its final answer via the `reply` tool —
|
|
22
|
+
* which is now the single final-answer tool (the redundant `stream_reply`
|
|
23
|
+
* tool was retired). The model's exact phrasing isn't forceable here, which
|
|
24
|
+
* makes this scenario a WEAKER backstop than the unit matrix — the real proof
|
|
25
|
+
* of the upgrade behaviour lives in the deterministic unit tests in
|
|
26
|
+
* `over-ping-final-answer-decoupling.test.ts`. Treat this as a live
|
|
27
|
+
* smoke-test of the happy path, not the source of truth.
|
|
29
28
|
*/
|
|
30
29
|
import { describe, it, expect, beforeAll } from "vitest";
|
|
31
30
|
import { spinUp, type Scenario } from "../harness.js";
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* (`SILENCE_FALLBACK_MS`, default 300 000 ms) even while it is visibly
|
|
9
9
|
* progressing. The silence clock is reset by:
|
|
10
10
|
*
|
|
11
|
-
* - A fresh `reply`
|
|
11
|
+
* - A fresh `reply` first-emit (any real user-visible send).
|
|
12
12
|
* - `SILENCE_LIVENESS_PRODUCTION` ON (the default): a new tool-activity label
|
|
13
13
|
* appearing on the feed, or an answer-stream draft update.
|
|
14
14
|
*
|
|
@@ -100,7 +100,7 @@ describe("uat-jtbd: narration intent before a silent tool stretch", () => {
|
|
|
100
100
|
const toolCount = events.filter((e) => e.kind === "tool_use").length;
|
|
101
101
|
expect(toolCount).toBeGreaterThan(1);
|
|
102
102
|
const replyToolFired = events.some(
|
|
103
|
-
(e) => e.kind === "tool_use" && /^
|
|
103
|
+
(e) => e.kind === "tool_use" && /^reply$/.test(e.toolName),
|
|
104
104
|
);
|
|
105
105
|
expect(replyToolFired).toBe(false);
|
|
106
106
|
});
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
truncate,
|
|
39
39
|
} from './card-format.js'
|
|
40
40
|
import { STATUS_ROLLING_LINES } from './status-no-truncate.js'
|
|
41
|
-
import { renderStatusCard,
|
|
41
|
+
import { renderStatusCard, formatStepSuffix } from './tool-activity-summary.js'
|
|
42
42
|
|
|
43
43
|
/** Worker-activity feed is ON by default; an operator opts out with
|
|
44
44
|
* SWITCHROOM_WORKER_ACTIVITY_FEED=0. */
|
|
@@ -226,6 +226,13 @@ interface WorkerHandle {
|
|
|
226
226
|
* `· Ns` suffix climbs even when no fresh view arrives.
|
|
227
227
|
*/
|
|
228
228
|
dispatchAtMs: number | null
|
|
229
|
+
/**
|
|
230
|
+
* Wall-clock ms the CURRENT step started — stamped whenever a NEW narrative
|
|
231
|
+
* line lands (the `→` line changes). The heartbeat's step suffix shows the
|
|
232
|
+
* step's OWN elapsed from this anchor (not the worker total, which the
|
|
233
|
+
* header already carries), and only once past STEP_TIMER_MIN_MS.
|
|
234
|
+
*/
|
|
235
|
+
stepStartedAtMs: number | null
|
|
229
236
|
}
|
|
230
237
|
|
|
231
238
|
const COOLDOWN_JITTER_MS = 500
|
|
@@ -311,10 +318,19 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
311
318
|
function accumulateNarrative(h: WorkerHandle, view: WorkerActivityView): void {
|
|
312
319
|
const line = view.latestSummary.trim()
|
|
313
320
|
if (line.length === 0) return
|
|
314
|
-
// Dedup
|
|
315
|
-
//
|
|
316
|
-
|
|
321
|
+
// Dedup within the whole rolling window, not just the immediately-
|
|
322
|
+
// preceding line. The watcher re-emits the same narrative across ticks
|
|
323
|
+
// while a tool runs (adjacent repeats), AND one logical step can surface
|
|
324
|
+
// twice non-adjacently — e.g. a "Look for X" preamble followed later by
|
|
325
|
+
// the Task tool whose describeToolUse label is the same "Look for X"
|
|
326
|
+
// description, interleaved with another step (the A,B,A duplication the
|
|
327
|
+
// operator observed on live cards). A legitimate later re-visit of the
|
|
328
|
+
// same step re-appears once the earlier copy scrolls out of the window.
|
|
329
|
+
if (h.narrative.includes(line)) return
|
|
317
330
|
h.narrative.push(line)
|
|
331
|
+
// The `→` current-step line just CHANGED — reset the per-step timer so the
|
|
332
|
+
// heartbeat's `· Ns` suffix measures THIS step, not the whole worker run.
|
|
333
|
+
h.stepStartedAtMs = nowFn()
|
|
318
334
|
// Rolling window — keep only the last STATUS_ROLLING_LINES in memory. The
|
|
319
335
|
// render shows exactly those lines (clipped per-line by the unified pipeline);
|
|
320
336
|
// fitCardToBudget is the wire-limit backstop.
|
|
@@ -405,13 +421,27 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
405
421
|
}
|
|
406
422
|
|
|
407
423
|
/**
|
|
408
|
-
* Heartbeat —
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
424
|
+
* Heartbeat — keeps a running worker's message alive AND performs the
|
|
425
|
+
* FIRST paint for a prose-silent worker whose only tick arrived before
|
|
426
|
+
* `firstPaintMin`.
|
|
427
|
+
*
|
|
428
|
+
* Why the first-paint branch exists: a background worker that dives
|
|
429
|
+
* straight into quiet work (e.g. a long `Bash` / `npm test`) emits a
|
|
430
|
+
* single `sub_agent_tool_use` event when the command is invoked, then no
|
|
431
|
+
* further JSONL lines for the whole run. That one tick drives `update`
|
|
432
|
+
* once — but if it lands before `firstPaintMin` the paint is held, and
|
|
433
|
+
* with no subsequent tick nothing ever re-drives it, so the worker shows
|
|
434
|
+
* NOTHING for its entire run (the "I can't see the worker" gap). The
|
|
435
|
+
* heartbeat closes it: once such a handle is past `firstPaintMin`, drive a
|
|
436
|
+
* paint here through the same chain → doUpdate path. After first paint the
|
|
437
|
+
* suffix-only maintenance branch keeps it advancing.
|
|
438
|
+
*
|
|
439
|
+
* For handles that already have a posted message, this is the original
|
|
440
|
+
* option-(a), suffix-only re-render (never editMessageText directly). Skips:
|
|
412
441
|
* - handles inside a 429 cooldown,
|
|
413
|
-
* - handles
|
|
414
|
-
* -
|
|
442
|
+
* - handles with no `lastView` (no update ever arrived) or non-running,
|
|
443
|
+
* - for the maintenance branch: handles edited within minEditInterval
|
|
444
|
+
* (no stampede) or whose current step isn't yet stale.
|
|
415
445
|
* The `· Ns` liveSuffix is applied ONLY when the worker's current step is
|
|
416
446
|
* stale (now - lastEditAt >= heartbeatTickMs) so a normally-ticking worker is
|
|
417
447
|
* untouched and its body stays byte-stable for the dedup.
|
|
@@ -419,19 +449,48 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
419
449
|
function heartbeatTick(): void {
|
|
420
450
|
const now = nowFn()
|
|
421
451
|
for (const h of handles.values()) {
|
|
422
|
-
|
|
452
|
+
// Orphan-paint guard: `finish()` deletes the handle in a `.finally` that
|
|
453
|
+
// may not have drained if a tick fires in the same synchronous stretch.
|
|
454
|
+
// Skip any handle no longer in the map so the first-paint branch below
|
|
455
|
+
// can never send a fresh `running` message on an already-finished worker
|
|
456
|
+
// (which would orphan a card that never finalizes). Restores the
|
|
457
|
+
// structural safety the pre-first-paint `messageId == null` skip gave.
|
|
458
|
+
if (!handles.has(h.agentId)) continue
|
|
423
459
|
if (h.lastView == null) continue
|
|
424
460
|
if (h.lastView.state !== 'running') continue
|
|
425
461
|
if (now < h.cooldownUntil) continue
|
|
462
|
+
|
|
463
|
+
const liveElapsed = h.dispatchAtMs != null ? now - h.dispatchAtMs : h.lastView.elapsedMs
|
|
464
|
+
|
|
465
|
+
// First-paint path: a prose-silent worker's single early tick was held
|
|
466
|
+
// (elapsed < firstPaintMin) and no further tick re-drove it. Once it is
|
|
467
|
+
// past firstPaintMin, drive the paint. doUpdate's send branch re-checks
|
|
468
|
+
// firstPaintMin against the refreshed elapsed, so this is exact.
|
|
469
|
+
if (h.messageId == null) {
|
|
470
|
+
if (liveElapsed < firstPaintMin) continue
|
|
471
|
+
const view = { ...h.lastView, elapsedMs: Math.max(h.lastView.elapsedMs, liveElapsed) }
|
|
472
|
+
h.chain = h.chain
|
|
473
|
+
.then(() => doUpdate(h, view))
|
|
474
|
+
.catch((err) => {
|
|
475
|
+
log(`worker-feed: heartbeat first-paint chain error ${h.agentId}: ${(err as Error).message}`)
|
|
476
|
+
})
|
|
477
|
+
continue
|
|
478
|
+
}
|
|
479
|
+
|
|
426
480
|
if (now - h.lastEditAt < minEditInterval) continue
|
|
427
481
|
const stale = now - h.lastEditAt >= heartbeatTickMs
|
|
428
482
|
if (!stale) continue
|
|
429
|
-
|
|
430
|
-
|
|
483
|
+
// Per-step suffix: the CURRENT step's own elapsed (since the `→` line
|
|
484
|
+
// last changed), never the worker total — the header already shows the
|
|
485
|
+
// total, and repeating it on the step line was the Ken-observed dupe.
|
|
486
|
+
// Under STEP_TIMER_MIN_MS formatStepSuffix returns '' (no timer yet);
|
|
487
|
+
// the header elapsed still climbs via the refreshed view below.
|
|
488
|
+
const stepElapsed = h.stepStartedAtMs != null ? now - h.stepStartedAtMs : liveElapsed
|
|
489
|
+
const liveSuffix = formatStepSuffix(stepElapsed)
|
|
431
490
|
// Re-render THROUGH the chain + doUpdate path — never editMessageText directly.
|
|
432
491
|
//
|
|
433
|
-
// CLOCK-ANCHOR PARITY: refresh the view's elapsedMs to the
|
|
434
|
-
//
|
|
492
|
+
// CLOCK-ANCHOR PARITY: refresh the view's elapsedMs to the same `now`
|
|
493
|
+
// anchor the step suffix uses. The header renders
|
|
435
494
|
// `view.elapsedMs`; passing the stale lastView froze the header at the
|
|
436
495
|
// last watcher event while the `· Ns` suffix kept ticking, so the
|
|
437
496
|
// current step's timer could read MORE than the card's master elapsed
|
|
@@ -478,6 +537,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
478
537
|
chain: Promise.resolve(),
|
|
479
538
|
lastView: null,
|
|
480
539
|
dispatchAtMs: null,
|
|
540
|
+
stepStartedAtMs: null,
|
|
481
541
|
}
|
|
482
542
|
handles.set(agentId, h)
|
|
483
543
|
}
|
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
### Ported from upstream (vectorize-io/hindsight, `hindsight-integrations/claude-code/`)
|
|
6
|
+
|
|
7
|
+
- `c5a61db2b` — raise `_check_health` default timeout 2s→10s in
|
|
8
|
+
`scripts/lib/daemon.py` to stop the busy-daemon restart/kill loop
|
|
9
|
+
(applied clean; codex-integration hunk not applicable).
|
|
10
|
+
- `3d6c2ba8b` — label "Current time" as UTC in the recall context block
|
|
11
|
+
(`lib/content.py:format_current_time`), so client LLMs in non-UTC
|
|
12
|
+
timezones don't misread the timestamp as local time.
|
|
13
|
+
- `962140eef` — recall tag filters: `recallTags`, `recallTagsMatch`,
|
|
14
|
+
`recallTagGroups`, plus per-additional-bank overrides via
|
|
15
|
+
`recallAdditionalBankFilters`. Hand-ported into the switchroom recall.py
|
|
16
|
+
rewrite: filters compose with sender-bank routing (per-bank overrides
|
|
17
|
+
apply to sender banks too) and are part of the recall cache key
|
|
18
|
+
(`_tag_filter_sig`) so a filter change can't serve stale cached results.
|
|
19
|
+
Note: because the key now joins an extra part (empty string when filters
|
|
20
|
+
are unused), every cache key rotates ONCE across this upgrade boundary —
|
|
21
|
+
the first recall per session after upgrading is a cache miss. Within a
|
|
22
|
+
version, keys are unchanged as long as filters stay unused.
|
|
23
|
+
- `55ef70679` — optional `requestTimeoutSeconds` /
|
|
24
|
+
`HINDSIGHT_REQUEST_TIMEOUT_SECONDS` global request-timeout override in
|
|
25
|
+
`HindsightClient` (adapted to our `_request`). Wired into retain.py only;
|
|
26
|
+
recall.py deliberately keeps its own 8s hook-budget timeout. Upstream's
|
|
27
|
+
mcp_server.py hunks skipped (not vendored).
|
|
28
|
+
|
|
5
29
|
### Added
|
|
6
30
|
|
|
7
31
|
- `{user_id}` template variable for `retainTags` and `retainMetadata`, resolved
|
|
@@ -186,6 +186,11 @@ Auto-recall runs on every user prompt. It queries Hindsight for relevant memorie
|
|
|
186
186
|
| `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `1` | How many prior conversation turns to include when composing the recall query. `1` = only the latest user message; higher values give more context but may dilute the query. |
|
|
187
187
|
| `recallMaxQueryChars` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | `800` | Maximum character length of the query sent to Hindsight. Longer queries are truncated. |
|
|
188
188
|
| `recallRoles` | — | `["user", "assistant"]` | Which message roles to include when building the recall query from prior turns. |
|
|
189
|
+
| `recallTags` | `HINDSIGHT_RECALL_TAGS` | `[]` | Optional tags to pass to the recall API, such as `["memory_type:rule"]`. The env var accepts JSON or a comma-separated list. |
|
|
190
|
+
| `recallTagsMatch` | `HINDSIGHT_RECALL_TAGS_MATCH` | `"any"` | Tag matching mode used with `recallTags` or `recallTagGroups`: `"any"`, `"all"`, `"any_strict"`, or `"all_strict"`. |
|
|
191
|
+
| `recallTagGroups` | `HINDSIGHT_RECALL_TAG_GROUPS` | `null` | Optional compound tag filter passed through to the recall API. The env var must be JSON. |
|
|
192
|
+
| `recallAdditionalBankFilters` | `HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS` | `{}` | Optional per-bank tag filter overrides for banks listed in `recallAdditionalBanks`, keyed by bank ID. Each value may set `recallTags`, `recallTagsMatch`, and `recallTagGroups`. The env var must be JSON. |
|
|
193
|
+
| `requestTimeoutSeconds` | `HINDSIGHT_REQUEST_TIMEOUT_SECONDS` | unset | Optional global HTTP request timeout override (seconds) for retain and API calls. The recall hook keeps its own 8s hook-budget timeout regardless. |
|
|
189
194
|
| `recallPromptPreamble` | — | built-in string | Text placed above the recalled memories in the injected context block. Customize this to change how Claude interprets the memories. |
|
|
190
195
|
|
|
191
196
|
---
|
|
@@ -44,9 +44,29 @@ def _validate_api_url(url: str) -> str:
|
|
|
44
44
|
class HindsightClient:
|
|
45
45
|
"""HTTP client for the Hindsight API."""
|
|
46
46
|
|
|
47
|
-
def __init__(
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
api_url: str,
|
|
50
|
+
api_token: Optional[str] = None,
|
|
51
|
+
request_timeout_override: Optional[int] = None,
|
|
52
|
+
):
|
|
48
53
|
self.api_url = _validate_api_url(api_url)
|
|
49
54
|
self.api_token = api_token
|
|
55
|
+
self.request_timeout_override = request_timeout_override
|
|
56
|
+
|
|
57
|
+
def _resolve_timeout(self, timeout: int) -> int:
|
|
58
|
+
"""Return the override if configured, otherwise the caller's timeout.
|
|
59
|
+
|
|
60
|
+
Upstream 55ef70679. NOTE: recall.py deliberately does not pass the
|
|
61
|
+
override — its 8s timeout is a hook-budget invariant.
|
|
62
|
+
|
|
63
|
+
The override is clamped to >= 1: a zero/negative env value would
|
|
64
|
+
otherwise reach urlopen as a nonsensical timeout (0 fails every
|
|
65
|
+
request immediately), turning a config typo into a dead client.
|
|
66
|
+
"""
|
|
67
|
+
if self.request_timeout_override is None:
|
|
68
|
+
return timeout
|
|
69
|
+
return max(1, self.request_timeout_override)
|
|
50
70
|
|
|
51
71
|
def _headers(self) -> dict:
|
|
52
72
|
headers = {
|
|
@@ -58,6 +78,7 @@ class HindsightClient:
|
|
|
58
78
|
return headers
|
|
59
79
|
|
|
60
80
|
def _request(self, method: str, path: str, body: Optional[dict] = None, timeout: int = DEFAULT_TIMEOUT) -> dict:
|
|
81
|
+
timeout = self._resolve_timeout(timeout)
|
|
61
82
|
url = f"{self.api_url}{path}"
|
|
62
83
|
data = json.dumps(body).encode() if body else None
|
|
63
84
|
req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
|
|
@@ -100,6 +121,9 @@ class HindsightClient:
|
|
|
100
121
|
max_tokens: int = 1024,
|
|
101
122
|
budget: str = "mid",
|
|
102
123
|
types: Optional[list] = None,
|
|
124
|
+
tags: Optional[list] = None,
|
|
125
|
+
tags_match: Optional[str] = None,
|
|
126
|
+
tag_groups: Optional[object] = None,
|
|
103
127
|
timeout: int = 10,
|
|
104
128
|
) -> dict:
|
|
105
129
|
"""Recall memories from a bank.
|
|
@@ -115,6 +139,12 @@ class HindsightClient:
|
|
|
115
139
|
body["budget"] = budget
|
|
116
140
|
if types:
|
|
117
141
|
body["types"] = types
|
|
142
|
+
if tags:
|
|
143
|
+
body["tags"] = tags
|
|
144
|
+
if tags_match:
|
|
145
|
+
body["tags_match"] = tags_match
|
|
146
|
+
if tag_groups:
|
|
147
|
+
body["tag_groups"] = tag_groups
|
|
118
148
|
return self._request("POST", path, body, timeout=timeout)
|
|
119
149
|
|
|
120
150
|
def retain(
|