@gotcos/glasses-server 6.40.0 → 6.40.2
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/.env.example +1 -1
- package/CHANGELOG.md +73 -0
- package/package.json +1 -1
- package/server/lib/ollama-bridge.ts +241 -37
- package/server/lib/ollama-catalog.ts +51 -0
- package/server/lib/ollama-run-ledger.ts +7 -0
- package/server/lib/ollama-tools.ts +368 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/routes/query.ts +22 -2
package/.env.example
CHANGED
|
@@ -69,7 +69,7 @@ BIND_HOST=0.0.0.0
|
|
|
69
69
|
#
|
|
70
70
|
# Local Ollama (https://ollama.com) as a fourth G2 picker. Hidden unless
|
|
71
71
|
# GET http://127.0.0.1:11434/api/tags succeeds with at least one pulled model.
|
|
72
|
-
#
|
|
72
|
+
# Ollama runs read-only COS tools when the pulled tag advertises tools and COS_SCRIPTS_DIR is set; no vision, no Codex --oss.
|
|
73
73
|
# brew install ollama
|
|
74
74
|
# ollama serve
|
|
75
75
|
# ollama run qwen2.5-coder
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,76 @@
|
|
|
1
|
+
## 6.40.2
|
|
2
|
+
|
|
3
|
+
A finished answer could leave the stream open forever.
|
|
4
|
+
|
|
5
|
+
An Ollama turn with tools streamed its whole answer, persisted it, and then
|
|
6
|
+
never sent the terminal `done` event. On the glasses the turn counted past
|
|
7
|
+
1,100 seconds while the reply had actually completed in 166 and was already
|
|
8
|
+
saved in Messages. Double-tap to cancel did nothing, because there was no live
|
|
9
|
+
job to cancel.
|
|
10
|
+
|
|
11
|
+
The cause is one line in the query route, and it predates the tool loop. The
|
|
12
|
+
SSE callbacks are declared inside `const sid = await callModelStreaming(...)`,
|
|
13
|
+
so while that call is running `sid` is in its temporal dead zone. `onStart`
|
|
14
|
+
never noticed because its own `sid` PARAMETER shadows the outer binding.
|
|
15
|
+
`onDone` has no such parameter: it read the outer `sid`, threw
|
|
16
|
+
ReferenceError, and — critically — threw AFTER setting `done = true` and
|
|
17
|
+
BEFORE writing the terminal event, so the failure could not even fall through
|
|
18
|
+
to the error path. The socket stayed open with the work already committed.
|
|
19
|
+
|
|
20
|
+
Only Ollama hit it. The child-process bridges resolve outside that window, so
|
|
21
|
+
Claude and Codex kept working; the in-process Ollama loop awaits its own
|
|
22
|
+
finalize before returning, which lands the callback squarely inside the dead
|
|
23
|
+
zone. Both readers now use the session id captured from `onStart`.
|
|
24
|
+
|
|
25
|
+
The existing SSE contract test could never have caught this: it fires
|
|
26
|
+
callbacks on a macrotask after the router resolves, with a comment asserting
|
|
27
|
+
that bridges "never" call back synchronously. That assumption is now marked
|
|
28
|
+
as covering the child-process bridges only, and a new test pins the
|
|
29
|
+
in-process ordering. Reverting the fix makes all three of its cases hang,
|
|
30
|
+
which is the production symptom exactly.
|
|
31
|
+
|
|
32
|
+
## 6.40.1
|
|
33
|
+
|
|
34
|
+
The local model can read your meetings and memories.
|
|
35
|
+
|
|
36
|
+
Until now the Ollama path appended "You have no tools" to every prompt, so a
|
|
37
|
+
local model could only answer from what was already in the window. It now gets
|
|
38
|
+
a closed allowlist of three READ-ONLY COS tools -- `search_meetings`,
|
|
39
|
+
`search_memories`, `read_meeting` -- but only when two things are true: the
|
|
40
|
+
pulled tag advertises `tools` on `/api/show`, and `COS_SCRIPTS_DIR` is a real
|
|
41
|
+
directory. A standalone npm install with no operations tree is unchanged, and
|
|
42
|
+
so is any tag that cannot call tools.
|
|
43
|
+
|
|
44
|
+
Writes, Bash, MCP, web search, web fetch, photos, Continue, Fork and Live Cues
|
|
45
|
+
are all still off this path. There is no in-process search or fetch executor in
|
|
46
|
+
this package, so advertising `WebSearch` would name a capability that cannot
|
|
47
|
+
run; those names stay out of the tools array deliberately.
|
|
48
|
+
|
|
49
|
+
The loop is bounded at five `/api/chat` POSTs per turn. Posts one through four
|
|
50
|
+
may execute a tool batch; the fifth is a closing fetch, and tool calls returned
|
|
51
|
+
there are refused rather than executed, so there is never a sixth. Tool results
|
|
52
|
+
are capped by DROPPING whole hits, never by cutting mid-string, so the model
|
|
53
|
+
always receives parseable JSON. `read_meeting` serializes a picked subset and
|
|
54
|
+
never the unbounded transcript the ops helpers still attach.
|
|
55
|
+
|
|
56
|
+
Search results always carry `semanticReason`, defaulting to `"none"`. Empty
|
|
57
|
+
hits with a reason describe THAT call; without the field a model reads an empty
|
|
58
|
+
list as proof the archive is empty and tells you that you have no meetings. The
|
|
59
|
+
prompt also states that the cached context block is today's calendar and not the
|
|
60
|
+
meeting library, because "No more meetings today" was being read as an archive
|
|
61
|
+
claim.
|
|
62
|
+
|
|
63
|
+
The Ollama system prompt is now built here rather than borrowed from the Claude
|
|
64
|
+
path, which instructs the model to search the web and read photo files. Cached
|
|
65
|
+
context is always included instead of keyword-gated, so a question about today's
|
|
66
|
+
schedule no longer arrives with no calendar attached unless it happened to use
|
|
67
|
+
the word "meeting".
|
|
68
|
+
|
|
69
|
+
Cancelling a turn mid-tool reports it as cancelled rather than as a timeout, and
|
|
70
|
+
an abort landing between tool calls now ends the turn instead of quietly issuing
|
|
71
|
+
another POST. A cancelled `semantic_search.py` child still runs to its own 15s
|
|
72
|
+
timeout; killing children is deferred.
|
|
73
|
+
|
|
1
74
|
## 6.40.0
|
|
2
75
|
|
|
3
76
|
Local thinking now follows the effort you asked for.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.40.
|
|
3
|
+
"version": "6.40.2",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// Direct Ollama chat — POST /api/chat. No Codex --oss
|
|
1
|
+
// Direct Ollama chat — POST /api/chat. Optional read-only COS tools. No Codex --oss. Text only.
|
|
2
2
|
|
|
3
3
|
import type { CallOptions, StreamCallbacks } from './claude-bridge.js'
|
|
4
|
-
import { buildLightweightSystemPrompt } from './context-builder.js'
|
|
5
4
|
import {
|
|
6
5
|
addExchange,
|
|
7
6
|
formatHistoryForPrompt,
|
|
@@ -19,6 +18,7 @@ import {
|
|
|
19
18
|
getOllamaCatalog,
|
|
20
19
|
isOllamaProviderReady,
|
|
21
20
|
ollamaFetch,
|
|
21
|
+
ollamaModelSupportsTools,
|
|
22
22
|
} from './ollama-catalog.js'
|
|
23
23
|
import {
|
|
24
24
|
classifyOllamaError,
|
|
@@ -27,9 +27,27 @@ import {
|
|
|
27
27
|
} from './ollama-run-ledger.js'
|
|
28
28
|
import { notifyExchange, notifySessionStart } from './telegram-notify.js'
|
|
29
29
|
import { OLLAMA_MODEL, type EffortPreference } from '../../shared/model-preference.js'
|
|
30
|
+
import { getCachedContextInstant } from './context-builder.js'
|
|
31
|
+
import { getOwnerName } from './profile.js'
|
|
32
|
+
import {
|
|
33
|
+
buildOllamaSystemPrompt,
|
|
34
|
+
buildOllamaToolDefs,
|
|
35
|
+
executeOllamaTool,
|
|
36
|
+
ollamaCosPipelineConfigured,
|
|
37
|
+
ollamaToolStatusLabel,
|
|
38
|
+
parseToolArguments,
|
|
39
|
+
} from './ollama-tools.js'
|
|
30
40
|
|
|
31
41
|
const INACTIVITY_MS = 60_000
|
|
32
42
|
const WALL_MAX_MS = 180_000
|
|
43
|
+
/** A turn may EXTEND past the 180s wall while tools run, but never past this. */
|
|
44
|
+
const WALL_HARD_MAX_MS = 300_000
|
|
45
|
+
/** POSTs to /api/chat per turn. Counted as fetches, not as "rounds" in prose:
|
|
46
|
+
* POSTs 1-4 may execute a tool batch, POST 5 is the closing fetch. */
|
|
47
|
+
const MAX_CHAT_POSTS = 5
|
|
48
|
+
/** While a tool runs there is no NDJSON, so inactivity must be bumped on a
|
|
49
|
+
* timer or a slow search trips the 60s idle abort. */
|
|
50
|
+
const TOOL_HEARTBEAT_MS = 5_000
|
|
33
51
|
|
|
34
52
|
/**
|
|
35
53
|
* Thinking follows the REQUESTED EFFORT, not a blanket switch.
|
|
@@ -70,25 +88,72 @@ const HISTORY_LIMIT = 20
|
|
|
70
88
|
|
|
71
89
|
type OllamaChatMessage = { role: 'system' | 'user' | 'assistant'; content: string }
|
|
72
90
|
|
|
73
|
-
|
|
91
|
+
/** One tool call as the daemon streams it. `arguments` is an OBJECT on the
|
|
92
|
+
* live probe, though the wire format also permits a JSON string. */
|
|
93
|
+
export interface OllamaToolCall {
|
|
94
|
+
id?: string
|
|
95
|
+
function: { name: string; arguments: unknown }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Parse one NDJSON line.
|
|
100
|
+
*
|
|
101
|
+
* `toolCalls` is OMITTED, not set to undefined or [], when a line carries no
|
|
102
|
+
* calls. Existing tests assert `toEqual({ content: 'Hi', done: false })` on an
|
|
103
|
+
* exact object, and an always-present key fails them — which would be a real
|
|
104
|
+
* signal that every consumer now has to think about tool calls, so the shape
|
|
105
|
+
* stays honest instead.
|
|
106
|
+
*
|
|
107
|
+
* Calls can ride the `done: true` line as easily as a mid-stream one, so this
|
|
108
|
+
* reads them on every line and the READER accumulates across lines (the live
|
|
109
|
+
* C3 shape puts calls on line 1 and an empty done on line 2).
|
|
110
|
+
*/
|
|
111
|
+
export function parseOllamaChatDelta(line: string): {
|
|
112
|
+
content: string
|
|
113
|
+
done: boolean
|
|
114
|
+
error?: string
|
|
115
|
+
toolCalls?: OllamaToolCall[]
|
|
116
|
+
} {
|
|
74
117
|
const trimmed = line.trim()
|
|
75
118
|
if (!trimmed) return { content: '', done: false }
|
|
76
119
|
try {
|
|
77
120
|
const event = JSON.parse(trimmed) as {
|
|
78
121
|
error?: unknown
|
|
79
122
|
done?: unknown
|
|
80
|
-
message?: { content?: unknown }
|
|
123
|
+
message?: { content?: unknown; tool_calls?: unknown }
|
|
81
124
|
}
|
|
82
125
|
if (typeof event.error === 'string' && event.error.trim()) {
|
|
83
126
|
return { content: '', done: true, error: event.error.trim() }
|
|
84
127
|
}
|
|
85
128
|
const content = typeof event.message?.content === 'string' ? event.message.content : ''
|
|
86
|
-
|
|
129
|
+
const raw = event.message?.tool_calls
|
|
130
|
+
const calls: OllamaToolCall[] = Array.isArray(raw)
|
|
131
|
+
? raw.flatMap(entry => {
|
|
132
|
+
const row = entry as { id?: unknown; function?: { name?: unknown; arguments?: unknown } }
|
|
133
|
+
const name = typeof row?.function?.name === 'string' ? row.function.name.trim() : ''
|
|
134
|
+
if (!name) return []
|
|
135
|
+
return [{
|
|
136
|
+
...(typeof row.id === 'string' && row.id ? { id: row.id } : {}),
|
|
137
|
+
function: { name, arguments: row.function?.arguments },
|
|
138
|
+
}]
|
|
139
|
+
})
|
|
140
|
+
: []
|
|
141
|
+
return {
|
|
142
|
+
content,
|
|
143
|
+
done: event.done === true,
|
|
144
|
+
...(calls.length > 0 ? { toolCalls: calls } : {}),
|
|
145
|
+
}
|
|
87
146
|
} catch {
|
|
88
147
|
return { content: '', done: false }
|
|
89
148
|
}
|
|
90
149
|
}
|
|
91
150
|
|
|
151
|
+
/** In-loop only. Never written to history. */
|
|
152
|
+
export type OllamaLoopMessage =
|
|
153
|
+
| { role: 'system' | 'user'; content: string }
|
|
154
|
+
| { role: 'assistant'; content: string; tool_calls?: OllamaToolCall[] }
|
|
155
|
+
| { role: 'tool'; content: string; tool_name: string; tool_call_id?: string }
|
|
156
|
+
|
|
92
157
|
export function historyToOllamaMessages(
|
|
93
158
|
exchanges: Exchange[],
|
|
94
159
|
contextBreaks: number[],
|
|
@@ -111,6 +176,10 @@ function safeOllamaUserError(message: string): string {
|
|
|
111
176
|
if (code === 'ollama.unavailable') return 'Ollama is not running. Start ollama serve on this Mac.'
|
|
112
177
|
if (code === 'ollama.no_model') return 'Ollama has no pulled models. Run ollama pull, then retry.'
|
|
113
178
|
if (code === 'ollama.text_only') return 'Ollama is text-only here. Remove the photo and retry.'
|
|
179
|
+
if (code === 'ollama.tool_cap') {
|
|
180
|
+
return 'The local model hit its tool round cap without finishing an answer. Rephrase, or ask a narrower question.'
|
|
181
|
+
}
|
|
182
|
+
if (code === 'ollama.tool_abort') return 'That tool call was cancelled before it finished.'
|
|
114
183
|
if (code === 'ollama.timeout') return 'Ollama timed out. Retry or pick another model.'
|
|
115
184
|
return `Ollama failed (${code}). Retry or check that ollama serve is running.`
|
|
116
185
|
}
|
|
@@ -148,7 +217,22 @@ export async function callOllamaStreaming(
|
|
|
148
217
|
const contextBreaks = session?.contextBreaks ?? []
|
|
149
218
|
const historyPrompt = formatHistoryForPrompt(history, contextBreaks, reference)
|
|
150
219
|
const handoffPrompt = options?.handoffContext?.promptBlock ? `\n\n${options.handoffContext.promptBlock}` : ''
|
|
151
|
-
|
|
220
|
+
// Tools are advertised only when the pulled tag says it can call them AND
|
|
221
|
+
// the COS pipeline is really on disk. Either missing means no `tools` key at
|
|
222
|
+
// all and the original no-tools sentence, so a standalone npm install is
|
|
223
|
+
// unchanged by this release.
|
|
224
|
+
const modelSupportsTools = await ollamaModelSupportsTools(catalog.model)
|
|
225
|
+
const toolDefs = modelSupportsTools && ollamaCosPipelineConfigured() ? buildOllamaToolDefs() : []
|
|
226
|
+
const toolNames = toolDefs.map(def => def.function.name)
|
|
227
|
+
const systemPrompt = buildOllamaSystemPrompt({
|
|
228
|
+
ownerName: getOwnerName(),
|
|
229
|
+
// ALWAYS, never keyword-gated: the old path omitted the calendar unless the
|
|
230
|
+
// query happened to match schedule|meeting|..., so "what's left today"
|
|
231
|
+
// answered with no calendar at all. Cached read, so it cannot block.
|
|
232
|
+
cachedContext: getCachedContextInstant(),
|
|
233
|
+
historyPrompt: `${historyPrompt}${handoffPrompt}`,
|
|
234
|
+
toolNames,
|
|
235
|
+
})
|
|
152
236
|
|
|
153
237
|
const startTime = Date.now()
|
|
154
238
|
const run = startOllamaRun({
|
|
@@ -182,7 +266,11 @@ export async function callOllamaStreaming(
|
|
|
182
266
|
markSessionNotified(sid)
|
|
183
267
|
}
|
|
184
268
|
|
|
185
|
-
|
|
269
|
+
// Wider than OllamaChatMessage on purpose, and used ONLY for this turn's
|
|
270
|
+
// array. Tool turns are never persisted: Exchange.role stays user|assistant,
|
|
271
|
+
// and historyToOllamaMessages drops empty content — which would silently eat
|
|
272
|
+
// the C3 assistant turn whose content is '' and whose payload is the calls.
|
|
273
|
+
const messages: OllamaLoopMessage[] = [
|
|
186
274
|
{ role: 'system', content: systemPrompt },
|
|
187
275
|
...historyToOllamaMessages(history, contextBreaks),
|
|
188
276
|
{ role: 'user', content: query },
|
|
@@ -194,9 +282,13 @@ export async function callOllamaStreaming(
|
|
|
194
282
|
|
|
195
283
|
let inactivityTimer: ReturnType<typeof setTimeout> | undefined
|
|
196
284
|
let wallTimer: ReturnType<typeof setTimeout> | undefined
|
|
285
|
+
// Tracked here so clearTimers can kill it: a heartbeat still ticking after
|
|
286
|
+
// finalize would keep bumping a dead turn's inactivity timer.
|
|
287
|
+
let toolHeartbeat: ReturnType<typeof setInterval> | undefined
|
|
197
288
|
const clearTimers = () => {
|
|
198
289
|
if (inactivityTimer) clearTimeout(inactivityTimer)
|
|
199
290
|
if (wallTimer) clearTimeout(wallTimer)
|
|
291
|
+
if (toolHeartbeat) { clearInterval(toolHeartbeat); toolHeartbeat = undefined }
|
|
200
292
|
}
|
|
201
293
|
const bumpInactivity = () => {
|
|
202
294
|
if (inactivityTimer) clearTimeout(inactivityTimer)
|
|
@@ -235,32 +327,96 @@ export async function callOllamaStreaming(
|
|
|
235
327
|
bumpInactivity()
|
|
236
328
|
wallTimer = setTimeout(() => abort.abort(), WALL_MAX_MS)
|
|
237
329
|
|
|
238
|
-
|
|
330
|
+
/**
|
|
331
|
+
* Extend the wall while a tool runs, never past the hard max.
|
|
332
|
+
*
|
|
333
|
+
* Clears and RE-ARMS: leaving the original 180s timer armed would abort the
|
|
334
|
+
* turn on schedule no matter how much time was granted. Remaining time is
|
|
335
|
+
* min(hardMax - elapsed, current + 60s), so extension cannot outrun the cap.
|
|
336
|
+
*/
|
|
337
|
+
const bumpWall = () => {
|
|
338
|
+
const elapsed = Date.now() - startTime
|
|
339
|
+
const remainingToHardMax = WALL_HARD_MAX_MS - elapsed
|
|
340
|
+
if (remainingToHardMax <= 0) return
|
|
341
|
+
const current = Math.max(0, WALL_MAX_MS - elapsed)
|
|
342
|
+
const next = Math.min(remainingToHardMax, current + 60_000)
|
|
343
|
+
if (wallTimer) clearTimeout(wallTimer)
|
|
344
|
+
wallTimer = setTimeout(() => abort.abort(), next)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
let postCount = 0
|
|
348
|
+
let toolsRetried = false
|
|
349
|
+
|
|
350
|
+
/** One POST + its stream. Returns what the round produced. */
|
|
351
|
+
const runChatPost = async (sendTools: boolean): Promise<
|
|
352
|
+
| { kind: 'text' }
|
|
353
|
+
| { kind: 'tools'; calls: OllamaToolCall[] }
|
|
354
|
+
| { kind: 'empty' }
|
|
355
|
+
| { kind: 'handled' }
|
|
356
|
+
> => {
|
|
357
|
+
postCount += 1
|
|
358
|
+
const body: Record<string, unknown> = {
|
|
359
|
+
model: catalog.model,
|
|
360
|
+
messages,
|
|
361
|
+
stream: true,
|
|
362
|
+
think: resolveOllamaThink(process.env.COS_OLLAMA_THINK, options?.effort),
|
|
363
|
+
}
|
|
364
|
+
if (sendTools && toolDefs.length > 0) body.tools = toolDefs
|
|
365
|
+
|
|
366
|
+
// First-byte grace on EVERY post: a silent think (effort xhigh/max) emits
|
|
367
|
+
// nothing for a long time, and without this the 60s idle abort fires
|
|
368
|
+
// before the first token rather than during a stall.
|
|
369
|
+
bumpInactivity()
|
|
239
370
|
const response = await ollamaFetch(`${catalog.origin}/api/chat`, {
|
|
240
371
|
method: 'POST',
|
|
241
372
|
headers: { 'Content-Type': 'application/json' },
|
|
242
|
-
body: JSON.stringify(
|
|
243
|
-
model: catalog.model,
|
|
244
|
-
messages,
|
|
245
|
-
stream: true,
|
|
246
|
-
think: resolveOllamaThink(process.env.COS_OLLAMA_THINK, options?.effort),
|
|
247
|
-
}),
|
|
373
|
+
body: JSON.stringify(body),
|
|
248
374
|
signal: abort.signal,
|
|
249
375
|
})
|
|
250
376
|
if (!response.ok) {
|
|
251
377
|
const detail = (await response.text().catch(() => '')).trim().slice(0, 240)
|
|
378
|
+
// A daemon that rejects the tools key gets ONE retry without it, and only
|
|
379
|
+
// on the first POST of the turn. A later-round 400 is a real failure, and
|
|
380
|
+
// the retry deliberately does not consume a cap slot.
|
|
381
|
+
if (
|
|
382
|
+
response.status === 400 && sendTools && body.tools && !toolsRetried && postCount === 1
|
|
383
|
+
&& /tool/i.test(detail)
|
|
384
|
+
) {
|
|
385
|
+
toolsRetried = true
|
|
386
|
+
postCount -= 1
|
|
387
|
+
console.warn(`[ollama-bridge] tools rejected by ${catalog.model}: ${detail.slice(0, 120)}`)
|
|
388
|
+
return runChatPost(false)
|
|
389
|
+
}
|
|
252
390
|
await finalizeError(`ollama-bridge: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`)
|
|
253
|
-
return
|
|
391
|
+
return { kind: 'handled' }
|
|
254
392
|
}
|
|
255
393
|
if (!response.body) {
|
|
256
394
|
await finalizeError('ollama-bridge: empty stream')
|
|
257
|
-
return
|
|
395
|
+
return { kind: 'handled' }
|
|
258
396
|
}
|
|
259
397
|
|
|
260
398
|
const reader = response.body.getReader()
|
|
261
399
|
const decoder = new TextDecoder()
|
|
262
400
|
let buffer = ''
|
|
263
|
-
|
|
401
|
+
let sawText = false
|
|
402
|
+
// Accumulated across NDJSON LINES: the live shape puts calls on line 1 and
|
|
403
|
+
// an empty done:true on line 2, so judging the done line alone sees nothing.
|
|
404
|
+
const calls: OllamaToolCall[] = []
|
|
405
|
+
|
|
406
|
+
const absorb = (line: string): 'error' | 'done' | 'ok' => {
|
|
407
|
+
const delta = parseOllamaChatDelta(line)
|
|
408
|
+
if (delta.error) return 'error'
|
|
409
|
+
if (delta.toolCalls) calls.push(...delta.toolCalls)
|
|
410
|
+
if (delta.content) {
|
|
411
|
+
sawText = true
|
|
412
|
+
fullText += delta.content
|
|
413
|
+
callbacks.onChunk(delta.content)
|
|
414
|
+
}
|
|
415
|
+
return delta.done ? 'done' : 'ok'
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let streamDone = false
|
|
419
|
+
while (!streamDone) {
|
|
264
420
|
const { done, value } = await reader.read()
|
|
265
421
|
if (done) break
|
|
266
422
|
bumpInactivity()
|
|
@@ -268,34 +424,82 @@ export async function callOllamaStreaming(
|
|
|
268
424
|
const lines = buffer.split('\n')
|
|
269
425
|
buffer = lines.pop() ?? ''
|
|
270
426
|
for (const line of lines) {
|
|
271
|
-
const
|
|
272
|
-
if (
|
|
273
|
-
await finalizeError(`ollama-bridge: ${
|
|
274
|
-
return
|
|
427
|
+
const outcome = absorb(line)
|
|
428
|
+
if (outcome === 'error') {
|
|
429
|
+
await finalizeError(`ollama-bridge: ${parseOllamaChatDelta(line).error}`)
|
|
430
|
+
return { kind: 'handled' }
|
|
275
431
|
}
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
|
|
432
|
+
if (outcome === 'done') { streamDone = true; break }
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (!streamDone && buffer.trim()) {
|
|
436
|
+
const outcome = absorb(buffer)
|
|
437
|
+
if (outcome === 'error') {
|
|
438
|
+
await finalizeError(`ollama-bridge: ${parseOllamaChatDelta(buffer).error}`)
|
|
439
|
+
return { kind: 'handled' }
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (calls.length > 0) return { kind: 'tools', calls }
|
|
444
|
+
return sawText ? { kind: 'text' } : { kind: 'empty' }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
for (;;) {
|
|
449
|
+
const round = await runChatPost(toolDefs.length > 0)
|
|
450
|
+
if (round.kind === 'handled') return sid
|
|
451
|
+
if (round.kind === 'text') { await finalizeDone(); return sid }
|
|
452
|
+
|
|
453
|
+
if (round.kind === 'empty') {
|
|
454
|
+
await finalizeError('ollama-bridge: Ollama completed without a response.')
|
|
455
|
+
return sid
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// POST 5 is the closing fetch. Calls returned there are NOT executed and
|
|
459
|
+
// there is never a sixth POST, even if text came with them.
|
|
460
|
+
if (postCount >= MAX_CHAT_POSTS) {
|
|
461
|
+
await finalizeError('ollama-bridge: tool round cap reached without a final answer.')
|
|
462
|
+
return sid
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// The assistant turn carries the calls with empty content. It must be
|
|
466
|
+
// appended as-is; dropping empty content here loses the call payload.
|
|
467
|
+
messages.push({ role: 'assistant', content: '', tool_calls: round.calls })
|
|
468
|
+
|
|
469
|
+
// Sequential. A batch runs to completion before the next POST, and a
|
|
470
|
+
// late promise from an aborted call is ignored rather than appended.
|
|
471
|
+
for (const call of round.calls) {
|
|
472
|
+
if (finalized || abort.signal.aborted) break
|
|
473
|
+
const name = call.function.name
|
|
474
|
+
callbacks.onToolStatus?.(name)
|
|
475
|
+
toolHeartbeat = setInterval(bumpInactivity, TOOL_HEARTBEAT_MS)
|
|
476
|
+
let result: string
|
|
477
|
+
try {
|
|
478
|
+
result = await executeOllamaTool(name, parseToolArguments(call.function.arguments), abort.signal)
|
|
479
|
+
} finally {
|
|
480
|
+
if (toolHeartbeat) { clearInterval(toolHeartbeat); toolHeartbeat = undefined }
|
|
279
481
|
}
|
|
280
|
-
if (
|
|
281
|
-
await
|
|
482
|
+
if (result === 'aborted' || abort.signal.aborted) {
|
|
483
|
+
await finalizeError('ollama-bridge: tool call aborted.')
|
|
282
484
|
return sid
|
|
283
485
|
}
|
|
486
|
+
messages.push({
|
|
487
|
+
role: 'tool',
|
|
488
|
+
content: result,
|
|
489
|
+
tool_name: name,
|
|
490
|
+
...(call.id ? { tool_call_id: call.id } : {}),
|
|
491
|
+
})
|
|
284
492
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
493
|
+
if (finalized) return sid
|
|
494
|
+
// An abort that lands BETWEEN calls must end the turn. Without this the
|
|
495
|
+
// loop simply fell out of the batch and issued another POST, so a
|
|
496
|
+
// cancelled turn kept talking to the daemon until it hit the cap.
|
|
497
|
+
if (abort.signal.aborted) {
|
|
498
|
+
await finalizeError('ollama-bridge: tool call aborted.')
|
|
290
499
|
return sid
|
|
291
500
|
}
|
|
292
|
-
|
|
293
|
-
fullText += delta.content
|
|
294
|
-
callbacks.onChunk(delta.content)
|
|
295
|
-
}
|
|
501
|
+
bumpWall()
|
|
296
502
|
}
|
|
297
|
-
await finalizeDone()
|
|
298
|
-
return sid
|
|
299
503
|
} catch (error: any) {
|
|
300
504
|
const aborted = abort.signal.aborted || options?.abortSignal?.aborted
|
|
301
505
|
await finalizeError(
|
|
@@ -105,6 +105,57 @@ export function isOllamaProviderReady(): boolean {
|
|
|
105
105
|
export function _resetOllamaCatalogCache(): void {
|
|
106
106
|
catalogSnapshot = unavailableCatalog(DEFAULT_OLLAMA_ORIGIN, 'unprobed')
|
|
107
107
|
refreshPromise = null
|
|
108
|
+
// The show map MUST clear with the rest. A tools-on entry surviving a reset
|
|
109
|
+
// leaks into the next test's llama3.2 stub and it passes for the wrong reason.
|
|
110
|
+
showCache.clear()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Per-model `/api/show` capability cache. Deliberately separate from the
|
|
114
|
+
* catalog snapshot: capabilities are per TAG, and folding them into the boot
|
|
115
|
+
* `/api/tags` probe would make the health payload carry them too. */
|
|
116
|
+
const showCache = new Map<string, { tools: boolean; at: number }>()
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Does this pulled tag advertise tool support?
|
|
120
|
+
*
|
|
121
|
+
* True only for a parsed `capabilities` array containing 'tools'. A timeout or
|
|
122
|
+
* a throw returns false for THIS call but writes NO cache entry — caching a
|
|
123
|
+
* failed probe as `tools=false` would be inferring absence from silence, and
|
|
124
|
+
* the tag would stay toolless for the full TTL after one slow probe.
|
|
125
|
+
*/
|
|
126
|
+
export async function ollamaModelSupportsTools(model: string): Promise<boolean> {
|
|
127
|
+
const tag = (model || '').trim()
|
|
128
|
+
if (!tag) return false
|
|
129
|
+
const cached = showCache.get(tag)
|
|
130
|
+
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.tools
|
|
131
|
+
|
|
132
|
+
const origin = catalogSnapshot.origin || DEFAULT_OLLAMA_ORIGIN
|
|
133
|
+
try {
|
|
134
|
+
const controller = new AbortController()
|
|
135
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
|
136
|
+
let body: unknown
|
|
137
|
+
try {
|
|
138
|
+
// ollamaFetch, never raw fetch: raw fetch bypasses the test mock and can
|
|
139
|
+
// reach a live daemon from a unit test.
|
|
140
|
+
const response = await ollamaFetch(`${origin}/api/show`, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'Content-Type': 'application/json' },
|
|
143
|
+
body: JSON.stringify({ name: tag }),
|
|
144
|
+
signal: controller.signal,
|
|
145
|
+
})
|
|
146
|
+
if (!response.ok) return false
|
|
147
|
+
body = await response.json()
|
|
148
|
+
} finally {
|
|
149
|
+
clearTimeout(timer)
|
|
150
|
+
}
|
|
151
|
+
const capabilities = (body as { capabilities?: unknown } | null)?.capabilities
|
|
152
|
+
if (!Array.isArray(capabilities)) return false
|
|
153
|
+
const tools = capabilities.includes('tools')
|
|
154
|
+
showCache.set(tag, { tools, at: Date.now() })
|
|
155
|
+
return tools
|
|
156
|
+
} catch {
|
|
157
|
+
return false
|
|
158
|
+
}
|
|
108
159
|
}
|
|
109
160
|
|
|
110
161
|
async function probeOllamaCatalog(): Promise<OllamaCatalog> {
|
|
@@ -92,6 +92,13 @@ export function classifyOllamaError(message: string): string {
|
|
|
92
92
|
if (/unreachable|econnrefused|fetch failed|enotfound/.test(text)) return 'ollama.unavailable'
|
|
93
93
|
if (/no models|not ready/.test(text)) return 'ollama.no_model'
|
|
94
94
|
if (/text-only|photo|image/.test(text)) return 'ollama.text_only'
|
|
95
|
+
// BOTH tool codes must precede the /aborted/ branch below. A cancelled tool
|
|
96
|
+
// reads "aborted" and would otherwise be reported to the user as a timeout,
|
|
97
|
+
// and the round-cap message would fall all the way through to the generic
|
|
98
|
+
// 'ollama.error' and surface as "Ollama failed (ollama.error)" — which tells
|
|
99
|
+
// the user nothing about what actually stopped the turn.
|
|
100
|
+
if (/tool round cap/.test(text)) return 'ollama.tool_cap'
|
|
101
|
+
if (/tool aborted|tool call aborted/.test(text)) return 'ollama.tool_abort'
|
|
95
102
|
if (/timeout|timed out|aborted/.test(text)) return 'ollama.timeout'
|
|
96
103
|
return 'ollama.error'
|
|
97
104
|
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// Read-only COS tools for the Ollama path.
|
|
2
|
+
//
|
|
3
|
+
// A CLOSED ALLOWLIST of three. These are not Claude CLI tool names and this
|
|
4
|
+
// file must never grow toward parity: glasses-server has no in-process search
|
|
5
|
+
// or fetch executor (deps are cors/express/tsx), so putting `WebSearch` or
|
|
6
|
+
// `WebFetch` in the tools array would advertise a capability that cannot run.
|
|
7
|
+
// Writes, Bash, MCP and photos stay off this path entirely.
|
|
8
|
+
//
|
|
9
|
+
// Nothing here is named `Read`. `TOOL_STATUS_MESSAGES.Read` already means
|
|
10
|
+
// 'Analyzing photo...' in both HUD maps, so a tool called Read would put the
|
|
11
|
+
// wrong sentence on the lens.
|
|
12
|
+
|
|
13
|
+
import { statSync } from 'node:fs'
|
|
14
|
+
import { resolve } from 'node:path'
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
cosOperationsMeetingsConfigured,
|
|
18
|
+
getCosOperationsMeetingDetail,
|
|
19
|
+
getDirectLibraryMeetingDetail,
|
|
20
|
+
} from './cos-operations-meetings.js'
|
|
21
|
+
import { searchMemories } from './context-library-search.js'
|
|
22
|
+
import { searchMeetingLibrary } from './meeting-library-search.js'
|
|
23
|
+
import { getMeetingStore, MeetingStoreError } from './meeting-store.js'
|
|
24
|
+
import { TOOL_HONESTY_CLAUSE, UNTRUSTED_CONTENT_CLAUSE } from './claude-tool-access.js'
|
|
25
|
+
|
|
26
|
+
export const OLLAMA_COS_TOOL_NAMES = ['search_meetings', 'search_memories', 'read_meeting'] as const
|
|
27
|
+
export type OllamaCosToolName = (typeof OLLAMA_COS_TOOL_NAMES)[number]
|
|
28
|
+
|
|
29
|
+
/** One tool result may not exceed this. Enforced by DROPPING hits, never by
|
|
30
|
+
* cutting mid-string: a truncated JSON body is worse than fewer results. */
|
|
31
|
+
const TOOL_RESULT_MAX_CHARS = 24_000
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Is the COS Python pipeline actually present?
|
|
35
|
+
*
|
|
36
|
+
* DUPLICATED from `cosPipelineConfigured` in claude-tool-access.ts on purpose:
|
|
37
|
+
* that one is module-private (not exported), and importing it is impossible
|
|
38
|
+
* without widening a Claude-owned surface. Same live `statSync` shape, so a
|
|
39
|
+
* standalone npm install with no operations tree advertises no COS tools and
|
|
40
|
+
* gets a prompt that never mentions them.
|
|
41
|
+
*
|
|
42
|
+
* Read live, never cached at import: `python-bridge.ts` binds COS_SCRIPTS_DIR
|
|
43
|
+
* at module load, and a test that sets the env after import would be lying to
|
|
44
|
+
* itself.
|
|
45
|
+
*/
|
|
46
|
+
export function ollamaCosPipelineConfigured(): boolean {
|
|
47
|
+
const dir = process.env.COS_SCRIPTS_DIR
|
|
48
|
+
if (!dir || !dir.trim()) return false
|
|
49
|
+
try {
|
|
50
|
+
return statSync(resolve(dir.trim())).isDirectory()
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Ollama's function-tool schema, matching the live C2/C4 probe. */
|
|
57
|
+
export interface OllamaToolDef {
|
|
58
|
+
type: 'function'
|
|
59
|
+
function: {
|
|
60
|
+
name: string
|
|
61
|
+
description: string
|
|
62
|
+
parameters: { type: 'object'; properties: Record<string, unknown>; required: string[] }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function buildOllamaToolDefs(): OllamaToolDef[] {
|
|
67
|
+
if (!ollamaCosPipelineConfigured()) return []
|
|
68
|
+
return [
|
|
69
|
+
{
|
|
70
|
+
type: 'function',
|
|
71
|
+
function: {
|
|
72
|
+
name: 'search_meetings',
|
|
73
|
+
description:
|
|
74
|
+
'Search the meeting library by meaning and keyword. Returns hits with title, date, domain, month and filename. Use the returned domain/month/filename with read_meeting to read one.',
|
|
75
|
+
parameters: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: {
|
|
78
|
+
query: { type: 'string', description: 'What to look for.' },
|
|
79
|
+
domain: { type: 'string', description: "Optional domain filter, e.g. quilt. Omit for all." },
|
|
80
|
+
},
|
|
81
|
+
required: ['query'],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
type: 'function',
|
|
87
|
+
function: {
|
|
88
|
+
name: 'search_memories',
|
|
89
|
+
description:
|
|
90
|
+
'Search stored COS memories (past session summaries, decisions and corrections) by meaning and keyword.',
|
|
91
|
+
parameters: {
|
|
92
|
+
type: 'object',
|
|
93
|
+
properties: { query: { type: 'string', description: 'What to look for.' } },
|
|
94
|
+
required: ['query'],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
type: 'function',
|
|
100
|
+
function: {
|
|
101
|
+
name: 'read_meeting',
|
|
102
|
+
description:
|
|
103
|
+
'Read one meeting by its exact domain, month and filename, as returned by search_meetings.',
|
|
104
|
+
parameters: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
domain: { type: 'string', description: "Domain, or 'library'." },
|
|
108
|
+
month: { type: 'string', description: 'YYYY-MM.' },
|
|
109
|
+
filename: { type: 'string', description: 'Exact filename from search_meetings.' },
|
|
110
|
+
},
|
|
111
|
+
required: ['domain', 'month', 'filename'],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** HUD label. Never `Analyzing photo...`, never a bare `Read`. */
|
|
119
|
+
export function ollamaToolStatusLabel(name: string): string {
|
|
120
|
+
switch (name) {
|
|
121
|
+
case 'search_meetings': return 'Searching meetings...'
|
|
122
|
+
case 'search_memories': return 'Searching memory...'
|
|
123
|
+
case 'read_meeting': return 'Reading meeting...'
|
|
124
|
+
default: return 'Working...'
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function asString(value: unknown): string {
|
|
129
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `function.arguments` arrives as an OBJECT from the live daemon (C2) but the
|
|
134
|
+
* wire format also permits a JSON string. Accept both; a malformed string is a
|
|
135
|
+
* tool result, never a throw.
|
|
136
|
+
*/
|
|
137
|
+
export function parseToolArguments(raw: unknown): Record<string, unknown> {
|
|
138
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw as Record<string, unknown>
|
|
139
|
+
if (typeof raw === 'string') {
|
|
140
|
+
try {
|
|
141
|
+
const parsed = JSON.parse(raw)
|
|
142
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
143
|
+
return parsed as Record<string, unknown>
|
|
144
|
+
}
|
|
145
|
+
} catch { /* falls through to empty */ }
|
|
146
|
+
}
|
|
147
|
+
return {}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Shrink by DROPPING whole hits until the JSON fits, so the string a model
|
|
152
|
+
* receives is always parseable. Mid-string truncation would hand the model
|
|
153
|
+
* broken JSON and invite it to hallucinate the rest.
|
|
154
|
+
*/
|
|
155
|
+
function capHits<T>(payload: Record<string, unknown>, hits: T[]): string {
|
|
156
|
+
let kept = hits.slice()
|
|
157
|
+
for (;;) {
|
|
158
|
+
const body = JSON.stringify({ ...payload, hits: kept, truncated: kept.length < hits.length })
|
|
159
|
+
if (body.length <= TOOL_RESULT_MAX_CHARS || kept.length === 0) return body
|
|
160
|
+
kept = kept.slice(0, Math.max(0, Math.floor(kept.length / 2)))
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A search result ALWAYS carries its counts and its semantic reason, even when
|
|
166
|
+
* it found nothing.
|
|
167
|
+
*
|
|
168
|
+
* Empty hits plus a reason describe THIS call. Without the reason a model reads
|
|
169
|
+
* an empty list as proof the archive is empty and tells Miles he has no
|
|
170
|
+
* meetings. `semanticReason` is `"none"` rather than absent so the field can
|
|
171
|
+
* never be silently missing.
|
|
172
|
+
*/
|
|
173
|
+
function serializeSearch(
|
|
174
|
+
result: { hits: unknown[]; keywordCount: number; semanticCount: number; semanticAvailable: boolean; semanticReason?: string },
|
|
175
|
+
extra: Record<string, unknown> = {},
|
|
176
|
+
): string {
|
|
177
|
+
return capHits(
|
|
178
|
+
{
|
|
179
|
+
...extra,
|
|
180
|
+
keywordCount: result.keywordCount,
|
|
181
|
+
semanticCount: result.semanticCount,
|
|
182
|
+
semanticAvailable: result.semanticAvailable,
|
|
183
|
+
semanticReason: result.semanticReason ?? 'none',
|
|
184
|
+
},
|
|
185
|
+
result.hits,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Serialize a meeting as a PICKED SUBSET.
|
|
191
|
+
*
|
|
192
|
+
* Never `JSON.stringify(detail)`: the ops helpers still attach an unbounded
|
|
193
|
+
* `transcript`, and MEETING_SOURCE_MAX_BYTES is 100_000 — an order of magnitude
|
|
194
|
+
* past the tool cap. Only these fields cross into the model's context.
|
|
195
|
+
*/
|
|
196
|
+
function serializeMeeting(detail: Record<string, unknown>, ref: { domain: string; month: string; filename: string }): string {
|
|
197
|
+
const source = asString(detail.sourceContent)
|
|
198
|
+
const capped = source.length > TOOL_RESULT_MAX_CHARS ? source.slice(0, TOOL_RESULT_MAX_CHARS) : source
|
|
199
|
+
const picked: Record<string, unknown> = {
|
|
200
|
+
title: asString(detail.title),
|
|
201
|
+
date: asString(detail.date),
|
|
202
|
+
domain: ref.domain,
|
|
203
|
+
month: ref.month,
|
|
204
|
+
filename: ref.filename,
|
|
205
|
+
sourceContent: capped,
|
|
206
|
+
sourceTruncated: detail.sourceTruncated === true || capped.length < source.length,
|
|
207
|
+
}
|
|
208
|
+
const summary = asString(detail.summary)
|
|
209
|
+
if (summary && summary.length < 4_000) picked.summary = summary
|
|
210
|
+
if (Array.isArray(detail.topics) && detail.topics.length <= 40) picked.topics = detail.topics
|
|
211
|
+
return JSON.stringify(picked)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Copy of the routing in routes/meetings.ts ~238-256.
|
|
216
|
+
*
|
|
217
|
+
* The route reaches its store through `createMeetingsRouter(store)`, a factory
|
|
218
|
+
* parameter — there is no importable `store` binding, so this calls
|
|
219
|
+
* `getMeetingStore()` directly. A null from either helper is NOT terminal: an
|
|
220
|
+
* ops miss still falls through to the standalone store, which is what serves
|
|
221
|
+
* G2-local recordings sharing the same shape.
|
|
222
|
+
*/
|
|
223
|
+
function readMeetingDetail(domain: string, month: string, filename: string): string {
|
|
224
|
+
if (domain === 'library') {
|
|
225
|
+
const detail = getDirectLibraryMeetingDetail(month, filename)
|
|
226
|
+
if (detail) return serializeMeeting(detail as unknown as Record<string, unknown>, { domain, month, filename })
|
|
227
|
+
}
|
|
228
|
+
if (cosOperationsMeetingsConfigured()) {
|
|
229
|
+
const detail = getCosOperationsMeetingDetail(domain, month, filename)
|
|
230
|
+
if (detail) return serializeMeeting(detail as unknown as Record<string, unknown>, { domain, month, filename })
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const detail = getMeetingStore().detail(domain, month, filename)
|
|
234
|
+
if (!detail) return JSON.stringify({ error: 'not found for this path', domain, month, filename })
|
|
235
|
+
return serializeMeeting(detail as unknown as Record<string, unknown>, { domain, month, filename })
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error instanceof MeetingStoreError) {
|
|
238
|
+
return JSON.stringify({ error: 'not found for this path', domain, month, filename })
|
|
239
|
+
}
|
|
240
|
+
throw error
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Run one tool call.
|
|
246
|
+
*
|
|
247
|
+
* Returns a STRING for every outcome the model could plausibly cause — unknown
|
|
248
|
+
* name, missing argument, nothing found — because a thrown error would end the
|
|
249
|
+
* whole turn where the model could simply have tried again. Only an abort
|
|
250
|
+
* escapes as the sentinel `aborted`.
|
|
251
|
+
*
|
|
252
|
+
* `AbortSignal` is not thenable, so cancellation is a listener race rather than
|
|
253
|
+
* an await. The signal is optional purely so unit tests can call this directly.
|
|
254
|
+
*/
|
|
255
|
+
export async function executeOllamaTool(
|
|
256
|
+
name: string,
|
|
257
|
+
args: Record<string, unknown>,
|
|
258
|
+
abortSignal?: AbortSignal,
|
|
259
|
+
): Promise<string> {
|
|
260
|
+
if (abortSignal?.aborted) return 'aborted'
|
|
261
|
+
const work = runTool(name, args)
|
|
262
|
+
if (!abortSignal) return work
|
|
263
|
+
return Promise.race([
|
|
264
|
+
work,
|
|
265
|
+
new Promise<string>(resolveRace => {
|
|
266
|
+
abortSignal.addEventListener('abort', () => resolveRace('aborted'), { once: true })
|
|
267
|
+
}),
|
|
268
|
+
])
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function runTool(name: string, args: Record<string, unknown>): Promise<string> {
|
|
272
|
+
try {
|
|
273
|
+
if (name === 'search_meetings') {
|
|
274
|
+
const query = asString(args.query)
|
|
275
|
+
if (!query) return JSON.stringify({ error: 'query is required' })
|
|
276
|
+
const domain = asString(args.domain)
|
|
277
|
+
const result = await searchMeetingLibrary({ query, ...(domain ? { domain } : {}) })
|
|
278
|
+
return serializeSearch(result, { query, domain: domain || 'all' })
|
|
279
|
+
}
|
|
280
|
+
if (name === 'search_memories') {
|
|
281
|
+
const query = asString(args.query)
|
|
282
|
+
if (!query) return JSON.stringify({ error: 'query is required' })
|
|
283
|
+
const result = await searchMemories({ query })
|
|
284
|
+
return serializeSearch(result, { query })
|
|
285
|
+
}
|
|
286
|
+
if (name === 'read_meeting') {
|
|
287
|
+
const domain = asString(args.domain)
|
|
288
|
+
const month = asString(args.month)
|
|
289
|
+
const filename = asString(args.filename)
|
|
290
|
+
if (!domain || !month || !filename) {
|
|
291
|
+
return JSON.stringify({ error: 'domain, month and filename are all required' })
|
|
292
|
+
}
|
|
293
|
+
return readMeetingDetail(domain, month, filename)
|
|
294
|
+
}
|
|
295
|
+
return `unknown tool ${name}`
|
|
296
|
+
} catch (error) {
|
|
297
|
+
// Call-local wording. Helper keys like `qdrant_unreachable` describe one
|
|
298
|
+
// probe, and repeating them as prose invites the model to announce a
|
|
299
|
+
// service outage it cannot actually observe.
|
|
300
|
+
const detail = error instanceof Error ? error.message.slice(0, 200) : 'failed'
|
|
301
|
+
return JSON.stringify({ error: 'tool call failed', detail })
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* What the model is told it can do. Deliberately NOT `readOnlyCapabilityPrompt`
|
|
307
|
+
* (it asserts MCP reachability) and NOT `claudeToolCapabilityPrompt`.
|
|
308
|
+
*
|
|
309
|
+
* The escalation line must never name Ollama: Codex once offered to re-run a
|
|
310
|
+
* failed task on itself, which is a loop dressed as a suggestion.
|
|
311
|
+
*/
|
|
312
|
+
export function ollamaToolCapabilityPrompt(toolNames: readonly string[]): string {
|
|
313
|
+
return [
|
|
314
|
+
'This request runs on the READ-ONLY Ollama path.',
|
|
315
|
+
`Available tools: ${toolNames.join(', ')}. Writes, Bash, MCP, web search, web fetch, and photos are not available here.`,
|
|
316
|
+
'An absent name means it is not on this path — do not ToolSearch and do not claim a connector is down.',
|
|
317
|
+
'Empty search hits plus a reason report the result of THIS call; they are not proof that no meetings or memories exist.',
|
|
318
|
+
'A read_meeting miss means not found for that path, not that the meeting does not exist.',
|
|
319
|
+
'If the user needs a write or a web search, say so and offer Opus or Codex/GPT (workspace-write), never this local slot.',
|
|
320
|
+
TOOL_HONESTY_CLAUSE,
|
|
321
|
+
UNTRUSTED_CONTENT_CLAUSE,
|
|
322
|
+
].join('\n')
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The sentence used when no tools are advertised. Preserved verbatim. */
|
|
326
|
+
export const OLLAMA_NO_TOOLS_SENTENCE =
|
|
327
|
+
'You have no tools. Answer from the prompt and conversation only. Plain text.'
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The Ollama system prompt.
|
|
331
|
+
*
|
|
332
|
+
* Built here rather than through `buildSystemPrompt`, which instructs the model
|
|
333
|
+
* to search the web and read photo files — neither of which exists on this
|
|
334
|
+
* path. The glasses display constraints are copied from
|
|
335
|
+
* `buildLightweightSystemPrompt` WITHOUT its Siri framing ("NOT a work
|
|
336
|
+
* productivity tool"), which is wrong the moment COS tools are advertised.
|
|
337
|
+
*
|
|
338
|
+
* Cached context is included ALWAYS, not keyword-gated: the old gate meant a
|
|
339
|
+
* question about today's schedule got no calendar unless it happened to use the
|
|
340
|
+
* word "meeting". The cached read is instant, so this cannot block a glasses
|
|
341
|
+
* turn the way an awaited build would.
|
|
342
|
+
*/
|
|
343
|
+
export function buildOllamaSystemPrompt(input: {
|
|
344
|
+
ownerName: string
|
|
345
|
+
cachedContext: string
|
|
346
|
+
historyPrompt: string
|
|
347
|
+
toolNames: readonly string[]
|
|
348
|
+
}): string {
|
|
349
|
+
const owner = input.ownerName.trim() || 'the wearer'
|
|
350
|
+
const parts = [
|
|
351
|
+
`You are COS, ${owner}'s chief of staff, answering on smart glasses.`,
|
|
352
|
+
'Answer in plain text. No markdown, no bullet characters, no emoji.',
|
|
353
|
+
'Aim for 300 to 600 characters. Up to about 2000 when the answer genuinely needs it.',
|
|
354
|
+
]
|
|
355
|
+
if (input.cachedContext.trim()) {
|
|
356
|
+
parts.push(input.cachedContext.trim())
|
|
357
|
+
parts.push(
|
|
358
|
+
"The context block above is TODAY'S calendar and tasks. It is not the meeting archive. \"No more meetings today\" describes today's schedule only.",
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
if (input.historyPrompt.trim()) parts.push(input.historyPrompt.trim())
|
|
362
|
+
parts.push(
|
|
363
|
+
input.toolNames.length > 0
|
|
364
|
+
? ollamaToolCapabilityPrompt(input.toolNames)
|
|
365
|
+
: OLLAMA_NO_TOOLS_SENTENCE,
|
|
366
|
+
)
|
|
367
|
+
return parts.join('\n\n')
|
|
368
|
+
}
|
|
@@ -38,6 +38,9 @@ const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
|
38
38
|
WebSearch: 'Searching web...',
|
|
39
39
|
WebFetch: 'Reading page...',
|
|
40
40
|
Read: 'Analyzing photo...',
|
|
41
|
+
search_meetings: 'Searching meetings...',
|
|
42
|
+
search_memories: 'Searching memory...',
|
|
43
|
+
read_meeting: 'Reading meeting...',
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
export class QueryJobAdmissionPreparationError extends Error {
|
package/server/routes/query.ts
CHANGED
|
@@ -24,6 +24,9 @@ const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
|
24
24
|
WebSearch: 'Searching web...',
|
|
25
25
|
WebFetch: 'Reading page...',
|
|
26
26
|
Read: 'Analyzing photo...',
|
|
27
|
+
search_meetings: 'Searching meetings...',
|
|
28
|
+
search_memories: 'Searching memory...',
|
|
29
|
+
read_meeting: 'Reading meeting...',
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export const queryRouter = Router()
|
|
@@ -111,6 +114,22 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
111
114
|
res.write(': keepalive\n\n')
|
|
112
115
|
|
|
113
116
|
let done = false
|
|
117
|
+
// The session id AS REPORTED BY THE BRIDGE.
|
|
118
|
+
//
|
|
119
|
+
// `const sid = await callModelStreaming(...)` below is not assigned until the
|
|
120
|
+
// whole call resolves, but these callbacks fire DURING that await — so any
|
|
121
|
+
// callback that reads `sid` reads a temporal-dead-zone binding and throws
|
|
122
|
+
// ReferenceError. `onStart` never noticed because its own `sid` PARAMETER
|
|
123
|
+
// shadows the outer const; `onDone` has no such parameter and did throw,
|
|
124
|
+
// after setting `done = true` and before writing the terminal event, which
|
|
125
|
+
// left the SSE socket open forever with the answer already persisted.
|
|
126
|
+
//
|
|
127
|
+
// Measured 2026-08-26: an Ollama tool turn streamed its full answer and never
|
|
128
|
+
// sent `done`; the glasses counted past 1,100 seconds on a turn that had
|
|
129
|
+
// finished in 166. Claude survived only because its bridge resolves outside
|
|
130
|
+
// the window — the same line was one scheduling change away from hanging
|
|
131
|
+
// every provider.
|
|
132
|
+
let streamSessionId: string | undefined = typeof sessionId === 'string' ? sessionId : undefined
|
|
114
133
|
const abortController = new AbortController()
|
|
115
134
|
res.on('close', () => {
|
|
116
135
|
if (!done) abortController.abort()
|
|
@@ -120,6 +139,7 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
120
139
|
try {
|
|
121
140
|
const sid = await callModelStreaming(resolvedQuery || '', sessionId, {
|
|
122
141
|
onStart: (model, sid, cliSessionId, metadata) => {
|
|
142
|
+
streamSessionId = sid
|
|
123
143
|
if (!done) {
|
|
124
144
|
const payload = { model, sessionId: sid, cliSessionId, ...metadata }
|
|
125
145
|
res.write(`event: start\ndata: ${JSON.stringify(payload)}\n\n`)
|
|
@@ -158,7 +178,7 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
158
178
|
// expiry or make maintenance proof outrun the pending write.
|
|
159
179
|
if (resolvedAttachments.ids.length > 0) {
|
|
160
180
|
await getMediaStore().associate(resolvedAttachments.ids, {
|
|
161
|
-
sessionId:
|
|
181
|
+
sessionId: streamSessionId ?? '',
|
|
162
182
|
...(validGlobalMsgNum ? { globalMsgNum: validGlobalMsgNum } : {}),
|
|
163
183
|
messageEra: activeMessageEra,
|
|
164
184
|
}).catch((err) => console.error('[query] attachment association failed:', err))
|
|
@@ -168,7 +188,7 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
168
188
|
const attachments = mergeMediaAttachmentRefs(attachmentRefs, metadata?.outputAttachments)
|
|
169
189
|
const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
|
|
170
190
|
const payload = {
|
|
171
|
-
text: fullText, sessionId:
|
|
191
|
+
text: fullText, sessionId: streamSessionId, model, cliSessionId, ...runMetadata,
|
|
172
192
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
173
193
|
}
|
|
174
194
|
res.write(`event: done\ndata: ${JSON.stringify(payload)}\n\n`)
|