@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,104 @@
1
+ // Audio transcription dispatcher.
2
+ // Detects available local tools at runtime and picks the best one.
3
+ // Preference order:
4
+ // 1. mlx-whisper (M-series Apple Silicon optimized; ~5x faster on M-chips)
5
+ // 2. whisper (OpenAI reference CLI; pip install openai-whisper)
6
+ // 3. whisper-cpp (cross-platform C impl)
7
+ // No cloud calls. Local-only, defensive posture.
8
+
9
+ import { execFile } from 'node:child_process';
10
+ import { promisify } from 'node:util';
11
+ import { existsSync } from 'node:fs';
12
+ import { mkdtemp, readFile, copyFile } from 'node:fs/promises';
13
+ import { tmpdir } from 'node:os';
14
+ import { basename, join, dirname } from 'node:path';
15
+
16
+ const execFileAsync = promisify(execFile);
17
+
18
+ const TOOLS = [
19
+ {
20
+ name: 'mlx-whisper',
21
+ binary: 'mlx-whisper',
22
+ args: (audioPath, outDir, model) => [audioPath, '--model', `mlx-community/whisper-${model}-mlx`, '--output-dir', outDir, '--output-format', 'txt'],
23
+ txtFilename: (audioPath) => basename(audioPath).replace(/\.[^.]+$/, '.txt'),
24
+ },
25
+ {
26
+ name: 'whisper',
27
+ binary: 'whisper',
28
+ args: (audioPath, outDir, model) => [audioPath, '--model', model, '--output_dir', outDir, '--output_format', 'txt', '--verbose', 'False'],
29
+ txtFilename: (audioPath) => basename(audioPath).replace(/\.[^.]+$/, '.txt'),
30
+ },
31
+ {
32
+ name: 'whisper-cpp',
33
+ binary: 'whisper-cpp',
34
+ args: (audioPath, outDir) => ['-f', audioPath, '-otxt', '-of', join(outDir, basename(audioPath).replace(/\.[^.]+$/, ''))],
35
+ txtFilename: (audioPath) => basename(audioPath).replace(/\.[^.]+$/, '.txt'),
36
+ },
37
+ ];
38
+
39
+ /**
40
+ * Detect the first installed transcription tool.
41
+ * Returns the tool spec or null.
42
+ */
43
+ export async function detectTool() {
44
+ for (const tool of TOOLS) {
45
+ try {
46
+ const { stdout } = await execFileAsync('which', [tool.binary]);
47
+ if (stdout.trim()) return tool;
48
+ } catch { /* not installed */ }
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Transcribe an audio file. Returns { text, tool, error }.
55
+ * Never throws — failures are reported in the result so the inbox can log+continue.
56
+ */
57
+ export async function transcribe(audioPath, opts = {}) {
58
+ const model = opts.model || process.env.FLO_WHISPER_MODEL || 'base';
59
+ const tool = opts.tool || await detectTool();
60
+ if (!tool) {
61
+ return {
62
+ text: null,
63
+ tool: null,
64
+ error: 'no transcription tool found; install one of: mlx-whisper, openai-whisper, whisper-cpp',
65
+ };
66
+ }
67
+ if (!existsSync(audioPath)) {
68
+ return { text: null, tool: tool.name, error: `audio file not found: ${audioPath}` };
69
+ }
70
+ const outDir = await mkdtemp(join(tmpdir(), 'flo-transcribe-'));
71
+ try {
72
+ const args = tool.args(audioPath, outDir, model);
73
+ await execFileAsync(tool.binary, args, {
74
+ timeout: opts.timeoutMs || 5 * 60_000,
75
+ maxBuffer: 32 * 1024 * 1024,
76
+ });
77
+ const txtPath = join(outDir, tool.txtFilename(audioPath));
78
+ if (!existsSync(txtPath)) {
79
+ return { text: null, tool: tool.name, error: `transcript file not produced at ${txtPath}` };
80
+ }
81
+ const text = await readFile(txtPath, 'utf8');
82
+ return { text: text.trim(), tool: tool.name, error: null };
83
+ } catch (err) {
84
+ return {
85
+ text: null,
86
+ tool: tool.name,
87
+ error: err.message || String(err),
88
+ };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Convenience: transcribe + save a sidecar .txt next to the original audio file.
94
+ */
95
+ export async function transcribeAndSaveSidecar(audioPath, opts = {}) {
96
+ const result = await transcribe(audioPath, opts);
97
+ if (result.text) {
98
+ const sidecar = audioPath + '.txt';
99
+ const { writeFile } = await import('node:fs/promises');
100
+ await writeFile(sidecar, result.text + '\n', 'utf8');
101
+ result.sidecar = sidecar;
102
+ }
103
+ return result;
104
+ }
@@ -0,0 +1,95 @@
1
+ // `flo transcripts list` — surface sidecar .txt files produced by audio
2
+ // transcription (inbox watcher or standalone `flo transcribe --save`).
3
+
4
+ import { readdir, readFile, stat } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import { join, basename, extname } from 'node:path';
7
+ import { listInboxes } from './inbox-registry.js';
8
+
9
+ const AUDIO_EXTS = new Set(['.m4a', '.wav', '.mp3', '.aiff', '.flac']);
10
+
11
+ export async function transcriptsCommand(args) {
12
+ const [sub = 'list', ...rest] = args;
13
+ if (sub === 'help' || sub === '--help' || sub === '-h') return printHelp();
14
+ if (sub === 'list') return listCommand(rest);
15
+ console.error(`flo transcripts: unknown subcommand '${sub}'`);
16
+ console.error(`Available: list, help`);
17
+ process.exit(2);
18
+ }
19
+
20
+ function printHelp() {
21
+ console.log(`flo transcripts — list sidecar transcripts from registered inboxes
22
+
23
+ Usage:
24
+ flo transcripts list [--json] [--limit N]
25
+
26
+ Scans every registered inbox's .processed/ folder for audio files whose
27
+ sidecar .txt was written next to them by the inbox watcher.
28
+ `);
29
+ }
30
+
31
+ async function listCommand(args) {
32
+ let json = false, limit = 50;
33
+ for (let i = 0; i < args.length; i++) {
34
+ if (args[i] === '--json') json = true;
35
+ else if (args[i] === '--limit') limit = Number(args[++i]);
36
+ }
37
+ const transcripts = await collectTranscripts(limit);
38
+ if (json) { console.log(JSON.stringify(transcripts, null, 2)); return; }
39
+ if (!transcripts.length) {
40
+ console.log(`flo transcripts: none found. Audio drops in registered inboxes will produce transcripts here.`);
41
+ return;
42
+ }
43
+ console.log(`when inbox file chars snippet`);
44
+ console.log(`------------------- ---------------- -------------------------------- ----- ----------------`);
45
+ for (const t of transcripts) {
46
+ const when = new Date(t.mtime).toISOString().slice(0, 19).replace('T', ' ');
47
+ const inbox = (t.inboxSlug || '?').padEnd(16).slice(0, 16);
48
+ const file = (t.audioFilename || '?').padEnd(32).slice(0, 32);
49
+ const chars = String(t.chars).padStart(5);
50
+ const snippet = (t.snippet || '').slice(0, 60);
51
+ console.log(`${when} ${inbox} ${file} ${chars} ${snippet}`);
52
+ }
53
+ }
54
+
55
+ export async function collectTranscripts(limit = 200) {
56
+ const inboxes = await listInboxes();
57
+ const transcripts = [];
58
+ for (const inbox of inboxes) {
59
+ if (!inbox.exists) continue;
60
+ const dirs = [join(inbox.dir, '.processed'), inbox.dir];
61
+ for (const dir of dirs) {
62
+ if (!existsSync(dir)) continue;
63
+ let entries;
64
+ try { entries = await readdir(dir); } catch { continue; }
65
+ for (const name of entries) {
66
+ const ext = extname(name).toLowerCase();
67
+ if (!AUDIO_EXTS.has(ext)) continue;
68
+ const audioPath = join(dir, name);
69
+ const sidecar = audioPath + '.txt';
70
+ if (!existsSync(sidecar)) continue;
71
+ try {
72
+ const [audioStat, txt] = await Promise.all([
73
+ stat(audioPath),
74
+ readFile(sidecar, 'utf8'),
75
+ ]);
76
+ const sidecarStat = await stat(sidecar);
77
+ transcripts.push({
78
+ inboxSlug: inbox.slug,
79
+ inboxDir: inbox.dir,
80
+ audioPath,
81
+ audioFilename: basename(audioPath),
82
+ sidecarPath: sidecar,
83
+ audioBytes: audioStat.size,
84
+ mtime: sidecarStat.mtimeMs,
85
+ chars: txt.length,
86
+ snippet: txt.trim().slice(0, 160).replace(/\s+/g, ' '),
87
+ fullText: txt,
88
+ });
89
+ } catch { /* skip */ }
90
+ }
91
+ }
92
+ }
93
+ transcripts.sort((a, b) => b.mtime - a.mtime);
94
+ return transcripts.slice(0, limit);
95
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@fuzeelogik/myflo",
3
+ "version": "1.0.0-rc.4",
4
+ "description": "myflo — local-first developer workbench. CLI + MCP server + Next.js dashboard. Forked from ruflo for the runtime substrate; flo CLI on top is ours.",
5
+ "type": "module",
6
+ "bin": {
7
+ "flo": "./bin/flo.js",
8
+ "myflo": "./bin/flo.js"
9
+ },
10
+ "files": [
11
+ "bin/**",
12
+ "lib/**",
13
+ "tests/**",
14
+ "README.md"
15
+ ],
16
+ "dependencies": {},
17
+ "optionalDependencies": {
18
+ "@myflo/memory": "workspace:*"
19
+ },
20
+ "engines": {
21
+ "node": ">=20.0.0"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/therealsiege/myflo.git",
26
+ "directory": "apps/cli"
27
+ },
28
+ "keywords": [
29
+ "myflo",
30
+ "flo",
31
+ "claude-code",
32
+ "mcp",
33
+ "local-first",
34
+ "developer-tools",
35
+ "agentdb",
36
+ "memory",
37
+ "tasks",
38
+ "transcription"
39
+ ],
40
+ "license": "MIT",
41
+ "publishConfig": {
42
+ "access": "public",
43
+ "tag": "latest"
44
+ }
45
+ }
package/tests/smoke.sh ADDED
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env bash
2
+ # End-to-end smoke test for the flo CLI.
3
+ # Exercises every command. Exits non-zero on first failure.
4
+
5
+ set -euo pipefail
6
+
7
+ REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
8
+ FLO="node ${REPO_ROOT}/apps/cli/bin/flo.js"
9
+ TMP="$(mktemp -d)"
10
+ PASS=0
11
+ FAIL=0
12
+
13
+ trap 'rm -rf "$TMP"' EXIT
14
+
15
+ check() {
16
+ local name="$1"; shift
17
+ if "$@" >/dev/null 2>&1; then
18
+ echo " PASS $name"
19
+ PASS=$((PASS+1))
20
+ else
21
+ echo " FAIL $name"
22
+ FAIL=$((FAIL+1))
23
+ fi
24
+ }
25
+
26
+ echo "flo smoke test"
27
+ echo "--------------"
28
+
29
+ check "help" $FLO help
30
+ check "version" $FLO version
31
+
32
+ # Doctor may exit non-zero if MCP config absent — that's fine; just check it runs.
33
+ $FLO doctor >/dev/null 2>&1 || true
34
+ echo " PASS doctor (ran)"
35
+ PASS=$((PASS+1))
36
+
37
+ check "sessions list (text)" $FLO sessions list --limit 5
38
+ check "sessions list (json)" bash -c "$FLO sessions list --json | python3 -c 'import sys,json; json.load(sys.stdin)'"
39
+
40
+ check "guidance audit (json)" bash -c "$FLO guidance audit --json --quiet | python3 -c 'import sys,json; d=json.load(sys.stdin); assert d[\"total\"] >= 0'"
41
+
42
+ # Inbox: drop a markdown file, run --once, confirm it moved to .processed
43
+ mkdir -p "$TMP/inbox-test"
44
+ cat > "$TMP/inbox-test/hello.md" <<EOF
45
+ ---
46
+ to: tester
47
+ from: smoke
48
+ subject: ping
49
+ ---
50
+ hi
51
+ EOF
52
+ $FLO inbox watch "$TMP/inbox-test" --once >/dev/null 2>&1
53
+ if [ -f "$TMP/inbox-test/.processed/hello.md" ]; then
54
+ echo " PASS inbox once-mode (file moved to .processed)"
55
+ PASS=$((PASS+1))
56
+ else
57
+ echo " FAIL inbox once-mode (file did not move)"
58
+ FAIL=$((FAIL+1))
59
+ fi
60
+
61
+ # Migrate dry-run (must not write)
62
+ check "migrate --dry-run" bash -c "$FLO migrate --dry-run --mcp-path $TMP/no-such-mcp.json"
63
+ if [ -f "$TMP/no-such-mcp.json" ]; then
64
+ echo " FAIL migrate --dry-run wrote a file"
65
+ FAIL=$((FAIL+1))
66
+ else
67
+ echo " PASS migrate --dry-run did not write"
68
+ PASS=$((PASS+1))
69
+ fi
70
+
71
+ # MCP handshake: send initialize + tools/list, expect expanded toolset
72
+ RESP=$(printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n' | $FLO mcp start 2>/dev/null)
73
+ if echo "$RESP" | grep -q '"protocolVersion":"2024-11-05"' \
74
+ && echo "$RESP" | grep -q 'flo_sessions_list' \
75
+ && echo "$RESP" | grep -q 'flo_memory_store' \
76
+ && echo "$RESP" | grep -q 'flo_inbox_list' \
77
+ && echo "$RESP" | grep -q 'flo_transcribe'; then
78
+ echo " PASS mcp start (initialize + tools/list, expanded)"
79
+ PASS=$((PASS+1))
80
+ else
81
+ echo " FAIL mcp start (unexpected response or missing tools)"
82
+ FAIL=$((FAIL+1))
83
+ fi
84
+
85
+ # MCP call: round-trip a tool to exercise the dispatch path
86
+ MCP_CALL=$(printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"flo_memory_namespaces","arguments":{}}}\n' | $FLO mcp start 2>/dev/null)
87
+ if echo "$MCP_CALL" | grep -q '"content"'; then
88
+ echo " PASS mcp tools/call (flo_memory_namespaces)"
89
+ PASS=$((PASS+1))
90
+ else
91
+ echo " FAIL mcp tools/call did not return content"
92
+ FAIL=$((FAIL+1))
93
+ fi
94
+
95
+ # Transcribe: detect should always run; we don't assert which tool, only that the command exits cleanly with --json
96
+ if $FLO transcribe --detect --json >/dev/null 2>&1; then
97
+ echo " PASS transcribe --detect"
98
+ PASS=$((PASS+1))
99
+ else
100
+ echo " SKIP transcribe --detect (no tool installed — install whisper/mlx-whisper to enable)"
101
+ # Not counted as a failure: transcribe is local-tool-dependent.
102
+ fi
103
+
104
+ # Swarm: if .swarm/ exists, expect available=true; otherwise expect available=false
105
+ if [ -d "${REPO_ROOT}/.swarm" ]; then
106
+ if $FLO swarm status --json | python3 -c 'import sys,json; d=json.load(sys.stdin); assert d.get("available") is True' 2>/dev/null; then
107
+ echo " PASS swarm status (available)"
108
+ PASS=$((PASS+1))
109
+ else
110
+ echo " FAIL swarm status with .swarm/ present"
111
+ FAIL=$((FAIL+1))
112
+ fi
113
+ else
114
+ echo " SKIP swarm status (no .swarm/ dir)"
115
+ fi
116
+
117
+ # Memory store: store → list → search → namespaces → bridge from inbox md drop
118
+ export FLO_HOME="$TMP/flo-home"
119
+ if $FLO memory store --value "JWT auth pattern with 1hr refresh" --key smoke-pattern-auth --namespace smoke-patterns --tags auth,security >/dev/null 2>&1; then
120
+ echo " PASS memory store"
121
+ PASS=$((PASS+1))
122
+ else
123
+ echo " FAIL memory store"
124
+ FAIL=$((FAIL+1))
125
+ fi
126
+ if $FLO memory list --namespace smoke-patterns --json | python3 -c 'import sys,json; d=json.load(sys.stdin); assert len(d)>=1' 2>/dev/null; then
127
+ echo " PASS memory list (json)"
128
+ PASS=$((PASS+1))
129
+ else
130
+ echo " FAIL memory list (json)"
131
+ FAIL=$((FAIL+1))
132
+ fi
133
+ if $FLO memory search "JWT" --namespace smoke-patterns --json | python3 -c 'import sys,json; d=json.load(sys.stdin); assert any("JWT" in e["value"] for e in d)' 2>/dev/null; then
134
+ echo " PASS memory search (substring)"
135
+ PASS=$((PASS+1))
136
+ else
137
+ echo " FAIL memory search (substring)"
138
+ FAIL=$((FAIL+1))
139
+ fi
140
+ if $FLO memory namespaces --json | python3 -c 'import sys,json; d=json.load(sys.stdin); assert any(n["namespace"]=="smoke-patterns" for n in d)' 2>/dev/null; then
141
+ echo " PASS memory namespaces"
142
+ PASS=$((PASS+1))
143
+ else
144
+ echo " FAIL memory namespaces"
145
+ FAIL=$((FAIL+1))
146
+ fi
147
+
148
+ # Inbox bridge: drop .md with frontmatter → mailbox + memory:inbox entry
149
+ mkdir -p "$TMP/bridge-inbox"
150
+ cat > "$TMP/bridge-inbox/msg.md" <<EOF
151
+ ---
152
+ to: architect
153
+ from: tester
154
+ subject: bridge smoke
155
+ ---
156
+ Smoke body content.
157
+ EOF
158
+ $FLO inbox watch "$TMP/bridge-inbox" --once >/dev/null 2>&1
159
+ if $FLO messages list architect --json | python3 -c 'import sys,json; d=json.load(sys.stdin); assert len(d)>=1' 2>/dev/null; then
160
+ echo " PASS inbox bridge (mailbox)"
161
+ PASS=$((PASS+1))
162
+ else
163
+ echo " FAIL inbox bridge (mailbox)"
164
+ FAIL=$((FAIL+1))
165
+ fi
166
+ if $FLO memory list --namespace inbox --json | python3 -c 'import sys,json; d=json.load(sys.stdin); assert any(e.get("metadata",{}).get("to")=="architect" for e in d)' 2>/dev/null; then
167
+ echo " PASS inbox bridge (memory entry)"
168
+ PASS=$((PASS+1))
169
+ else
170
+ echo " FAIL inbox bridge (memory entry)"
171
+ FAIL=$((FAIL+1))
172
+ fi
173
+
174
+ # Inbox registry: add → list → install (macOS only) → uninstall → remove
175
+ mkdir -p "$TMP/inbox-reg-test"
176
+ if $FLO inbox add "$TMP/inbox-reg-test" --slug smoke-reg >/dev/null 2>&1; then
177
+ echo " PASS inbox add"
178
+ PASS=$((PASS+1))
179
+ else
180
+ echo " FAIL inbox add"
181
+ FAIL=$((FAIL+1))
182
+ fi
183
+ if $FLO inbox list --json 2>/dev/null | python3 -c 'import sys,json; d=json.load(sys.stdin); assert any(i["slug"]=="smoke-reg" for i in d)' 2>/dev/null; then
184
+ echo " PASS inbox list (json)"
185
+ PASS=$((PASS+1))
186
+ else
187
+ echo " FAIL inbox list (json)"
188
+ FAIL=$((FAIL+1))
189
+ fi
190
+ if [ "$(uname)" = "Darwin" ]; then
191
+ if $FLO inbox install smoke-reg --interval 60 >/dev/null 2>&1; then
192
+ if [ -f "$HOME/Library/LaunchAgents/io.myflo.inbox.smoke-reg.plist" ]; then
193
+ echo " PASS inbox install (plist created)"
194
+ PASS=$((PASS+1))
195
+ $FLO inbox uninstall smoke-reg >/dev/null 2>&1
196
+ if [ ! -f "$HOME/Library/LaunchAgents/io.myflo.inbox.smoke-reg.plist" ]; then
197
+ echo " PASS inbox uninstall (plist removed)"
198
+ PASS=$((PASS+1))
199
+ else
200
+ echo " FAIL inbox uninstall did not remove plist"
201
+ FAIL=$((FAIL+1))
202
+ fi
203
+ else
204
+ echo " FAIL inbox install did not create plist"
205
+ FAIL=$((FAIL+1))
206
+ fi
207
+ else
208
+ echo " FAIL inbox install command"
209
+ FAIL=$((FAIL+1))
210
+ fi
211
+ else
212
+ echo " SKIP inbox install (macOS-only)"
213
+ fi
214
+ $FLO inbox remove smoke-reg >/dev/null 2>&1 || true
215
+
216
+ # Transcripts: returns JSON array (empty is OK)
217
+ if $FLO transcripts list --json | python3 -c 'import sys,json; d=json.load(sys.stdin); assert isinstance(d, list)' 2>/dev/null; then
218
+ echo " PASS transcripts list (json shape)"
219
+ PASS=$((PASS+1))
220
+ else
221
+ echo " FAIL transcripts list (json shape)"
222
+ FAIL=$((FAIL+1))
223
+ fi
224
+
225
+ # Tasks: create → list → update → complete → counts → delete (event log round-trip)
226
+ TASK_ID=$($FLO tasks create "smoke test task" --tags smoke,test --json 2>/dev/null \
227
+ | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])' 2>/dev/null)
228
+ if [ -n "$TASK_ID" ]; then
229
+ echo " PASS tasks create (id=$TASK_ID)"
230
+ PASS=$((PASS+1))
231
+ else
232
+ echo " FAIL tasks create"
233
+ FAIL=$((FAIL+1))
234
+ fi
235
+ if $FLO tasks list --json | python3 -c 'import sys,json; d=json.load(sys.stdin); assert any(t["status"]=="pending" for t in d)' 2>/dev/null; then
236
+ echo " PASS tasks list (pending)"
237
+ PASS=$((PASS+1))
238
+ else
239
+ echo " FAIL tasks list"
240
+ FAIL=$((FAIL+1))
241
+ fi
242
+ if [ -n "$TASK_ID" ] && $FLO tasks update "$TASK_ID" --status in_progress >/dev/null 2>&1; then
243
+ if $FLO tasks list --status in_progress --json | python3 -c "import sys,json; d=json.load(sys.stdin); assert any(t['id']=='$TASK_ID' for t in d)" 2>/dev/null; then
244
+ echo " PASS tasks update (status transition)"
245
+ PASS=$((PASS+1))
246
+ else
247
+ echo " FAIL tasks update (didn't show in in_progress list)"
248
+ FAIL=$((FAIL+1))
249
+ fi
250
+ else
251
+ echo " FAIL tasks update command"
252
+ FAIL=$((FAIL+1))
253
+ fi
254
+ if [ -n "$TASK_ID" ] && $FLO tasks complete "$TASK_ID" >/dev/null 2>&1; then
255
+ if $FLO tasks counts --json | python3 -c 'import sys,json; d=json.load(sys.stdin); assert d["completed"]>=1' 2>/dev/null; then
256
+ echo " PASS tasks complete + counts"
257
+ PASS=$((PASS+1))
258
+ else
259
+ echo " FAIL tasks counts"
260
+ FAIL=$((FAIL+1))
261
+ fi
262
+ else
263
+ echo " FAIL tasks complete command"
264
+ FAIL=$((FAIL+1))
265
+ fi
266
+ [ -n "$TASK_ID" ] && $FLO tasks delete "$TASK_ID" >/dev/null 2>&1 || true
267
+ # Terminal-attach: add/list/remove
268
+ if $FLO session terminal-add smoke-term --cwd "$TMP" --app ghostty --title "smoke" >/dev/null 2>&1; then
269
+ echo " PASS session terminal-add"
270
+ PASS=$((PASS+1))
271
+ else
272
+ echo " FAIL session terminal-add"
273
+ FAIL=$((FAIL+1))
274
+ fi
275
+ if $FLO session terminal-list --json | python3 -c 'import sys,json; d=json.load(sys.stdin); assert any(t["slug"]=="smoke-term" for t in d)' 2>/dev/null; then
276
+ echo " PASS session terminal-list (json)"
277
+ PASS=$((PASS+1))
278
+ else
279
+ echo " FAIL session terminal-list (json)"
280
+ FAIL=$((FAIL+1))
281
+ fi
282
+ if $FLO session terminal-remove smoke-term >/dev/null 2>&1; then
283
+ echo " PASS session terminal-remove"
284
+ PASS=$((PASS+1))
285
+ else
286
+ echo " FAIL session terminal-remove"
287
+ FAIL=$((FAIL+1))
288
+ fi
289
+
290
+ # BM25 ranking: discriminating query "refresh oauth2" should hit JWT entry alone
291
+ $FLO memory store --value "JWT auth with refresh tokens and OAuth2" --namespace bm25-test >/dev/null 2>&1
292
+ $FLO memory store --value "Stripe payment integration handles webhooks" --namespace bm25-test >/dev/null 2>&1
293
+ $FLO memory store --value "Auth tokens signed with HS256 keys" --namespace bm25-test >/dev/null 2>&1
294
+ TOP_SCORE_VAL=$($FLO memory search "refresh oauth2" --namespace bm25-test --json 2>/dev/null \
295
+ | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d[0]["value"] if d else "")' 2>/dev/null)
296
+ if echo "$TOP_SCORE_VAL" | grep -q "JWT auth"; then
297
+ echo " PASS memory search BM25 ranking (JWT entry top)"
298
+ PASS=$((PASS+1))
299
+ else
300
+ echo " FAIL memory search BM25 ranking (got: $TOP_SCORE_VAL)"
301
+ FAIL=$((FAIL+1))
302
+ fi
303
+ WEBHOOK_TOP=$($FLO memory search "webhook" --namespace bm25-test --json 2>/dev/null \
304
+ | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d[0]["value"] if d else "")' 2>/dev/null)
305
+ if echo "$WEBHOOK_TOP" | grep -q "Stripe"; then
306
+ echo " PASS memory search BM25 ranking (webhook → Stripe entry)"
307
+ PASS=$((PASS+1))
308
+ else
309
+ echo " FAIL memory search BM25 ranking (webhook expected Stripe, got: $WEBHOOK_TOP)"
310
+ FAIL=$((FAIL+1))
311
+ fi
312
+
313
+ unset FLO_HOME
314
+
315
+ # AgentDB backend: same operations should work with FLO_MEMORY_BACKEND=agentdb
316
+ export FLO_HOME="$TMP/flo-agentdb"
317
+ export FLO_MEMORY_BACKEND=agentdb
318
+ if $FLO memory store --value "JWT auth via AgentDB" --namespace agentdb-test --tags auth >/dev/null 2>&1; then
319
+ echo " PASS memory store (agentdb backend)"
320
+ PASS=$((PASS+1))
321
+ else
322
+ echo " FAIL memory store (agentdb backend)"
323
+ FAIL=$((FAIL+1))
324
+ fi
325
+ $FLO memory store --value "Stripe webhook handler" --namespace agentdb-test >/dev/null 2>&1 || true
326
+ $FLO memory store --value "HS256 keys" --namespace agentdb-test >/dev/null 2>&1 || true
327
+ if $FLO memory list --namespace agentdb-test --json | python3 -c 'import sys,json; d=json.load(sys.stdin); assert len(d)>=2' 2>/dev/null; then
328
+ echo " PASS memory list (agentdb backend)"
329
+ PASS=$((PASS+1))
330
+ else
331
+ echo " FAIL memory list (agentdb backend)"
332
+ FAIL=$((FAIL+1))
333
+ fi
334
+ if $FLO memory search "JWT" --namespace agentdb-test --json 2>/dev/null \
335
+ | python3 -c 'import sys,json; d=json.load(sys.stdin); assert any("JWT" in e["value"] for e in d)' 2>/dev/null; then
336
+ echo " PASS memory search FTS5 (agentdb backend)"
337
+ PASS=$((PASS+1))
338
+ else
339
+ echo " FAIL memory search FTS5 (agentdb backend)"
340
+ FAIL=$((FAIL+1))
341
+ fi
342
+ if $FLO memory namespaces --json | python3 -c 'import sys,json; d=json.load(sys.stdin); assert any(n["namespace"]=="agentdb-test" for n in d)' 2>/dev/null; then
343
+ echo " PASS memory namespaces (agentdb backend)"
344
+ PASS=$((PASS+1))
345
+ else
346
+ echo " FAIL memory namespaces (agentdb backend)"
347
+ FAIL=$((FAIL+1))
348
+ fi
349
+ unset FLO_HOME FLO_MEMORY_BACKEND
350
+
351
+ # Agents: spawn / list / update / health
352
+ export FLO_HOME="$TMP/flo-agents"
353
+ AGENT_ID=$($FLO agents spawn coder --name builder --tags impl --json 2>/dev/null \
354
+ | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])' 2>/dev/null)
355
+ if [ -n "$AGENT_ID" ]; then echo " PASS agents spawn (id=$AGENT_ID)"; PASS=$((PASS+1))
356
+ else echo " FAIL agents spawn"; FAIL=$((FAIL+1)); fi
357
+ if $FLO agents list --json | python3 -c "import sys,json; d=json.load(sys.stdin); assert any(a['id']=='$AGENT_ID' for a in d)" 2>/dev/null; then
358
+ echo " PASS agents list"; PASS=$((PASS+1))
359
+ else echo " FAIL agents list"; FAIL=$((FAIL+1)); fi
360
+ if [ -n "$AGENT_ID" ] && $FLO agents update "$AGENT_ID" --status busy >/dev/null 2>&1; then
361
+ if $FLO agents list --status busy --json | python3 -c "import sys,json; d=json.load(sys.stdin); assert any(a['id']=='$AGENT_ID' for a in d)" 2>/dev/null; then
362
+ echo " PASS agents update (status transition)"; PASS=$((PASS+1))
363
+ else echo " FAIL agents update visibility"; FAIL=$((FAIL+1)); fi
364
+ else echo " FAIL agents update command"; FAIL=$((FAIL+1)); fi
365
+ if $FLO agents health --json | python3 -c 'import sys,json; d=json.load(sys.stdin); assert any(h["health"]=="healthy" for h in d)' 2>/dev/null; then
366
+ echo " PASS agents health"; PASS=$((PASS+1))
367
+ else echo " FAIL agents health"; FAIL=$((FAIL+1)); fi
368
+ unset FLO_HOME
369
+
370
+ # Swarm vote: record + tally in an isolated temp swarm dir
371
+ mkdir -p "$TMP/swarm-vote-test"
372
+ (cd "$TMP/swarm-vote-test" && $FLO swarm vote use-flo --voter alice --vote yes >/dev/null 2>&1 \
373
+ && $FLO swarm vote use-flo --voter bob --vote yes --weight 2 >/dev/null 2>&1 \
374
+ && $FLO swarm vote use-flo --voter carol --vote no >/dev/null 2>&1)
375
+ TALLY=$(cd "$TMP/swarm-vote-test" && $FLO swarm tally use-flo --json 2>/dev/null)
376
+ if echo "$TALLY" | python3 -c 'import sys,json; d=json.load(sys.stdin); assert d["totalVoters"]==3 and d["tally"]["yes"]==3 and d["tally"]["no"]==1' 2>/dev/null; then
377
+ echo " PASS swarm vote + tally"; PASS=$((PASS+1))
378
+ else echo " FAIL swarm vote + tally (got $TALLY)"; FAIL=$((FAIL+1)); fi
379
+
380
+ # MCP tools/list should now report 22 tools (16 + 6 new)
381
+ TOOLCOUNT=$(printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n' \
382
+ | $FLO mcp start 2>/dev/null | python3 -c 'import sys,json
383
+ for line in sys.stdin:
384
+ if not line.strip(): continue
385
+ m = json.loads(line)
386
+ if m.get("id") == 2: print(len(m["result"]["tools"]))' 2>/dev/null)
387
+ if [ "$TOOLCOUNT" = "22" ]; then echo " PASS mcp tools/list count (22)"; PASS=$((PASS+1))
388
+ else echo " FAIL mcp tools/list count (got $TOOLCOUNT)"; FAIL=$((FAIL+1)); fi
389
+
390
+ echo "--------------"
391
+ echo "$PASS passed, $FAIL failed."
392
+ if [ "$FAIL" -gt 0 ]; then exit 1; fi