@gotcos/glasses-server 6.40.0 → 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 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
- # Direct POST /api/chat chat/summarize only, no tools, no Codex --oss.
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,45 @@
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
+
1
43
  ## 6.40.0
2
44
 
3
45
  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.0",
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, no tools, text only.
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
- export function parseOllamaChatDelta(line: string): { content: string; done: boolean; error?: string } {
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
- return { content, done: event.done === true }
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
- const systemPrompt = `${buildLightweightSystemPrompt(query, `${historyPrompt}${handoffPrompt}`)}\n\nYou have no tools. Answer from the prompt and conversation only. Plain text.`
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
- const messages: OllamaChatMessage[] = [
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
- try {
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 sid
391
+ return { kind: 'handled' }
254
392
  }
255
393
  if (!response.body) {
256
394
  await finalizeError('ollama-bridge: empty stream')
257
- return sid
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
- while (true) {
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 delta = parseOllamaChatDelta(line)
272
- if (delta.error) {
273
- await finalizeError(`ollama-bridge: ${delta.error}`)
274
- return sid
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 (delta.content) {
277
- fullText += delta.content
278
- callbacks.onChunk(delta.content)
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 (delta.done) {
281
- await finalizeDone()
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
- if (buffer.trim()) {
287
- const delta = parseOllamaChatDelta(buffer)
288
- if (delta.error) {
289
- await finalizeError(`ollama-bridge: ${delta.error}`)
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
- if (delta.content) {
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 {
@@ -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()