@gotcos/glasses-server 6.39.3 → 6.40.1
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 +61 -0
- package/package.json +1 -1
- package/server/lib/ollama-bridge.ts +271 -50
- 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 +3 -0
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,64 @@
|
|
|
1
|
+
## 6.40.1
|
|
2
|
+
|
|
3
|
+
The local model can read your meetings and memories.
|
|
4
|
+
|
|
5
|
+
Until now the Ollama path appended "You have no tools" to every prompt, so a
|
|
6
|
+
local model could only answer from what was already in the window. It now gets
|
|
7
|
+
a closed allowlist of three READ-ONLY COS tools -- `search_meetings`,
|
|
8
|
+
`search_memories`, `read_meeting` -- but only when two things are true: the
|
|
9
|
+
pulled tag advertises `tools` on `/api/show`, and `COS_SCRIPTS_DIR` is a real
|
|
10
|
+
directory. A standalone npm install with no operations tree is unchanged, and
|
|
11
|
+
so is any tag that cannot call tools.
|
|
12
|
+
|
|
13
|
+
Writes, Bash, MCP, web search, web fetch, photos, Continue, Fork and Live Cues
|
|
14
|
+
are all still off this path. There is no in-process search or fetch executor in
|
|
15
|
+
this package, so advertising `WebSearch` would name a capability that cannot
|
|
16
|
+
run; those names stay out of the tools array deliberately.
|
|
17
|
+
|
|
18
|
+
The loop is bounded at five `/api/chat` POSTs per turn. Posts one through four
|
|
19
|
+
may execute a tool batch; the fifth is a closing fetch, and tool calls returned
|
|
20
|
+
there are refused rather than executed, so there is never a sixth. Tool results
|
|
21
|
+
are capped by DROPPING whole hits, never by cutting mid-string, so the model
|
|
22
|
+
always receives parseable JSON. `read_meeting` serializes a picked subset and
|
|
23
|
+
never the unbounded transcript the ops helpers still attach.
|
|
24
|
+
|
|
25
|
+
Search results always carry `semanticReason`, defaulting to `"none"`. Empty
|
|
26
|
+
hits with a reason describe THAT call; without the field a model reads an empty
|
|
27
|
+
list as proof the archive is empty and tells you that you have no meetings. The
|
|
28
|
+
prompt also states that the cached context block is today's calendar and not the
|
|
29
|
+
meeting library, because "No more meetings today" was being read as an archive
|
|
30
|
+
claim.
|
|
31
|
+
|
|
32
|
+
The Ollama system prompt is now built here rather than borrowed from the Claude
|
|
33
|
+
path, which instructs the model to search the web and read photo files. Cached
|
|
34
|
+
context is always included instead of keyword-gated, so a question about today's
|
|
35
|
+
schedule no longer arrives with no calendar attached unless it happened to use
|
|
36
|
+
the word "meeting".
|
|
37
|
+
|
|
38
|
+
Cancelling a turn mid-tool reports it as cancelled rather than as a timeout, and
|
|
39
|
+
an abort landing between tool calls now ends the turn instead of quietly issuing
|
|
40
|
+
another POST. A cancelled `semantic_search.py` child still runs to its own 15s
|
|
41
|
+
timeout; killing children is deferred.
|
|
42
|
+
|
|
43
|
+
## 6.40.0
|
|
44
|
+
|
|
45
|
+
Local thinking now follows the effort you asked for.
|
|
46
|
+
|
|
47
|
+
6.39.3 turned thinking off wholesale. This release makes it a ladder: the
|
|
48
|
+
default effort keeps local turns instant, and raising the effort raises the
|
|
49
|
+
thinking budget with it -- xhigh maps to a high thinking level, max and
|
|
50
|
+
ultracode to the maximum. That is what the escalation means: in a controlled
|
|
51
|
+
flag benchmark against an Opus 5 reference (10/10), a local Qwen model WITH
|
|
52
|
+
thinking also scored 10/10, where the same model without it scored 6-7. The
|
|
53
|
+
daemon accepts level strings on Qwen-class models and silently tolerates
|
|
54
|
+
think:false on models without the capability (both probed live).
|
|
55
|
+
|
|
56
|
+
COS_OLLAMA_THINK becomes a PIN rather than the only switch: "1"/"true"
|
|
57
|
+
always think, "0"/"false" never think, an explicit level forces that level,
|
|
58
|
+
and unset defers to the effort map. Pairs with COS Control 0.5.79, which
|
|
59
|
+
adds a local-model picker writing COS_OLLAMA_MODEL so multi-model daemons
|
|
60
|
+
stop depending on pull order.
|
|
61
|
+
|
|
1
62
|
## 6.39.3
|
|
2
63
|
|
|
3
64
|
Local turns answer in seconds again: thinking is off by default.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.40.1",
|
|
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,
|
|
@@ -26,52 +26,134 @@ import {
|
|
|
26
26
|
startOllamaRun,
|
|
27
27
|
} from './ollama-run-ledger.js'
|
|
28
28
|
import { notifyExchange, notifySessionStart } from './telegram-notify.js'
|
|
29
|
-
import { OLLAMA_MODEL } from '../../shared/model-preference.js'
|
|
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
|
-
* Thinking
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
53
|
+
* Thinking follows the REQUESTED EFFORT, not a blanket switch.
|
|
54
|
+
*
|
|
55
|
+
* The default effort ('high' -- every ordinary lens turn) keeps thinking
|
|
56
|
+
* OFF: measured 2026-08-26 on qwen3.5:35b, a hidden chain turned a
|
|
57
|
+
* 2.2-second answer into 98.6 seconds of dead screen at the same visible
|
|
58
|
+
* quality, and a hard prompt can out-run WALL_MAX_MS entirely. Raising the
|
|
59
|
+
* effort raises the thinking budget with it (xhigh -> 'high', max and
|
|
60
|
+
* ultracode -> 'max'), because that is what the escalation MEANS -- the
|
|
61
|
+
* flag benchmark showed a thinking local model matching Opus 5 (10/10)
|
|
62
|
+
* where the same model without thinking scored 6-7. Level strings are
|
|
63
|
+
* accepted by the daemon on Qwen-class models (probed live), and
|
|
64
|
+
* `think: false` is silently tolerated by models WITHOUT the thinking
|
|
65
|
+
* capability (verified against llama3.2:1b -- HTTP 200), so no capability
|
|
66
|
+
* gate is needed.
|
|
67
|
+
*
|
|
68
|
+
* COS_OLLAMA_THINK, when set, PINS the behavior regardless of effort:
|
|
69
|
+
* "1"/"true" always think, "0"/"false" never think, or an explicit level.
|
|
70
|
+
* Anything unrecognized reads as unset and defers to the effort map.
|
|
45
71
|
*/
|
|
46
|
-
export function resolveOllamaThink(
|
|
72
|
+
export function resolveOllamaThink(
|
|
73
|
+
raw: string | undefined,
|
|
74
|
+
effort?: EffortPreference,
|
|
75
|
+
): boolean | string {
|
|
47
76
|
const value = (raw ?? '').trim().toLowerCase()
|
|
48
77
|
if (value === '1' || value === 'true') return true
|
|
78
|
+
if (value === '0' || value === 'false') return false
|
|
49
79
|
if (value === 'low' || value === 'medium' || value === 'high' || value === 'max') return value
|
|
50
|
-
|
|
80
|
+
switch (effort) {
|
|
81
|
+
case 'xhigh': return 'high'
|
|
82
|
+
case 'max':
|
|
83
|
+
case 'ultracode': return 'max'
|
|
84
|
+
default: return false
|
|
85
|
+
}
|
|
51
86
|
}
|
|
52
87
|
const HISTORY_LIMIT = 20
|
|
53
88
|
|
|
54
89
|
type OllamaChatMessage = { role: 'system' | 'user' | 'assistant'; content: string }
|
|
55
90
|
|
|
56
|
-
|
|
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
|
+
} {
|
|
57
117
|
const trimmed = line.trim()
|
|
58
118
|
if (!trimmed) return { content: '', done: false }
|
|
59
119
|
try {
|
|
60
120
|
const event = JSON.parse(trimmed) as {
|
|
61
121
|
error?: unknown
|
|
62
122
|
done?: unknown
|
|
63
|
-
message?: { content?: unknown }
|
|
123
|
+
message?: { content?: unknown; tool_calls?: unknown }
|
|
64
124
|
}
|
|
65
125
|
if (typeof event.error === 'string' && event.error.trim()) {
|
|
66
126
|
return { content: '', done: true, error: event.error.trim() }
|
|
67
127
|
}
|
|
68
128
|
const content = typeof event.message?.content === 'string' ? event.message.content : ''
|
|
69
|
-
|
|
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
|
+
}
|
|
70
146
|
} catch {
|
|
71
147
|
return { content: '', done: false }
|
|
72
148
|
}
|
|
73
149
|
}
|
|
74
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
|
+
|
|
75
157
|
export function historyToOllamaMessages(
|
|
76
158
|
exchanges: Exchange[],
|
|
77
159
|
contextBreaks: number[],
|
|
@@ -94,6 +176,10 @@ function safeOllamaUserError(message: string): string {
|
|
|
94
176
|
if (code === 'ollama.unavailable') return 'Ollama is not running. Start ollama serve on this Mac.'
|
|
95
177
|
if (code === 'ollama.no_model') return 'Ollama has no pulled models. Run ollama pull, then retry.'
|
|
96
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.'
|
|
97
183
|
if (code === 'ollama.timeout') return 'Ollama timed out. Retry or pick another model.'
|
|
98
184
|
return `Ollama failed (${code}). Retry or check that ollama serve is running.`
|
|
99
185
|
}
|
|
@@ -131,7 +217,22 @@ export async function callOllamaStreaming(
|
|
|
131
217
|
const contextBreaks = session?.contextBreaks ?? []
|
|
132
218
|
const historyPrompt = formatHistoryForPrompt(history, contextBreaks, reference)
|
|
133
219
|
const handoffPrompt = options?.handoffContext?.promptBlock ? `\n\n${options.handoffContext.promptBlock}` : ''
|
|
134
|
-
|
|
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
|
+
})
|
|
135
236
|
|
|
136
237
|
const startTime = Date.now()
|
|
137
238
|
const run = startOllamaRun({
|
|
@@ -165,7 +266,11 @@ export async function callOllamaStreaming(
|
|
|
165
266
|
markSessionNotified(sid)
|
|
166
267
|
}
|
|
167
268
|
|
|
168
|
-
|
|
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[] = [
|
|
169
274
|
{ role: 'system', content: systemPrompt },
|
|
170
275
|
...historyToOllamaMessages(history, contextBreaks),
|
|
171
276
|
{ role: 'user', content: query },
|
|
@@ -177,9 +282,13 @@ export async function callOllamaStreaming(
|
|
|
177
282
|
|
|
178
283
|
let inactivityTimer: ReturnType<typeof setTimeout> | undefined
|
|
179
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
|
|
180
288
|
const clearTimers = () => {
|
|
181
289
|
if (inactivityTimer) clearTimeout(inactivityTimer)
|
|
182
290
|
if (wallTimer) clearTimeout(wallTimer)
|
|
291
|
+
if (toolHeartbeat) { clearInterval(toolHeartbeat); toolHeartbeat = undefined }
|
|
183
292
|
}
|
|
184
293
|
const bumpInactivity = () => {
|
|
185
294
|
if (inactivityTimer) clearTimeout(inactivityTimer)
|
|
@@ -218,32 +327,96 @@ export async function callOllamaStreaming(
|
|
|
218
327
|
bumpInactivity()
|
|
219
328
|
wallTimer = setTimeout(() => abort.abort(), WALL_MAX_MS)
|
|
220
329
|
|
|
221
|
-
|
|
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()
|
|
222
370
|
const response = await ollamaFetch(`${catalog.origin}/api/chat`, {
|
|
223
371
|
method: 'POST',
|
|
224
372
|
headers: { 'Content-Type': 'application/json' },
|
|
225
|
-
body: JSON.stringify(
|
|
226
|
-
model: catalog.model,
|
|
227
|
-
messages,
|
|
228
|
-
stream: true,
|
|
229
|
-
think: resolveOllamaThink(process.env.COS_OLLAMA_THINK),
|
|
230
|
-
}),
|
|
373
|
+
body: JSON.stringify(body),
|
|
231
374
|
signal: abort.signal,
|
|
232
375
|
})
|
|
233
376
|
if (!response.ok) {
|
|
234
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
|
+
}
|
|
235
390
|
await finalizeError(`ollama-bridge: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`)
|
|
236
|
-
return
|
|
391
|
+
return { kind: 'handled' }
|
|
237
392
|
}
|
|
238
393
|
if (!response.body) {
|
|
239
394
|
await finalizeError('ollama-bridge: empty stream')
|
|
240
|
-
return
|
|
395
|
+
return { kind: 'handled' }
|
|
241
396
|
}
|
|
242
397
|
|
|
243
398
|
const reader = response.body.getReader()
|
|
244
399
|
const decoder = new TextDecoder()
|
|
245
400
|
let buffer = ''
|
|
246
|
-
|
|
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) {
|
|
247
420
|
const { done, value } = await reader.read()
|
|
248
421
|
if (done) break
|
|
249
422
|
bumpInactivity()
|
|
@@ -251,34 +424,82 @@ export async function callOllamaStreaming(
|
|
|
251
424
|
const lines = buffer.split('\n')
|
|
252
425
|
buffer = lines.pop() ?? ''
|
|
253
426
|
for (const line of lines) {
|
|
254
|
-
const
|
|
255
|
-
if (
|
|
256
|
-
await finalizeError(`ollama-bridge: ${
|
|
257
|
-
return
|
|
427
|
+
const outcome = absorb(line)
|
|
428
|
+
if (outcome === 'error') {
|
|
429
|
+
await finalizeError(`ollama-bridge: ${parseOllamaChatDelta(line).error}`)
|
|
430
|
+
return { kind: 'handled' }
|
|
258
431
|
}
|
|
259
|
-
if (
|
|
260
|
-
|
|
261
|
-
|
|
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 }
|
|
262
481
|
}
|
|
263
|
-
if (
|
|
264
|
-
await
|
|
482
|
+
if (result === 'aborted' || abort.signal.aborted) {
|
|
483
|
+
await finalizeError('ollama-bridge: tool call aborted.')
|
|
265
484
|
return sid
|
|
266
485
|
}
|
|
486
|
+
messages.push({
|
|
487
|
+
role: 'tool',
|
|
488
|
+
content: result,
|
|
489
|
+
tool_name: name,
|
|
490
|
+
...(call.id ? { tool_call_id: call.id } : {}),
|
|
491
|
+
})
|
|
267
492
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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.')
|
|
273
499
|
return sid
|
|
274
500
|
}
|
|
275
|
-
|
|
276
|
-
fullText += delta.content
|
|
277
|
-
callbacks.onChunk(delta.content)
|
|
278
|
-
}
|
|
501
|
+
bumpWall()
|
|
279
502
|
}
|
|
280
|
-
await finalizeDone()
|
|
281
|
-
return sid
|
|
282
503
|
} catch (error: any) {
|
|
283
504
|
const aborted = abort.signal.aborted || options?.abortSignal?.aborted
|
|
284
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()
|