freddie 0.0.121 → 0.0.123

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.
@@ -0,0 +1,312 @@
1
+ // freddie case toolset -- the agent's hands on a CRM "case" system of record, plus
2
+ // a worker ENQUIRY surface. Agentic code lives here (per the layering mandate);
3
+ // the data lives in a thatcher-backed store the host application injects. Nothing
4
+ // here is casey-specific: the store, the field/enum config, and the role model all
5
+ // arrive via toolCtx + plugin config, so a different application configures the
6
+ // same toolset differently.
7
+ //
8
+ // Two design rules from the re-architecture:
9
+ // - IDENTITY comes from toolCtx (author/role/activeCaseRef/store), never a global
10
+ // singleton, so the agent loop stays identity-blind and a tool answers FOR the
11
+ // asking worker.
12
+ // - ENQUIRY outputs are PROJECTED: a per-case row handed to the model is scrubbed
13
+ // to a whitelist that EXCLUDES external_id/contact_id (a phone number / chat id),
14
+ // so a worker's list -- or a mis-scoped neighbour row -- can never surface PII.
15
+
16
+ const str = (description, extra = {}) => ({ type: 'string', description, ...extra })
17
+
18
+ // The default report field set. An app overrides via config (plugins.case.reportKeys)
19
+ // so the captured-field vocabulary is configuration, not code.
20
+ const DEFAULT_REPORT_KEYS = ['species', 'symptoms', 'location', 'how_to_find', 'affected_count',
21
+ 'dead_count', 'onset', 'suspected_disease', 'recent_movement', 'identifying_traits',
22
+ 'access_notes', 'farmer_available', 'contact_fallback', 'photos', 'audio', 'notes']
23
+
24
+ const DEFAULT_STATUS_ENUM = ['new', 'triaging', 'in_progress', 'waiting', 'resolved', 'closed']
25
+
26
+ // Whitelisted fields a per-case enquiry row may expose to the agent. Deliberately
27
+ // EXCLUDES external_id and contact_id (PII). An app may extend via config
28
+ // (plugins.case.enquiryProjection) but the PII keys are always stripped.
29
+ const DEFAULT_PROJECTION = ['id', 'ref', 'status', 'priority', 'subject', 'summary',
30
+ 'case_type', 'tags', 'assignee', 'autonomy', 'species', 'location', 'last_event_at', 'created_at', 'distance_km']
31
+ const PII_KEYS = ['external_id', 'contact_id', 'contact', 'handle', 'phone', 'from']
32
+
33
+ function pick(obj, keys) {
34
+ const out = {}
35
+ for (const k of keys) if (obj[k] !== undefined) out[k] = obj[k]
36
+ return out
37
+ }
38
+
39
+ // Project a case row for enquiry output: keep only whitelisted keys and NEVER any
40
+ // PII key, even if the whitelist or a slimmed report leaked one in.
41
+ function projectCase(row, projection) {
42
+ if (!row || typeof row !== 'object') return row
43
+ const out = pick(row, projection)
44
+ for (const k of PII_KEYS) delete out[k]
45
+ return out
46
+ }
47
+
48
+ // Resolve the store from toolCtx, else the plugin-configured fallback. Throws a
49
+ // clear error (surfaced to the agent as a tool error) when neither is present.
50
+ function storeFrom(ctx, fallback) {
51
+ const s = ctx?.store || fallback?.()
52
+ if (!s) throw new Error('case toolset: no store in toolCtx and no configured fallback store')
53
+ return s
54
+ }
55
+
56
+ // Build the case toolset. Options:
57
+ // - resolveStore():store -- fallback store when toolCtx carries none (e.g. tests)
58
+ // - config -- scopedCfg-like { get(k,d), all() } for reportKeys,
59
+ // statusEnum, enquiryProjection, actor
60
+ // - slimCase/slimEvent -- optional projectors the host provides (else identity)
61
+ export function buildCaseToolset({ resolveStore = null, config = null, slimCase = (c) => c, slimEvent = (e) => e } = {}) {
62
+ const cfg = config || { get: (_k, d) => d, all: () => ({}) }
63
+ const reportKeys = cfg.get('reportKeys', DEFAULT_REPORT_KEYS)
64
+ const statusEnum = cfg.get('statusEnum', DEFAULT_STATUS_ENUM)
65
+ const projection = cfg.get('enquiryProjection', DEFAULT_PROJECTION)
66
+ const actor = cfg.get('actor', 'agent')
67
+ const terminalStatuses = cfg.get('terminalStatuses', ['resolved', 'closed'])
68
+ // The field that identifies the REPORTER of a case (who messaged it in). A worker's
69
+ // "my cases" itinerary must scope by who REPORTED the case, not who it is assigned
70
+ // to -- the reporter is the channel author (external_id), never an operator assignee,
71
+ // so an assignee-scoped "my cases" returned nothing for the asking worker. Default
72
+ // external_id; a host keys it via plugins.case.reporterField.
73
+ const reporterField = cfg.get('reporterField', 'external_id')
74
+
75
+ // A case row scrubbed for enquiry output -- slim first (structured report), then
76
+ // project to the PII-free whitelist.
77
+ const enquiryRow = (c) => projectCase({ ...c, ...slimCase(c) }, projection)
78
+
79
+ const tools = []
80
+ const T = (name, schema, handler) => tools.push({ name, toolset: 'cases', schema: { name, ...schema }, handler })
81
+
82
+ // ---- core case_* tools (relocated from the host app; store from ctx) ----
83
+ T('case_get',
84
+ { description: 'Fetch a case by id, including its recent timeline events. Use to refresh your view before acting.',
85
+ parameters: { type: 'object', properties: { id: str('Case id') }, required: ['id'] } },
86
+ async ({ id }, ctx) => {
87
+ const store = storeFrom(ctx, resolveStore)
88
+ const c = await store.getCase(id)
89
+ if (!c) return { error: `no case ${id}` }
90
+ const events = await store.listEvents(id, { limit: 30 })
91
+ // PII projection by caller: a worker-facing status read gets the enquiryRow
92
+ // whitelist (no external_id/contact_id/phone) so a model-narrated status answer
93
+ // can never echo a contact identifier; an operator/dashboard read keeps the full
94
+ // slimCase. Default to the safe (worker) projection when the role is unknown.
95
+ const operator = ctx?.role === 'operator' || ctx?.principal?.role === 'operator' || ctx?.role === 'dashboard'
96
+ return { case: operator ? slimCase(c) : enquiryRow(c), events: events.map(slimEvent) }
97
+ })
98
+
99
+ T('case_list',
100
+ { description: 'List cases, optionally filtered by status/channel/assignee/location. Use `location` (a town, area, or province name the person mentions) to find reports in a place -- this is the place-enquiry tool (case_near needs GPS the worker rarely has). Returns most-recently-active first, PII-free.',
101
+ parameters: { type: 'object', properties: {
102
+ status: str('Filter by workflow status', { enum: statusEnum }),
103
+ channel: str('Filter by channel'), assignee: str('Filter by assignee'),
104
+ location: str('A place name (town/area/province) to match reports whose location contains it'),
105
+ limit: { type: 'number', default: 25 },
106
+ } } },
107
+ async ({ status, channel, assignee, location, limit = 25 }, ctx) => {
108
+ const store = storeFrom(ctx, resolveStore)
109
+ const where = {}
110
+ if (status) where.status = status
111
+ if (channel) where.channel = channel
112
+ if (assignee) where.assignee = assignee
113
+ // A place-name enquiry: match reports whose location contains the token. Rides
114
+ // thatcher's $ilike operator-where (no gazetteer regex -- the model supplies the
115
+ // place). The store's feature-detect shim falls back to a JS contains filter.
116
+ if (location) where.location = { $ilike: `%${String(location).toLowerCase()}%` }
117
+ const rows = await store.listCases(where, { limit })
118
+ // enquiryRow (PII-free) not slimCase -- a worker-facing list must never carry
119
+ // external_id/contact_id/phone.
120
+ return { count: rows.length, cases: rows.map(enquiryRow) }
121
+ })
122
+
123
+ T('case_update',
124
+ { description: 'Update editable case fields (subject, summary, priority, tags, assignee). Keep `summary` current -- it is your working memory of the case.',
125
+ parameters: { type: 'object', properties: {
126
+ id: str('Case id'), subject: str('Short human title'),
127
+ summary: str('One-paragraph rolling summary of the case state'),
128
+ priority: str('Priority', { enum: ['low', 'normal', 'high', 'urgent'] }),
129
+ tags: str('Comma-separated tags'), assignee: str('Operator handle, or "agent"'),
130
+ }, required: ['id'] } },
131
+ async ({ id, ...patch }, ctx) => {
132
+ const store = storeFrom(ctx, resolveStore)
133
+ const clean = pick(patch, ['subject', 'summary', 'priority', 'tags', 'assignee'])
134
+ if (!Object.keys(clean).length) return { error: 'no editable fields supplied' }
135
+ const c = await store.getCase(id)
136
+ if (!c) return { error: `no case ${id}` }
137
+ // Observe mode is operator control: the agent may only observe.
138
+ if (c.autonomy === 'observe') return { error: 'case autonomy is "observe"; agent edits are disabled. Use case_observe to record notes.' }
139
+ const updated = await store.updateCase(id, clean, actor)
140
+ await store.appendEvent(id, { kind: 'action', actor: 'agent', text: `updated ${Object.keys(clean).join(', ')}`, data: clean })
141
+ return { ok: true, case: slimCase(updated) }
142
+ })
143
+
144
+ T('case_report',
145
+ { description: 'Record what you have learned about the report, one field at a time, as the person gives it. Pass ONLY the fields you actually learned this turn -- they merge into the running report, so you never lose earlier facts and never repeat a field already given.',
146
+ parameters: { type: 'object', properties: Object.fromEntries([['id', str('Case id')], ...reportKeys.map(k => [k, str(`Report field: ${k}`)])]), required: ['id'] } },
147
+ async ({ id, ...fields }, ctx) => {
148
+ const store = storeFrom(ctx, resolveStore)
149
+ const incoming = pick(fields, reportKeys)
150
+ if (!Object.keys(incoming).length) return { error: 'no report fields supplied' }
151
+ const res = await store.mergeReport(id, incoming, actor)
152
+ if (res.error === 'observe') return { error: 'case autonomy is "observe"; agent edits are disabled. Use case_observe to record notes.' }
153
+ if (res.error) return { error: res.error }
154
+ await store.appendEvent(id, { kind: 'action', actor: 'agent', text: `recorded report fields: ${Object.keys(incoming).join(', ')}`, data: incoming })
155
+ return { ok: true, report: res.report, fieldsRecorded: Object.keys(incoming) }
156
+ })
157
+
158
+ T('case_observe',
159
+ { description: 'Record an observation or internal note on the case timeline WITHOUT replying to the contact.',
160
+ parameters: { type: 'object', properties: { id: str('Case id'), text: str('The observation') }, required: ['id', 'text'] } },
161
+ async ({ id, text }, ctx) => {
162
+ const store = storeFrom(ctx, resolveStore)
163
+ await store.appendEvent(id, { kind: 'observation', actor: 'agent', text })
164
+ return { ok: true }
165
+ })
166
+
167
+ // ---- ENQUIRY surface (the new worker-facing retrieval, role-scoped, PII-free) ----
168
+
169
+ T('case_mine',
170
+ { description: 'List the cases assigned to or claimed by the CURRENT worker. Use when the worker asks about "my cases" or "what am I working on". Returns PII-free rows, most recent first.',
171
+ parameters: { type: 'object', properties: { status: str('Optional status filter', { enum: statusEnum }), limit: { type: 'number', default: 20 } } } },
172
+ async ({ status, limit = 20 }, ctx) => {
173
+ const store = storeFrom(ctx, resolveStore)
174
+ // Scope by REPORTER (the channel author), not assignee -- a worker's own cases
175
+ // are the ones they reported. The equality filter already isolates the worker,
176
+ // so no row_access user scope is needed (and external_id is stripped by
177
+ // enquiryRow, so filtering on it never leaks it).
178
+ const where = ctx?.author ? { [reporterField]: ctx.author } : {}
179
+ if (status) where.status = status
180
+ const rows = await store.listCases(where, { limit })
181
+ return { count: rows.length, cases: rows.map(enquiryRow) }
182
+ })
183
+
184
+ T('case_today',
185
+ { description: 'List cases created or last active TODAY (the worker\'s "today\'s list"). Returns PII-free rows.',
186
+ parameters: { type: 'object', properties: { mineOnly: { type: 'boolean', default: true, description: 'restrict to the current worker' }, limit: { type: 'number', default: 20 } } } },
187
+ async ({ mineOnly = true, limit = 20 }, ctx) => {
188
+ const store = storeFrom(ctx, resolveStore)
189
+ const dayStart = ctx?.now ? new Date(ctx.now) : new Date()
190
+ dayStart.setHours(0, 0, 0, 0)
191
+ const since = String(Math.floor(dayStart.getTime() / 1000)) // case timestamps are unix seconds
192
+ const where = { last_event_at: { $gte: since } }
193
+ if (mineOnly && ctx?.author) where[reporterField] = ctx.author // reporter scope, not assignee
194
+ const rows = await store.listCases(where, { limit: mineOnly ? limit : limit, ...(mineOnly ? {} : { user: ctx?.principal }) })
195
+ return { count: rows.length, cases: rows.map(enquiryRow) }
196
+ })
197
+
198
+ T('case_near',
199
+ { description: 'List cases NEAR the worker, by a lat/lon bounding box. Provide the worker\'s lat/lon and a radius (km). Returns PII-free rows.',
200
+ parameters: { type: 'object', properties: {
201
+ lat: { type: 'number', description: 'worker latitude' }, lon: { type: 'number', description: 'worker longitude' },
202
+ radiusKm: { type: 'number', default: 25 }, limit: { type: 'number', default: 20 },
203
+ }, required: ['lat', 'lon'] } },
204
+ async ({ lat, lon, radiusKm = 25, limit = 20 }, ctx) => {
205
+ const store = storeFrom(ctx, resolveStore)
206
+ // Bounding box: ~111km per degree latitude; longitude scaled by cos(lat).
207
+ const dLat = radiusKm / 111
208
+ const dLon = radiusKm / (111 * Math.max(0.01, Math.cos(lat * Math.PI / 180)))
209
+ const where = { lat: { $gte: lat - dLat, $lte: lat + dLat }, lon: { $gte: lon - dLon, $lte: lon + dLon } }
210
+ const rows = await store.listCases(where, { limit, user: ctx?.principal })
211
+ const withDist = rows.map(r => ({ ...r, distance_km: haversineKm(lat, lon, Number(r.lat), Number(r.lon)) }))
212
+ .filter(r => !Number.isFinite(r.distance_km) || r.distance_km <= radiusKm)
213
+ .sort((a, b) => (a.distance_km ?? 1e9) - (b.distance_km ?? 1e9))
214
+ return { count: withDist.length, cases: withDist.map(enquiryRow) }
215
+ })
216
+
217
+ T('case_today_open',
218
+ { description: 'List OPEN, available work the current worker could help with -- unassigned or unresolved cases ("anything I can help with"). Returns PII-free rows.',
219
+ parameters: { type: 'object', properties: { limit: { type: 'number', default: 20 } } } },
220
+ async ({ limit = 20 }, ctx) => {
221
+ const store = storeFrom(ctx, resolveStore)
222
+ // Open = status NOT in the terminal set; available = no assignee or 'agent'.
223
+ const where = { status: { $in: statusEnum.filter(s => !terminalStatuses.includes(s)) } }
224
+ const rows = await store.listCases(where, { limit: limit * 3, user: ctx?.principal })
225
+ const available = rows.filter(r => !r.assignee || r.assignee === 'agent' || r.assignee === actor).slice(0, limit)
226
+ return { count: available.length, cases: available.map(enquiryRow) }
227
+ })
228
+
229
+ // ---- active-case binding: select / new (explicit) ----
230
+
231
+ T('case_select',
232
+ { description: 'Bind the worker\'s ACTIVE case to an existing case (by id or ref), so their subsequent field updates go into THIS case. Use when the worker says which case they are working on. Does NOT create a case.',
233
+ parameters: { type: 'object', properties: { id: str('Case id'), ref: str('Case reference (e.g. CASE-1042-x)') } } },
234
+ async ({ id, ref }, ctx) => {
235
+ const store = storeFrom(ctx, resolveStore)
236
+ let c = id ? await store.getCase(id) : null
237
+ if (!c && ref && store.findCaseByRef) c = await store.findCaseByRef(ref)
238
+ if (!c) return { error: `no case found for ${id || ref || '(nothing given)'}` }
239
+ if (store.setActiveCase && ctx?.author) await store.setActiveCase(ctx.author, c.id)
240
+ await store.appendEvent(c.id, { kind: 'observation', actor: 'system', text: `worker ${ctx?.author || 'unknown'} selected this case as active` })
241
+ return { ok: true, activeCase: enquiryRow(c) }
242
+ })
243
+
244
+ T('case_new',
245
+ { description: 'Explicitly CREATE a new case and bind it active to the worker. Use ONLY when the worker explicitly asks to start a new case/report -- never to auto-open one. Optionally seed a subject.',
246
+ parameters: { type: 'object', properties: { subject: str('Optional short subject') } } },
247
+ async ({ subject }, ctx) => {
248
+ const store = storeFrom(ctx, resolveStore)
249
+ if (!store.createCase) return { error: 'store does not support explicit case creation' }
250
+ const c = await store.createCase({ subject: subject || '', assignee: ctx?.author || actor, channel: ctx?.channel || 'enquiry' })
251
+ if (store.setActiveCase && ctx?.author) await store.setActiveCase(ctx.author, c.id)
252
+ await store.appendEvent(c.id, { kind: 'note', actor: 'system', text: `case explicitly opened by worker ${ctx?.author || 'unknown'}` })
253
+ return { ok: true, activeCase: enquiryRow(c) }
254
+ })
255
+
256
+ // ---- service controls the agent can ACT on (opt-out / human handoff) ----
257
+ T('case_stop',
258
+ { description: 'The person asked to STOP receiving messages (opt out). Records the opt-out so no more automatic replies go to them. Use ONLY on a clear opt-out.',
259
+ parameters: { type: 'object', properties: { id: str('Case id') }, required: ['id'] } },
260
+ async ({ id }, ctx) => {
261
+ const store = storeFrom(ctx, resolveStore)
262
+ const c = await store.getCase(id)
263
+ if (!c) return { error: `no case ${id}` }
264
+ const tags = String(c.tags || '').split(',').map(s => s.trim()).filter(Boolean)
265
+ if (!tags.includes('opted-out')) tags.push('opted-out')
266
+ await store.updateCase(id, { tags: tags.join(',') }, ctx?.principal || undefined)
267
+ await store.appendEvent(id, { kind: 'observation', actor: 'agent', text: 'OPT-OUT: the person asked to stop; no more automatic replies.' })
268
+ return { ok: true }
269
+ })
270
+
271
+ T('case_handoff',
272
+ { description: 'The person wants a real person / operator to help. Flags the case for a human and records the request. Use on a clear ask for a person.',
273
+ parameters: { type: 'object', properties: { id: str('Case id') }, required: ['id'] } },
274
+ async ({ id }, ctx) => {
275
+ const store = storeFrom(ctx, resolveStore)
276
+ const c = await store.getCase(id)
277
+ if (!c) return { error: `no case ${id}` }
278
+ const tags = String(c.tags || '').split(',').map(s => s.trim()).filter(Boolean)
279
+ if (!tags.includes('needs-human')) tags.push('needs-human')
280
+ await store.updateCase(id, { tags: tags.join(',') }, ctx?.principal || undefined)
281
+ await store.appendEvent(id, { kind: 'observation', actor: 'agent', text: 'HANDOFF REQUESTED: the person asked for a real person.' })
282
+ return { ok: true }
283
+ })
284
+
285
+ // The agent DECLARES which phase the conversation is now in; the host reads this
286
+ // observation back and applies the durable state transition (dstate). Append-only,
287
+ // like case_intent -- keeps the state I/O in the host, not the tool.
288
+ T('case_stage',
289
+ { description: 'Declare which phase the conversation is now in: greeting (a warm opener), gathering (collecting the report), enquiring (the worker asked about their work), answering (a general question), complete (the report is on record), handoff (a person is needed), or closed (they asked to stop). Call this when the phase changes so you keep your place and never repeat a question.',
290
+ parameters: { type: 'object', properties: {
291
+ to: str('Conversation phase', { enum: ['greeting', 'gathering', 'enquiring', 'answering', 'complete', 'handoff', 'closed'] }),
292
+ }, required: ['to'] } },
293
+ async ({ to }, ctx) => {
294
+ const store = storeFrom(ctx, resolveStore)
295
+ const id = ctx?.activeCaseId
296
+ if (!id) return { error: 'no active case' }
297
+ await store.appendEvent(id, { kind: 'observation', actor: 'agent', text: `STAGE-DECLARED ${to}` })
298
+ return { ok: true, stage: to }
299
+ })
300
+
301
+ return tools
302
+ }
303
+
304
+ function haversineKm(lat1, lon1, lat2, lon2) {
305
+ if (![lat1, lon1, lat2, lon2].every(Number.isFinite)) return NaN
306
+ const R = 6371, toRad = (d) => d * Math.PI / 180
307
+ const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1)
308
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2
309
+ return 2 * R * Math.asin(Math.sqrt(a))
310
+ }
311
+
312
+ export { projectCase, DEFAULT_PROJECTION, DEFAULT_REPORT_KEYS, PII_KEYS }
package/src/sessions.js CHANGED
@@ -59,13 +59,28 @@ export async function appendMessage(sessionId, { role, content = '', toolCalls =
59
59
  const info = await d.prepare(`INSERT INTO messages (session_id, role, content, tool_calls, tool_call_id, ts) VALUES (?, ?, ?, ?, ?, ?)`)
60
60
  .run(sessionId, role, content, toolCalls ? JSON.stringify(toolCalls) : null, toolCallId, now)
61
61
  await d.prepare(`UPDATE sessions SET updated_at = ? WHERE id = ?`).run(now, sessionId)
62
+ // Auto-derive a scannable title from the first user prompt so `session list`
63
+ // shows readable rows instead of bare uuids. Only sets when title is null/empty.
64
+ if (role === 'user' && content) {
65
+ const title = content.replace(/\s+/g, ' ').trim().slice(0, 60)
66
+ if (title) await d.prepare(`UPDATE sessions SET title = ? WHERE id = ? AND (title IS NULL OR title = '')`).run(title, sessionId)
67
+ }
62
68
  return info.lastInsertRowid
63
69
  }
64
70
 
65
71
  export async function getMessages(sessionId) {
66
72
  const d = await db()
67
73
  const rows = await d.prepare(`SELECT id, role, content, tool_calls, tool_call_id, ts FROM messages WHERE session_id = ? ORDER BY ts ASC, id ASC`).all(sessionId)
68
- return rows.map(r => ({ ...r, tool_calls: r.tool_calls ? JSON.parse(r.tool_calls) : null }))
74
+ return rows.map(r => {
75
+ let tool_calls = null
76
+ if (r.tool_calls) {
77
+ // A corrupted tool_calls cell (manual DB edit, a crash mid-serialize)
78
+ // must not crash every reader of this session -- degrade to null.
79
+ try { tool_calls = JSON.parse(r.tool_calls) }
80
+ catch (e) { console.error('sessions.js: corrupted tool_calls, treating as null', { id: r.id, error: String(e) }) }
81
+ }
82
+ return { ...r, tool_calls }
83
+ })
69
84
  }
70
85
 
71
86
  export async function listSessions(limit = 50) {
@@ -73,6 +88,28 @@ export async function listSessions(limit = 50) {
73
88
  return await d.prepare(`SELECT id, platform, title, created_at, updated_at, model, cwd, skill FROM sessions ORDER BY updated_at DESC LIMIT ?`).all(limit)
74
89
  }
75
90
 
91
+ export async function getSession(id) {
92
+ const d = await db()
93
+ return await d.prepare(`SELECT id, platform, title, created_at, updated_at, model, cwd, skill FROM sessions WHERE id = ?`).get(id) || null
94
+ }
95
+
96
+ export async function deleteSession(id) {
97
+ const d = await db()
98
+ // messages_fts is an external-content FTS5 table (content='messages'); its
99
+ // 'ai' trigger only fires on INSERT, so deleting the messages does not purge
100
+ // the index. Rebuild the FTS index after the message rows are gone.
101
+ await d.prepare(`DELETE FROM messages WHERE session_id = ?`).run(id)
102
+ try { await d.prepare(`INSERT INTO messages_fts(messages_fts) VALUES('rebuild')`).run() } catch {}
103
+ const info = await d.prepare(`DELETE FROM sessions WHERE id = ?`).run(id)
104
+ return { id, deleted: (info.changes ?? info.rowsAffected ?? 0) > 0 }
105
+ }
106
+
107
+ export async function setSessionTitle(id, title) {
108
+ const d = await db()
109
+ await d.prepare(`UPDATE sessions SET title = ? WHERE id = ?`).run(title, id)
110
+ return { id, title }
111
+ }
112
+
76
113
  export async function search(query, limit = 20) {
77
114
  const d = await db()
78
115
  const likePattern = `%${query}%`
@@ -6,6 +6,9 @@ export const DEFAULT_DISTRIBUTIONS = {
6
6
  ops: { enabledToolsets: ['core'], disabledToolsets: ['creative', 'browse'] },
7
7
  minimal: { enabledToolsets: ['core'], disabledToolsets: ['browse', 'creative'] },
8
8
  full: { enabledToolsets: ['core', 'browse', 'creative'], disabledToolsets: [] },
9
+ // A field worker on a CRM "case" surface: the core tools plus the case/enquiry
10
+ // toolset (case_* + case_mine/today/near/select/new). No browse/creative.
11
+ 'field-worker': { enabledToolsets: ['core', 'cases'], disabledToolsets: ['browse', 'creative'] },
9
12
  }
10
13
  export function listDistributions() { return Object.keys(DEFAULT_DISTRIBUTIONS) }
11
14
  export function getDistribution(name) { return DEFAULT_DISTRIBUTIONS[name] || null }
package/src/toolsets.js CHANGED
@@ -20,5 +20,5 @@ export async function getEnabledToolNames(enabled = ['core'], disabled = []) {
20
20
 
21
21
  export async function getAvailableToolsets() {
22
22
  const h = await bootHost()
23
- return [...new Set(h.pi.tools.list().map(t => t.toolset || 'core'))]
23
+ return [...new Set(available(h).map(t => t.toolset || 'core'))]
24
24
  }
package/src/web/app.js CHANGED
@@ -2,35 +2,53 @@ import { h, applyDiff, installStyles, components } from 'anentrypoint-design';
2
2
  import { fetchHost, ROUTES } from './state.js';
3
3
  import { PAGES } from './routes.js';
4
4
 
5
- const { AppShell, Topbar, Side, Crumb, Status, EmptyState, Chip } = components;
5
+ const { AppShell, Topbar, Side, Crumb, Status, EmptyState, Chip, ThemeToggle, Icon } = components;
6
6
 
7
7
  await installStyles();
8
+ // Styles installed — lift the FOUC visibility guard (see index.html reset).
9
+ document.body.setAttribute('data-ready', '');
8
10
 
9
11
  const root = document.getElementById('app');
10
12
  root.textContent = 'loading…';
11
13
  const host0 = await fetchHost();
12
14
  root.innerHTML = '';
13
15
 
16
+ // Lightweight transient error surface for mutation failures (config.saveValue,
17
+ // chat.send, cron.create, …). state.js's mutators reject; the catch handler in
18
+ // each callsite — or state.js's wrapMutation — routes the message here so a
19
+ // thrown error is shown to the user instead of vanishing into the console.
20
+ let _toastTimer = null;
21
+ function notify(msg, tone = 'error') {
22
+ let el = document.getElementById('fd-toast');
23
+ if (!el) { el = document.createElement('div'); el.id = 'fd-toast'; el.className = 'fd-toast'; el.setAttribute('role', 'alert'); document.body.appendChild(el); }
24
+ el.textContent = String(msg || '').slice(0, 300);
25
+ el.dataset.tone = tone;
26
+ el.style.display = 'block';
27
+ if (_toastTimer) clearTimeout(_toastTimer);
28
+ _toastTimer = setTimeout(() => { el.style.display = 'none'; }, 6000);
29
+ }
30
+ if (typeof window !== 'undefined') window.__fd_notify = notify;
31
+
14
32
  function routeFromHash() {
15
33
  const m = String(location.hash || '').match(/^#(?:fd-)?([a-z]+)/i);
16
34
  const p = m && m[1];
17
35
  return ROUTES.find(r => r.path === p) ? p : 'home';
18
36
  }
19
- const state = { active: routeFromHash(), ts: new Date().toLocaleTimeString(), body: null, error: null, sampler: { ok: 0, bad: 0, total: 0 } };
37
+ const state = { active: routeFromHash(), ts: new Date().toLocaleTimeString(), body: null, error: null, sampler: { ok: 0, bad: 0, total: 0, error: false } };
20
38
 
21
39
  async function refreshSampler() {
22
40
  try {
23
41
  const j = await fetch('/api/models/sampler').then(r => r.json());
24
42
  const ents = Object.values(j.status || {});
25
- state.sampler = { total: ents.length, ok: ents.filter(s => s && s.available !== false).length, bad: ents.filter(s => s && s.available === false).length };
26
- } catch { state.sampler = { ok: 0, bad: 0, total: 0 }; }
43
+ state.sampler = { total: ents.length, ok: ents.filter(s => s && s.available !== false).length, bad: ents.filter(s => s && s.available === false).length, error: false };
44
+ } catch { state.sampler = { ok: 0, bad: 0, total: 0, error: true }; }
27
45
  }
28
46
  await refreshSampler();
29
47
  setInterval(() => { refreshSampler().then(rerender); }, 15000);
30
48
 
31
49
  function buildSide() {
32
50
  return Side({ sections: [{ group: 'freddie', items: ROUTES.map(r => ({
33
- glyph: r.glyph, label: r.label, href: '#fd-' + r.path,
51
+ glyph: Icon ? Icon(r.icon) : null, label: r.label, href: '#fd-' + r.path,
34
52
  active: state.active === r.path,
35
53
  onClick: ev => { ev.preventDefault(); setActive(r.path); },
36
54
  })) }] });
@@ -38,27 +56,48 @@ function buildSide() {
38
56
 
39
57
  function view() {
40
58
  const route = ROUTES.find(r => r.path === state.active) || ROUTES[0];
41
- const body = state.body || EmptyState({ text: 'loading…', glyph: '◌' });
59
+ const body = state.body || EmptyState({ text: 'loading…' });
42
60
  const main = h('div', { key: state.active, class: 'fd-page' }, ...(Array.isArray(body) ? body : [body]));
43
- const samplerPill = state.sampler.total > 0
44
- ? Chip({ tone: state.sampler.bad > 0 ? 'miss' : 'ok', children: 'sampler ' + state.sampler.ok + '/' + state.sampler.total })
45
- : Chip({ tone: 'neutral', children: 'sampler —' });
61
+ const samplerPill = state.sampler.error
62
+ ? Chip({ tone: 'miss', children: 'sampler err' })
63
+ : state.sampler.total > 0
64
+ ? Chip({ tone: state.sampler.bad > 0 ? 'miss' : 'ok', children: 'sampler ' + state.sampler.ok + '/' + state.sampler.total })
65
+ : Chip({ tone: 'neutral', children: 'sampler —' });
66
+ // Layout lives in .fd-topbar-leaf (index.html reset block) — zero inline CSS.
67
+ const leaf = h('span', { class: 'fd-topbar-leaf' },
68
+ samplerPill, ThemeToggle ? ThemeToggle({ compact: true }) : null);
46
69
  return AppShell({
47
- topbar: Topbar({ brand: 'freddie', leaf: samplerPill, items: [], active: '' }),
48
- crumb: Crumb({ trail: ['freddie'], leaf: route.path, right: state.error ? Chip({ tone: 'miss', children: 'error' }) : Chip({ tone: 'ok', children: 'live' }) }),
70
+ topbar: Topbar({ brand: 'freddie', leaf, items: [], active: '' }),
71
+ crumb: Crumb({ trail: ['freddie'], leaf: route.label || route.path, right: state.error ? Chip({ tone: 'miss', children: 'error' }) : Chip({ tone: 'ok', children: 'live' }) }),
49
72
  side: buildSide(),
50
73
  main,
51
74
  status: Status({ left: ['ds-247420 · webjsx · ' + ROUTES.length + ' routes'], right: [state.ts] }),
52
75
  });
53
76
  }
54
77
 
55
- function rerender() { applyDiff(root, view()); }
78
+ function rerender() {
79
+ // LIVE-ROUTE RECOMPUTE HOOK: rerender() reuses the cached state.body. No
80
+ // freddie route currently streams (SSE/live updates) — pages are computed
81
+ // once per nav in loadActive(). If a live route is added (e.g. 'chat' with
82
+ // SSE), recompute its body here before applyDiff, per AGENTS.md:
83
+ // if (state.active === 'chat') { Promise.resolve(PAGES.chat(host0)).then(b => { state.body = b; applyDiff(root, view()); }); return; }
84
+ applyDiff(root, view());
85
+ }
56
86
 
87
+ function setDocTitle(p) {
88
+ const r = ROUTES.find(x => x.path === p);
89
+ document.title = 'freddie · ' + (r ? (r.label || r.path) : p);
90
+ }
91
+ function focusMain() {
92
+ const main = root.querySelector('#app-main');
93
+ if (main) { main.setAttribute('tabindex', '-1'); main.focus({ preventScroll: false }); }
94
+ }
57
95
  function setActive(p) {
58
96
  if (state.active === p) return;
59
97
  state.active = p; state.body = null;
60
98
  const want = '#fd-' + p;
61
99
  if (location.hash !== want) { try { history.replaceState(null, '', want); } catch { location.hash = want; } }
100
+ setDocTitle(p);
62
101
  rerender(); loadActive();
63
102
  }
64
103
  if (typeof window !== 'undefined') {
@@ -77,13 +116,17 @@ async function loadActive() {
77
116
  } catch (e) {
78
117
  if (state.active !== active) return;
79
118
  state.error = String(e && e.stack || e);
119
+ // Reflect the error in the tab title (success path sets it in setActive).
120
+ document.title = 'freddie · ' + active + ' (error)';
80
121
  const { Panel } = components;
81
122
  state.body = Panel({ title: 'page error', children: h('pre', { class: 'fd-pre fd-page-error' }, state.error) });
82
123
  }
83
124
  state.ts = new Date().toLocaleTimeString();
84
125
  applyDiff(root, view());
126
+ focusMain();
85
127
  }
86
128
 
129
+ setDocTitle(state.active);
87
130
  applyDiff(root, view());
88
131
  loadActive();
89
132
 
@@ -4,16 +4,43 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Freddie Dashboard</title>
7
- <link rel="stylesheet" href="/vendor/anentrypoint-design/247420.css">
7
+ <link rel="stylesheet" href="/vendor/anentrypoint-design/sdk.css">
8
8
  <script type="importmap">
9
- { "imports": { "anentrypoint-design": "/vendor/anentrypoint-design/247420.js" } }
9
+ { "imports": { "anentrypoint-design": "/vendor/anentrypoint-design/sdk.js" } }
10
+ </script>
11
+ <!-- Theme: the SDK ThemeToggle controller is the single source of truth and
12
+ persists to <html data-theme> (verified in browser). Pre-paint the saved or
13
+ system theme onto <html> here, BEFORE the module loads, so first paint
14
+ matches the controller and there is no wrong-theme flash. -->
15
+ <script>
16
+ (function () {
17
+ var t;
18
+ try { t = localStorage.getItem('247420:theme'); } catch (e) {}
19
+ if (!t) t = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
20
+ document.documentElement.setAttribute('data-theme', t);
21
+ })();
10
22
  </script>
11
23
  <style>
12
24
  html, body { margin: 0; padding: 0; height: 100%; }
13
25
  #app { height: 100%; }
26
+ /* FOUC guard: hide the document until app.js signals ready (after installStyles)
27
+ so #app never paints raw unstyled 'loading…' text. */
28
+ body { visibility: hidden; }
29
+ body[data-ready] { visibility: visible; }
30
+ /* Cap raw-JSON/code dumps so unbounded payloads scroll instead of blowing
31
+ the page layout. Ideally belongs in the anentrypoint-design SDK CSS. */
32
+ .fd-pre { max-height: 60vh; overflow: auto; }
33
+ /* Topbar leaf layout (sampler pill + theme toggle). One utility class so app.js
34
+ carries no inline style string. Ideally belongs in the SDK CSS. */
35
+ .fd-topbar-leaf { display: inline-flex; gap: 8px; align-items: center; }
36
+ /* Transient mutation-error toast (see app.js notify()). */
37
+ .fd-toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
38
+ z-index: 1000; max-width: 90vw; padding: 8px 14px; border-radius: 8px;
39
+ background: #b3261e; color: #fff; font: 13px/1.4 system-ui, sans-serif;
40
+ box-shadow: 0 4px 16px rgba(0,0,0,.3); }
14
41
  </style>
15
42
  </head>
16
- <body data-theme="light">
43
+ <body>
17
44
  <div id="app"></div>
18
45
  <script type="module" src="./app.js"></script>
19
46
  </body>
package/src/web/server.js CHANGED
@@ -1,10 +1,29 @@
1
1
  import express from 'express'
2
2
  import path from 'node:path'
3
+ import fs from 'node:fs'
3
4
  import { fileURLToPath } from 'node:url'
4
5
  import { bootHost } from '../host/index.js'
5
6
 
6
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
7
8
 
9
+ // Resolve the real hashed SDK bundle filenames (247420.js / 247420.css today).
10
+ // index.html pins stable aliases (sdk.js / sdk.css); we map them to whatever
11
+ // hashed file is actually present so a version bump can't 404 silently.
12
+ function resolveSdkBundle(distDir) {
13
+ let js = '247420.js', css = '247420.css'
14
+ try {
15
+ const files = fs.readdirSync(distDir)
16
+ const jsHit = files.find(f => /^247420.*\.js$/.test(f))
17
+ const cssHit = files.find(f => /^247420.*\.css$/.test(f))
18
+ if (jsHit) js = jsHit
19
+ if (cssHit) css = cssHit
20
+ if (!jsHit || !cssHit) console.error(`[dashboard] SDK bundle missing in ${distDir} (js=${jsHit || 'NONE'}, css=${cssHit || 'NONE'}) — dashboard will fail to mount. Run: node scripts/build.mjs in anentrypoint-design`)
21
+ } catch (e) {
22
+ console.error(`[dashboard] cannot read SDK dist dir ${distDir}: ${e.message}`)
23
+ }
24
+ return { js, css }
25
+ }
26
+
8
27
  export async function createDashboard({ port = 0 } = {}) {
9
28
  const host = await bootHost()
10
29
  // Rehydrate any interrupted machines (agent turns, batches) from their
@@ -12,8 +31,30 @@ export async function createDashboard({ port = 0 } = {}) {
12
31
  try { const { resumeAll } = await import('../machines/resume.js'); await resumeAll() } catch (_) {}
13
32
  const app = express()
14
33
  app.use(express.json())
15
- app.use(express.static(__dirname))
34
+ // Baseline security headers for the local dashboard. No CSP (the SDK uses
35
+ // inline styles/SVG); these are the cheap, no-false-positive defaults.
36
+ app.use((req, res, next) => {
37
+ res.set('X-Content-Type-Options', 'nosniff')
38
+ res.set('X-Frame-Options', 'SAMEORIGIN')
39
+ res.set('Referrer-Policy', 'same-origin')
40
+ next()
41
+ })
16
42
  const fromNodeModules = path.join(__dirname, '..', '..', 'node_modules', 'anentrypoint-design', 'dist')
43
+ const sdk = resolveSdkBundle(fromNodeModules)
44
+
45
+ // Stable aliases -> real hashed bundle. Immutable cache (hashed content).
46
+ const sendImmutable = file => (req, res) => res.set('Cache-Control', 'public, max-age=31536000, immutable').sendFile(path.join(fromNodeModules, file))
47
+ app.get('/vendor/anentrypoint-design/sdk.js', sendImmutable(sdk.js))
48
+ app.get('/vendor/anentrypoint-design/sdk.css', sendImmutable(sdk.css))
49
+
50
+ // index.html / app.js are mutable entry points — never cache.
51
+ app.use((req, res, next) => {
52
+ if (req.method === 'GET' && (req.path === '/' || req.path === '/index.html' || req.path === '/app.js')) {
53
+ res.set('Cache-Control', 'no-cache')
54
+ }
55
+ next()
56
+ })
57
+ app.use(express.static(__dirname))
17
58
  app.use('/vendor/anentrypoint-design', express.static(fromNodeModules))
18
59
  const nmKitsOs = path.join(__dirname, '..', '..', 'node_modules', 'anentrypoint-design', 'src', 'kits', 'os')
19
60
  app.use('/vendor/anentrypoint-design/kits/os', express.static(nmKitsOs))
@@ -23,6 +64,14 @@ export async function createDashboard({ port = 0 } = {}) {
23
64
  }
24
65
  const debugApi = host.gui._state.apis.get('debug')
25
66
  if (debugApi?.attach) debugApi.attach(app)
26
- const { server, actualPort } = await new Promise((res, rej) => { const s = app.listen(port, () => res({ server: s, actualPort: s.address().port })); s.once('error', rej) })
67
+
68
+ // SPA fallback: unknown non-API GET routes serve index.html so deep links
69
+ // (and client-side hash routes) don't return Express's default 404 HTML.
70
+ // /api/* and /vendor/* are excluded — those 404 legitimately as data/assets.
71
+ app.get(/.*/, (req, res, next) => {
72
+ if (req.path.startsWith('/api/') || req.path.startsWith('/vendor/')) return next()
73
+ res.set('Cache-Control', 'no-cache').sendFile(path.join(__dirname, 'index.html'))
74
+ })
75
+ const { server, actualPort } = await new Promise((res, rej) => { const s = app.listen(port, () => { const a = s.address(); res({ server: s, actualPort: a && typeof a === 'object' ? a.port : port }) }); s.once('error', rej) })
27
76
  return { server, port: actualPort, url: `http://127.0.0.1:${actualPort}/`, stop: () => new Promise(r => server.close(() => r())) }
28
77
  }