@fuzeelogik/myflo 1.0.0-rc.4

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,459 @@
1
+ // MCP stdio server exposing flo capabilities as tools.
2
+ // Protocol: https://spec.modelcontextprotocol.io/specification/
3
+
4
+ import { createInterface } from 'node:readline';
5
+ import { readCheckpoints } from './sessions.js';
6
+ import { readSwarmState, recordVote, tallyVotes, listVotes } from './swarm.js';
7
+ import {
8
+ storeEntry,
9
+ listEntries,
10
+ searchEntries,
11
+ namespaceStats,
12
+ } from './memory-store.js';
13
+ import { listInboxes } from './inbox-registry.js';
14
+ import { listAllMailboxes } from './messages.js';
15
+ import { transcribe, detectTool } from './transcribe.js';
16
+ import {
17
+ createTask,
18
+ updateTask,
19
+ completeTask,
20
+ listTasks,
21
+ taskCounts,
22
+ } from './tasks-store.js';
23
+ import {
24
+ spawnAgent,
25
+ listAgents,
26
+ getAgent,
27
+ agentHealth,
28
+ } from './agents-store.js';
29
+
30
+ const PROTOCOL_VERSION = '2024-11-05';
31
+ const SERVER_INFO = { name: 'flo', version: '0.4.0' };
32
+
33
+ const TOOLS = [
34
+ {
35
+ name: 'flo_sessions_list',
36
+ description: 'List Claude Code session checkpoints from .claude/checkpoints/ in the current project.',
37
+ inputSchema: {
38
+ type: 'object',
39
+ properties: { limit: { type: 'number', description: 'Max results (default: 25)' } },
40
+ },
41
+ },
42
+ {
43
+ name: 'flo_guidance_audit',
44
+ description: 'Scan ~/.claude/{skills,commands,agents}/ and project .claude/ for duplicate or undocumented capabilities. Returns markdown report.',
45
+ inputSchema: {
46
+ type: 'object',
47
+ properties: { scope: { type: 'string', enum: ['all', 'user', 'project'] } },
48
+ },
49
+ },
50
+ {
51
+ name: 'flo_memory_store',
52
+ description: 'Append an entry to flo memory (~/.flo/memory/<namespace>.jsonl). Returns the new entry with its id.',
53
+ inputSchema: {
54
+ type: 'object',
55
+ properties: {
56
+ value: { type: 'string', description: 'Entry value (required)' },
57
+ key: { type: 'string', description: 'Optional human-readable key' },
58
+ namespace: { type: 'string', description: 'Namespace (default: "default")' },
59
+ tags: { type: 'array', items: { type: 'string' } },
60
+ metadata: { type: 'object' },
61
+ },
62
+ required: ['value'],
63
+ },
64
+ },
65
+ {
66
+ name: 'flo_memory_search',
67
+ description: 'Substring + tag search across flo memory namespaces. Returns scored matches.',
68
+ inputSchema: {
69
+ type: 'object',
70
+ properties: {
71
+ query: { type: 'string' },
72
+ namespace: { type: 'string' },
73
+ tags: { type: 'array', items: { type: 'string' } },
74
+ limit: { type: 'number' },
75
+ },
76
+ },
77
+ },
78
+ {
79
+ name: 'flo_memory_list',
80
+ description: 'List the most recent entries in a flo memory namespace.',
81
+ inputSchema: {
82
+ type: 'object',
83
+ properties: {
84
+ namespace: { type: 'string', description: 'Namespace (default: "default")' },
85
+ limit: { type: 'number' },
86
+ },
87
+ },
88
+ },
89
+ {
90
+ name: 'flo_memory_namespaces',
91
+ description: 'List all flo memory namespaces with entry counts and last-entry timestamps.',
92
+ inputSchema: { type: 'object', properties: {} },
93
+ },
94
+ {
95
+ name: 'flo_inbox_list',
96
+ description: 'List registered flo inboxes from ~/.flo/inboxes.json with pending/processed/failed counts.',
97
+ inputSchema: { type: 'object', properties: {} },
98
+ },
99
+ {
100
+ name: 'flo_messages_list',
101
+ description: 'List inbox-bridged messages from ~/.flo/messages/<recipient>/. Returns one entry per recipient with their messages.',
102
+ inputSchema: { type: 'object', properties: {} },
103
+ },
104
+ {
105
+ name: 'flo_swarm_status',
106
+ description: 'Read .swarm/state.json and .swarm/q-learning-model.json from the project. Returns swarm objective, agent plan, and q-learning stats.',
107
+ inputSchema: { type: 'object', properties: {} },
108
+ },
109
+ {
110
+ name: 'flo_transcribe',
111
+ description: 'Transcribe a local audio file (m4a/wav/mp3/aiff/flac). Auto-detects mlx-whisper / openai-whisper / whisper-cpp. No cloud calls.',
112
+ inputSchema: {
113
+ type: 'object',
114
+ properties: {
115
+ file: { type: 'string', description: 'Absolute path to audio file' },
116
+ model: { type: 'string', description: 'Whisper model (base/small/medium/large; default: base)' },
117
+ },
118
+ required: ['file'],
119
+ },
120
+ },
121
+ {
122
+ name: 'flo_transcribe_detect',
123
+ description: 'Report which local transcription tool would be used (mlx-whisper / openai-whisper / whisper-cpp).',
124
+ inputSchema: { type: 'object', properties: {} },
125
+ },
126
+ {
127
+ name: 'flo_tasks_create',
128
+ description: 'Create a persistent task in flo. Survives across sessions. Stored in ~/.flo/tasks.jsonl.',
129
+ inputSchema: {
130
+ type: 'object',
131
+ properties: {
132
+ subject: { type: 'string' },
133
+ description: { type: 'string' },
134
+ tags: { type: 'array', items: { type: 'string' } },
135
+ owner: { type: 'string' },
136
+ parent: { type: 'string', description: 'Parent task id for subtasks' },
137
+ status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
138
+ },
139
+ required: ['subject'],
140
+ },
141
+ },
142
+ {
143
+ name: 'flo_tasks_list',
144
+ description: 'List flo tasks. Optional filters: status / owner / tag.',
145
+ inputSchema: {
146
+ type: 'object',
147
+ properties: {
148
+ status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
149
+ owner: { type: 'string' },
150
+ tag: { type: 'string' },
151
+ limit: { type: 'number' },
152
+ },
153
+ },
154
+ },
155
+ {
156
+ name: 'flo_tasks_update',
157
+ description: 'Update a flo task. Can change status, subject, description, tags, owner.',
158
+ inputSchema: {
159
+ type: 'object',
160
+ properties: {
161
+ id: { type: 'string' },
162
+ subject: { type: 'string' },
163
+ description: { type: 'string' },
164
+ tags: { type: 'array', items: { type: 'string' } },
165
+ owner: { type: 'string' },
166
+ status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
167
+ },
168
+ required: ['id'],
169
+ },
170
+ },
171
+ {
172
+ name: 'flo_tasks_complete',
173
+ description: 'Mark a flo task as completed.',
174
+ inputSchema: {
175
+ type: 'object',
176
+ properties: { id: { type: 'string' } },
177
+ required: ['id'],
178
+ },
179
+ },
180
+ {
181
+ name: 'flo_tasks_counts',
182
+ description: 'Get counts of flo tasks by status.',
183
+ inputSchema: { type: 'object', properties: {} },
184
+ },
185
+ {
186
+ name: 'flo_agent_spawn',
187
+ description: 'Register a named agent in flo (~/.flo/agents.jsonl). Does NOT spawn a process — Claude Code\'s Task tool does that. This is a coordination record so multiple agents can discover each other.',
188
+ inputSchema: {
189
+ type: 'object',
190
+ properties: {
191
+ type: { type: 'string', description: 'Agent type (e.g. coder, tester, reviewer)' },
192
+ name: { type: 'string' },
193
+ role: { type: 'string' },
194
+ tags: { type: 'array', items: { type: 'string' } },
195
+ parent: { type: 'string', description: 'Parent agent id' },
196
+ },
197
+ required: ['type'],
198
+ },
199
+ },
200
+ {
201
+ name: 'flo_agent_list',
202
+ description: 'List registered flo agents with optional filters.',
203
+ inputSchema: {
204
+ type: 'object',
205
+ properties: {
206
+ status: { type: 'string', enum: ['idle', 'busy', 'completed', 'failed', 'stopped'] },
207
+ type: { type: 'string' },
208
+ },
209
+ },
210
+ },
211
+ {
212
+ name: 'flo_agent_status',
213
+ description: 'Get a single agent by id.',
214
+ inputSchema: {
215
+ type: 'object',
216
+ properties: { id: { type: 'string' } },
217
+ required: ['id'],
218
+ },
219
+ },
220
+ {
221
+ name: 'flo_agent_health',
222
+ description: 'Heartbeat-age health view of all live (non-stopped) agents.',
223
+ inputSchema: { type: 'object', properties: {} },
224
+ },
225
+ {
226
+ name: 'flo_swarm_vote',
227
+ description: 'Record a weighted vote on a proposal in .swarm/consensus.jsonl. Last vote per voter wins on tally.',
228
+ inputSchema: {
229
+ type: 'object',
230
+ properties: {
231
+ proposal: { type: 'string' },
232
+ voter: { type: 'string' },
233
+ vote: { type: 'string', description: 'yes / no / abstain / any string' },
234
+ weight: { type: 'number' },
235
+ },
236
+ required: ['proposal', 'voter'],
237
+ },
238
+ },
239
+ {
240
+ name: 'flo_swarm_tally',
241
+ description: 'Tally weighted votes on a proposal. Returns total voters, total weight, and per-vote weight.',
242
+ inputSchema: {
243
+ type: 'object',
244
+ properties: { proposal: { type: 'string' } },
245
+ required: ['proposal'],
246
+ },
247
+ },
248
+ ];
249
+
250
+ export async function mcpServe() {
251
+ const rl = createInterface({ input: process.stdin });
252
+ const inFlight = new Set();
253
+ let stdinClosed = false;
254
+
255
+ rl.on('line', (line) => {
256
+ if (!line.trim()) return;
257
+ const task = (async () => {
258
+ let req;
259
+ try { req = JSON.parse(line); }
260
+ catch { return send(null, null, { code: -32700, message: 'parse error' }); }
261
+ try {
262
+ const result = await handle(req);
263
+ if (req.id !== undefined && req.id !== null) {
264
+ send(req.id, result, null);
265
+ }
266
+ } catch (err) {
267
+ send(req?.id ?? null, null, { code: -32603, message: err.message || String(err) });
268
+ }
269
+ })();
270
+ inFlight.add(task);
271
+ task.finally(() => {
272
+ inFlight.delete(task);
273
+ if (stdinClosed && inFlight.size === 0) process.exit(0);
274
+ });
275
+ });
276
+
277
+ rl.on('close', () => {
278
+ stdinClosed = true;
279
+ if (inFlight.size === 0) process.exit(0);
280
+ });
281
+ }
282
+
283
+ async function handle(req) {
284
+ switch (req.method) {
285
+ case 'initialize':
286
+ return {
287
+ protocolVersion: PROTOCOL_VERSION,
288
+ capabilities: { tools: {} },
289
+ serverInfo: SERVER_INFO,
290
+ };
291
+ case 'notifications/initialized':
292
+ case 'notifications/cancelled':
293
+ return null;
294
+ case 'tools/list':
295
+ return { tools: TOOLS };
296
+ case 'tools/call':
297
+ return await callTool(req.params);
298
+ case 'ping':
299
+ return {};
300
+ default:
301
+ throw new Error(`method not implemented: ${req.method}`);
302
+ }
303
+ }
304
+
305
+ async function callTool({ name, arguments: args = {} }) {
306
+ switch (name) {
307
+ case 'flo_sessions_list': {
308
+ const records = await readCheckpoints(undefined, typeof args.limit === 'number' ? args.limit : 25);
309
+ return textResult(JSON.stringify(records, null, 2));
310
+ }
311
+ case 'flo_guidance_audit': {
312
+ const { runAuditJson } = await loadAuditRunner();
313
+ const json = await runAuditJson({ scope: args.scope || 'all' });
314
+ return textResult(json);
315
+ }
316
+ case 'flo_memory_store': {
317
+ if (!args.value) throw new Error('flo_memory_store: value is required');
318
+ const entry = await storeEntry({
319
+ namespace: args.namespace,
320
+ key: args.key,
321
+ value: args.value,
322
+ tags: args.tags,
323
+ metadata: args.metadata,
324
+ });
325
+ return textResult(JSON.stringify(entry, null, 2));
326
+ }
327
+ case 'flo_memory_search': {
328
+ const results = await searchEntries({
329
+ namespace: args.namespace,
330
+ query: args.query || '',
331
+ tags: args.tags || [],
332
+ limit: typeof args.limit === 'number' ? args.limit : 20,
333
+ });
334
+ return textResult(JSON.stringify(results, null, 2));
335
+ }
336
+ case 'flo_memory_list': {
337
+ const entries = await listEntries({
338
+ namespace: args.namespace || 'default',
339
+ limit: typeof args.limit === 'number' ? args.limit : 50,
340
+ });
341
+ return textResult(JSON.stringify(entries, null, 2));
342
+ }
343
+ case 'flo_memory_namespaces': {
344
+ const stats = await namespaceStats();
345
+ return textResult(JSON.stringify(stats, null, 2));
346
+ }
347
+ case 'flo_inbox_list': {
348
+ const list = await listInboxes();
349
+ return textResult(JSON.stringify(list, null, 2));
350
+ }
351
+ case 'flo_messages_list': {
352
+ const list = await listAllMailboxes();
353
+ return textResult(JSON.stringify(list, null, 2));
354
+ }
355
+ case 'flo_swarm_status': {
356
+ const state = await readSwarmState();
357
+ return textResult(JSON.stringify(state, null, 2));
358
+ }
359
+ case 'flo_transcribe': {
360
+ if (!args.file) throw new Error('flo_transcribe: file is required');
361
+ const result = await transcribe(args.file, { model: args.model });
362
+ return textResult(JSON.stringify(result, null, 2));
363
+ }
364
+ case 'flo_transcribe_detect': {
365
+ const tool = await detectTool();
366
+ return textResult(JSON.stringify({ tool: tool?.name || null, binary: tool?.binary || null }));
367
+ }
368
+ case 'flo_tasks_create': {
369
+ if (!args.subject) throw new Error('flo_tasks_create: subject is required');
370
+ const task = await createTask(args);
371
+ return textResult(JSON.stringify(task, null, 2));
372
+ }
373
+ case 'flo_tasks_list': {
374
+ const tasks = await listTasks({
375
+ status: args.status,
376
+ owner: args.owner,
377
+ tag: args.tag,
378
+ limit: typeof args.limit === 'number' ? args.limit : 100,
379
+ });
380
+ return textResult(JSON.stringify(tasks, null, 2));
381
+ }
382
+ case 'flo_tasks_update': {
383
+ if (!args.id) throw new Error('flo_tasks_update: id is required');
384
+ const task = await updateTask(args);
385
+ return textResult(JSON.stringify(task, null, 2));
386
+ }
387
+ case 'flo_tasks_complete': {
388
+ if (!args.id) throw new Error('flo_tasks_complete: id is required');
389
+ const task = await completeTask(args.id);
390
+ return textResult(JSON.stringify(task, null, 2));
391
+ }
392
+ case 'flo_tasks_counts': {
393
+ const counts = await taskCounts();
394
+ return textResult(JSON.stringify(counts, null, 2));
395
+ }
396
+ case 'flo_agent_spawn': {
397
+ if (!args.type) throw new Error('flo_agent_spawn: type is required');
398
+ const agent = await spawnAgent(args);
399
+ return textResult(JSON.stringify(agent, null, 2));
400
+ }
401
+ case 'flo_agent_list': {
402
+ const agents = await listAgents({ status: args.status, type: args.type });
403
+ return textResult(JSON.stringify(agents, null, 2));
404
+ }
405
+ case 'flo_agent_status': {
406
+ if (!args.id) throw new Error('flo_agent_status: id is required');
407
+ const agent = await getAgent(args.id);
408
+ return textResult(JSON.stringify(agent, null, 2));
409
+ }
410
+ case 'flo_agent_health': {
411
+ const health = await agentHealth();
412
+ return textResult(JSON.stringify(health, null, 2));
413
+ }
414
+ case 'flo_swarm_vote': {
415
+ if (!args.proposal) throw new Error('flo_swarm_vote: proposal is required');
416
+ if (!args.voter) throw new Error('flo_swarm_vote: voter is required');
417
+ const event = await recordVote(args);
418
+ return textResult(JSON.stringify(event, null, 2));
419
+ }
420
+ case 'flo_swarm_tally': {
421
+ if (!args.proposal) throw new Error('flo_swarm_tally: proposal is required');
422
+ const result = await tallyVotes({ proposal: args.proposal });
423
+ return textResult(JSON.stringify(result, null, 2));
424
+ }
425
+ default:
426
+ throw new Error(`unknown tool: ${name}`);
427
+ }
428
+ }
429
+
430
+ function textResult(text) {
431
+ return { content: [{ type: 'text', text }] };
432
+ }
433
+
434
+ async function loadAuditRunner() {
435
+ // Spawn ourselves with `guidance audit --json --quiet` to reuse the markdown
436
+ // renderer side. Uses execFile with an argument array (no shell).
437
+ const { execFile } = await import('node:child_process');
438
+ const { promisify } = await import('node:util');
439
+ const execFileAsync = promisify(execFile);
440
+ return {
441
+ async runAuditJson({ scope }) {
442
+ const binPath = new URL('../bin/flo.js', import.meta.url).pathname;
443
+ const args = ['guidance', 'audit', '--json', '--quiet'];
444
+ if (scope && scope !== 'all') args.push('--scope', scope);
445
+ const { stdout } = await execFileAsync(process.execPath, [binPath, ...args], {
446
+ maxBuffer: 16 * 1024 * 1024,
447
+ timeout: 30_000,
448
+ });
449
+ return stdout;
450
+ },
451
+ };
452
+ }
453
+
454
+ function send(id, result, error) {
455
+ const msg = { jsonrpc: '2.0', id };
456
+ if (error) msg.error = error;
457
+ else msg.result = result;
458
+ process.stdout.write(JSON.stringify(msg) + '\n');
459
+ }
@@ -0,0 +1,240 @@
1
+ // AgentDB-backed memory adapter. Wraps @myflo/memory's UnifiedMemoryService
2
+ // + SqlJsBackend to expose the same API as memory-store.js's JSONL backend:
3
+ // storeEntry, searchEntries, listEntries, getEntry, deleteEntry,
4
+ // listNamespaces, namespaceStats
5
+ //
6
+ // Backend: SqlJsBackend (pure WASM SQLite, no native deps — works on any
7
+ // Node 20+ runtime). Vector search is degraded gracefully when no embedding
8
+ // generator is configured (returns FTS5 / keyword results only).
9
+
10
+ import { mkdir } from 'node:fs/promises';
11
+ import { existsSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { randomBytes } from 'node:crypto';
15
+
16
+ const FLO_HOME = process.env.FLO_HOME || join(homedir(), '.flo');
17
+ const DB_PATH = join(FLO_HOME, 'agentdb.sqlite');
18
+
19
+ let _backend = null;
20
+
21
+ async function locateSqlJsWasm() {
22
+ // sql.js ships its WASM as a file. By default it tries to fetch from
23
+ // sql.js.org which fails offline. Point it at the bundled file.
24
+ // sql.js is a transitive dep of @myflo/memory, so we resolve via that
25
+ // package's location (createRequire from the memory module URL).
26
+ const { createRequire } = await import('node:module');
27
+ try {
28
+ // Resolve @myflo/memory's package.json to find its location
29
+ const memoryPkgUrl = import.meta.resolve('@myflo/memory/package.json');
30
+ const req = createRequire(memoryPkgUrl);
31
+ const sqlJsMain = req.resolve('sql.js');
32
+ return sqlJsMain.replace(/sql-wasm\.js$/, 'sql-wasm.wasm');
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ async function getBackend() {
39
+ if (_backend) return _backend;
40
+ if (!existsSync(FLO_HOME)) await mkdir(FLO_HOME, { recursive: true });
41
+ // Dynamic import keeps the heavy module out of the load path for the JSONL
42
+ // codepath. @myflo/memory is an optionalDependency — when missing (e.g. a
43
+ // bare `npm install myflo` without the optional extras), throw a clear error
44
+ // so the caller knows to either install the optional dep or stick with the
45
+ // jsonl backend.
46
+ let SqlJsBackend;
47
+ try {
48
+ ({ SqlJsBackend } = await import('@myflo/memory'));
49
+ } catch (err) {
50
+ throw new Error(
51
+ `agentdb backend requires @myflo/memory (optional dependency). ` +
52
+ `Install it: npm i @myflo/memory. ` +
53
+ `Or stick with the default jsonl backend (unset FLO_MEMORY_BACKEND). ` +
54
+ `Underlying error: ${err.message}`
55
+ );
56
+ }
57
+ const wasmPath = await locateSqlJsWasm();
58
+ // autoPersistInterval: 0 disables the setInterval that holds the Node event
59
+ // loop open. We persist explicitly after each write so a one-shot CLI invocation
60
+ // can exit cleanly.
61
+ _backend = new SqlJsBackend({ databasePath: DB_PATH, wasmPath, autoPersistInterval: 0 });
62
+ await _backend.initialize();
63
+ return _backend;
64
+ }
65
+
66
+ function sanitizeNs(ns) {
67
+ return String(ns || 'default').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64) || 'default';
68
+ }
69
+
70
+ function newId() {
71
+ return `${Date.now()}-${randomBytes(3).toString('hex')}`;
72
+ }
73
+
74
+ // Map flo-shape entry to @myflo/memory MemoryEntry. SqlJsBackend binds every
75
+ // field via positional ?-params, so undefined is fatal — we provide every
76
+ // expected field with a sensible default.
77
+ function toBackendEntry({ namespace = 'default', key, value, tags = [], metadata = {} }) {
78
+ const id = newId();
79
+ const ns = sanitizeNs(namespace);
80
+ const now = Date.now();
81
+ return {
82
+ id,
83
+ key: key || id,
84
+ content: String(value ?? ''),
85
+ type: 'semantic',
86
+ namespace: ns,
87
+ tags: Array.isArray(tags) ? tags.map(String) : [],
88
+ metadata: metadata && typeof metadata === 'object' ? metadata : {},
89
+ ownerId: null,
90
+ accessLevel: 'private',
91
+ createdAt: now,
92
+ updatedAt: now,
93
+ expiresAt: null,
94
+ version: 1,
95
+ references: [],
96
+ accessCount: 0,
97
+ lastAccessedAt: now,
98
+ };
99
+ }
100
+
101
+ // Map backend MemoryEntry to the shape callers expect
102
+ function fromBackendEntry(entry) {
103
+ if (!entry) return null;
104
+ return {
105
+ id: entry.id,
106
+ namespace: entry.namespace,
107
+ key: entry.key && entry.key !== entry.id ? entry.key : null,
108
+ value: entry.content || '',
109
+ tags: entry.tags || [],
110
+ metadata: entry.metadata || {},
111
+ createdAt: entry.createdAt
112
+ ? new Date(typeof entry.createdAt === 'number' ? entry.createdAt : Date.parse(entry.createdAt)).toISOString()
113
+ : new Date().toISOString(),
114
+ deleted: false,
115
+ };
116
+ }
117
+
118
+ // Public API mirrors memory-store.js (JSONL backend)
119
+
120
+ export async function storeEntry(input) {
121
+ const backend = await getBackend();
122
+ const entry = toBackendEntry(input);
123
+ await backend.store(entry);
124
+ await backend.persist();
125
+ return fromBackendEntry(entry);
126
+ }
127
+
128
+ export async function deleteEntry({ id }) {
129
+ if (!id) return;
130
+ const backend = await getBackend();
131
+ await backend.delete(id);
132
+ await backend.persist();
133
+ }
134
+
135
+ export async function getEntry({ namespace = 'default', id, key }) {
136
+ const backend = await getBackend();
137
+ if (id) {
138
+ const entry = await backend.get(id);
139
+ return fromBackendEntry(entry);
140
+ }
141
+ if (key) {
142
+ const entry = await backend.getByKey(sanitizeNs(namespace), key);
143
+ return fromBackendEntry(entry);
144
+ }
145
+ return null;
146
+ }
147
+
148
+ export async function listEntries({ namespace = 'default', limit = 50 } = {}) {
149
+ const backend = await getBackend();
150
+ const entries = await backend.query({
151
+ namespace: sanitizeNs(namespace),
152
+ limit,
153
+ });
154
+ return entries
155
+ .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
156
+ .slice(0, limit)
157
+ .map(fromBackendEntry);
158
+ }
159
+
160
+ export async function searchEntries({ namespace, query = '', tags = [], limit = 20 }) {
161
+ const backend = await getBackend();
162
+ const ns = namespace ? sanitizeNs(namespace) : undefined;
163
+ const wantTags = new Set(tags.map((t) => String(t).toLowerCase()));
164
+
165
+ // Use FTS5 keyword search when a query string is provided (real BM25 ranking
166
+ // from SQLite). Without a query, fall back to a namespace scan + tag filter.
167
+ let candidates = [];
168
+ if (query) {
169
+ try {
170
+ // SqlJsBackend.searchKeyword returns SearchResult[] (entry + score)
171
+ const searchResults = await backend.searchKeyword(query, Math.max(limit * 3, 50));
172
+ candidates = searchResults
173
+ .map((r) => ({ entry: r.entry || r, score: r.score ?? 1 }))
174
+ .filter(({ entry }) => !ns || entry.namespace === ns);
175
+ } catch {
176
+ // FTS5 not available in this sql.js build — fall through to scan
177
+ }
178
+ }
179
+
180
+ if (candidates.length === 0) {
181
+ const scan = await backend.query({ namespace: ns, limit: 1000 });
182
+ const q = String(query || '').toLowerCase();
183
+ candidates = scan
184
+ .map((entry) => {
185
+ const tagSet = new Set((entry.tags || []).map((t) => String(t).toLowerCase()));
186
+ const tagOverlap = [...wantTags].filter((t) => tagSet.has(t)).length;
187
+ const haystack = `${entry.key || ''} ${entry.content || ''}`.toLowerCase();
188
+ let score = tagOverlap * 2;
189
+ if (q && haystack.includes(q)) score += 1 + Math.min(haystack.split(q).length - 1, 4);
190
+ if (q) {
191
+ const terms = q.split(/\s+/).filter(Boolean);
192
+ if (terms.length > 1) {
193
+ const matched = terms.filter((t) => haystack.includes(t)).length;
194
+ score += matched;
195
+ }
196
+ }
197
+ if (score <= 0 && !wantTags.size) return null;
198
+ if (wantTags.size && tagOverlap === 0 && !q) return null;
199
+ return { entry, score };
200
+ })
201
+ .filter(Boolean);
202
+ }
203
+
204
+ // Apply tag boost (post-FTS) and re-sort
205
+ for (const c of candidates) {
206
+ const tagSet = new Set((c.entry.tags || []).map((t) => String(t).toLowerCase()));
207
+ const tagOverlap = [...wantTags].filter((t) => tagSet.has(t)).length;
208
+ if (tagOverlap > 0) c.score += 2 + tagOverlap * 0.5;
209
+ }
210
+ return candidates
211
+ .sort((a, b) => b.score - a.score)
212
+ .slice(0, limit)
213
+ .map(({ entry, score }) => ({ ...fromBackendEntry(entry), _score: Number(score.toFixed(4)) }));
214
+ }
215
+
216
+ export async function listNamespaces() {
217
+ const backend = await getBackend();
218
+ return await backend.listNamespaces();
219
+ }
220
+
221
+ export async function namespaceStats() {
222
+ const backend = await getBackend();
223
+ const namespaces = await backend.listNamespaces();
224
+ const out = [];
225
+ for (const ns of namespaces) {
226
+ const count = await backend.count(ns);
227
+ // Fetch most recent entry in this namespace for lastEntryAt
228
+ const latest = await backend.query({ namespace: ns, limit: 1 });
229
+ const lastTs = latest[0]?.createdAt || null;
230
+ out.push({
231
+ namespace: ns,
232
+ count,
233
+ lastEntryAt: lastTs ? new Date(lastTs).toISOString() : null,
234
+ });
235
+ }
236
+ out.sort((a, b) => (a.lastEntryAt && b.lastEntryAt ? (a.lastEntryAt < b.lastEntryAt ? 1 : -1) : 0));
237
+ return out;
238
+ }
239
+
240
+ export const _internal = { FLO_HOME, DB_PATH, getBackend };