@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.
- package/README.md +103 -0
- package/bin/flo.js +8 -0
- package/lib/activity.js +243 -0
- package/lib/agents-cmd.js +173 -0
- package/lib/agents-store.js +153 -0
- package/lib/completions.js +120 -0
- package/lib/doctor.js +101 -0
- package/lib/edit-cmd.js +136 -0
- package/lib/export.js +182 -0
- package/lib/guidance-audit.js +215 -0
- package/lib/help.js +49 -0
- package/lib/hook-cmd.js +129 -0
- package/lib/inbox-install.js +111 -0
- package/lib/inbox-registry.js +122 -0
- package/lib/inbox.js +320 -0
- package/lib/log-cmd.js +82 -0
- package/lib/main.js +97 -0
- package/lib/mcp-server.js +459 -0
- package/lib/memory-backend-agentdb.js +240 -0
- package/lib/memory-cmd.js +148 -0
- package/lib/memory-store.js +258 -0
- package/lib/messages.js +119 -0
- package/lib/migrate.js +88 -0
- package/lib/notes-cmd.js +110 -0
- package/lib/replace-ruflo.js +133 -0
- package/lib/sessions.js +82 -0
- package/lib/setup.js +93 -0
- package/lib/swarm.js +236 -0
- package/lib/tasks-cmd.js +160 -0
- package/lib/tasks-store.js +152 -0
- package/lib/terminal-attach.js +281 -0
- package/lib/transcribe-cmd.js +75 -0
- package/lib/transcribe.js +104 -0
- package/lib/transcripts.js +95 -0
- package/package.json +45 -0
- package/tests/smoke.sh +392 -0
package/lib/notes-cmd.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// `flo notes` — quick capture wrapper over memory store. Defaults to the
|
|
2
|
+
// `notes` namespace and auto-derives tags from #hashtags and trailing
|
|
3
|
+
// "tags: a,b,c" lines so notes stay searchable without ceremony.
|
|
4
|
+
|
|
5
|
+
import { storeEntry, listEntries, searchEntries } from './memory-store.js';
|
|
6
|
+
|
|
7
|
+
export async function notesCommand(args) {
|
|
8
|
+
const [sub = 'help', ...rest] = args;
|
|
9
|
+
if (sub === 'help' || sub === '--help' || sub === '-h') return printHelp();
|
|
10
|
+
if (sub === 'list') return listCmd(rest);
|
|
11
|
+
if (sub === 'search') return searchCmd(rest);
|
|
12
|
+
// Default: treat all args as note text
|
|
13
|
+
return addCmd(args);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function printHelp() {
|
|
17
|
+
console.log(`flo notes — quick markdown capture wrapped over flo memory
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
flo notes <text> Capture a note in ~/.flo/memory/notes.jsonl
|
|
21
|
+
flo notes "Body with #hashtag and trailing tags:"
|
|
22
|
+
flo notes list [--limit N] [--json]
|
|
23
|
+
flo notes search <query> [--limit N] [--json]
|
|
24
|
+
|
|
25
|
+
Auto-tagging:
|
|
26
|
+
- Inline #hashtags are extracted as tags
|
|
27
|
+
- Trailing 'tags: a, b, c' line is parsed and merged
|
|
28
|
+
- Always tagged 'note'
|
|
29
|
+
|
|
30
|
+
Notes live in the 'notes' namespace; cross-namespace search via 'flo memory'.
|
|
31
|
+
`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseFlags(args) {
|
|
35
|
+
const out = { positional: [] };
|
|
36
|
+
for (let i = 0; i < args.length; i++) {
|
|
37
|
+
const a = args[i];
|
|
38
|
+
if (a === '--limit') out.limit = Number(args[++i]);
|
|
39
|
+
else if (a === '--json') out.json = true;
|
|
40
|
+
else if (a === '--key') out.key = args[++i];
|
|
41
|
+
else if (a === '--tags') out.extraTags = (args[++i] || '').split(',').map(t => t.trim()).filter(Boolean);
|
|
42
|
+
else if (!a.startsWith('--')) out.positional.push(a);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function deriveTags(text) {
|
|
48
|
+
const tags = new Set(['note']);
|
|
49
|
+
// #hashtags
|
|
50
|
+
for (const m of text.matchAll(/#([a-zA-Z0-9_-]+)/g)) {
|
|
51
|
+
tags.add(m[1].toLowerCase());
|
|
52
|
+
}
|
|
53
|
+
// trailing "tags: a, b" line
|
|
54
|
+
const trailing = text.match(/^tags:\s*(.+)$/im);
|
|
55
|
+
if (trailing) {
|
|
56
|
+
for (const t of trailing[1].split(',')) {
|
|
57
|
+
const v = t.trim().replace(/^["']|["']$/g, '');
|
|
58
|
+
if (v) tags.add(v.toLowerCase());
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...tags];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function addCmd(args) {
|
|
65
|
+
const opts = parseFlags(args);
|
|
66
|
+
const text = opts.positional.join(' ').trim();
|
|
67
|
+
if (!text) {
|
|
68
|
+
console.error(`flo notes: missing <text>`);
|
|
69
|
+
console.error(`Usage: flo notes "<text>" (use 'flo notes help' for more)`);
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
const tags = deriveTags(text);
|
|
73
|
+
if (opts.extraTags) for (const t of opts.extraTags) if (!tags.includes(t)) tags.push(t);
|
|
74
|
+
const entry = await storeEntry({
|
|
75
|
+
namespace: 'notes',
|
|
76
|
+
key: opts.key || null,
|
|
77
|
+
value: text,
|
|
78
|
+
tags,
|
|
79
|
+
metadata: { capturedAt: new Date().toISOString() },
|
|
80
|
+
});
|
|
81
|
+
if (opts.json) console.log(JSON.stringify(entry));
|
|
82
|
+
else console.log(`flo notes: ${entry.id} (${text.length} chars, tags: ${tags.join(', ')})`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function listCmd(args) {
|
|
86
|
+
const opts = parseFlags(args);
|
|
87
|
+
const entries = await listEntries({ namespace: 'notes', limit: opts.limit || 50 });
|
|
88
|
+
if (opts.json) { console.log(JSON.stringify(entries, null, 2)); return; }
|
|
89
|
+
if (!entries.length) { console.log(`flo notes: nothing yet`); return; }
|
|
90
|
+
for (const e of entries) {
|
|
91
|
+
const head = e.value.split('\n')[0].slice(0, 70);
|
|
92
|
+
console.log(`${e.createdAt.slice(0, 19).replace('T', ' ')} ${e.id} ${head}${e.value.length > 70 ? '…' : ''}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function searchCmd(args) {
|
|
97
|
+
const opts = parseFlags(args);
|
|
98
|
+
const query = opts.positional.join(' ');
|
|
99
|
+
if (!query) {
|
|
100
|
+
console.error(`flo notes search: missing query`);
|
|
101
|
+
process.exit(2);
|
|
102
|
+
}
|
|
103
|
+
const results = await searchEntries({ namespace: 'notes', query, limit: opts.limit || 20 });
|
|
104
|
+
if (opts.json) { console.log(JSON.stringify(results, null, 2)); return; }
|
|
105
|
+
if (!results.length) { console.log(`flo notes search: 0 results`); return; }
|
|
106
|
+
for (const r of results) {
|
|
107
|
+
const head = r.value.split('\n')[0].slice(0, 70);
|
|
108
|
+
console.log(`[${r._score}] ${r.id} ${head}${r.value.length > 70 ? '…' : ''}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// `flo replace ruflo` — cutover script.
|
|
2
|
+
// Removes ruflo / claude-flow entries from ~/.claude/mcp.json and project
|
|
3
|
+
// .claude/settings.json mcpServers blocks, leaving only flo. Idempotent.
|
|
4
|
+
// Backs up both files before writing.
|
|
5
|
+
|
|
6
|
+
import { readFile, writeFile, copyFile, mkdir } from 'node:fs/promises';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const USER_MCP = join(homedir(), '.claude', 'mcp.json');
|
|
12
|
+
const PROJECT_SETTINGS = join(process.cwd(), '.claude', 'settings.json');
|
|
13
|
+
|
|
14
|
+
const RUFLO_KEYS = ['ruflo', 'claude-flow'];
|
|
15
|
+
|
|
16
|
+
export async function replaceCommand(args) {
|
|
17
|
+
const opts = parseFlags(args);
|
|
18
|
+
if (opts.help) return printHelp();
|
|
19
|
+
if (opts.positional[0] && opts.positional[0] !== 'ruflo') {
|
|
20
|
+
console.error(`flo replace: only 'ruflo' is supported (got '${opts.positional[0]}')`);
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.log(`flo replace ruflo — removing ruflo / claude-flow MCP entries`);
|
|
25
|
+
console.log('');
|
|
26
|
+
|
|
27
|
+
const targets = [
|
|
28
|
+
{ label: 'user-global', path: USER_MCP },
|
|
29
|
+
{ label: 'project', path: PROJECT_SETTINGS },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
for (const { label, path } of targets) {
|
|
33
|
+
if (!existsSync(path)) {
|
|
34
|
+
console.log(`- ${label}: ${path} not present, skipping`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
let raw, parsed;
|
|
38
|
+
try {
|
|
39
|
+
raw = await readFile(path, 'utf8');
|
|
40
|
+
parsed = JSON.parse(raw);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.log(`× ${label}: failed to parse ${path}: ${err.message}`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const result = removeRufloFrom(parsed);
|
|
46
|
+
if (!result.changed) {
|
|
47
|
+
console.log(`= ${label}: no ruflo entries found in ${path}`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (opts.dryRun) {
|
|
51
|
+
console.log(`# ${label}: would remove keys ${result.removed.join(', ')} from ${path}`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const backup = `${path}.flo-bak.${Date.now()}`;
|
|
55
|
+
await copyFile(path, backup);
|
|
56
|
+
await writeFile(path, JSON.stringify(parsed, null, 2) + '\n', 'utf8');
|
|
57
|
+
console.log(`✓ ${label}: removed [${result.removed.join(', ')}] from ${path} (backup: ${backup})`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log('');
|
|
61
|
+
console.log('Done. Restart Claude Code to pick up the change.');
|
|
62
|
+
console.log("If you need to roll back, the .flo-bak.* file is your snapshot.");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function removeRufloFrom(obj) {
|
|
66
|
+
const removed = [];
|
|
67
|
+
// mcpServers block
|
|
68
|
+
if (obj.mcpServers && typeof obj.mcpServers === 'object') {
|
|
69
|
+
for (const k of Object.keys(obj.mcpServers)) {
|
|
70
|
+
const val = obj.mcpServers[k];
|
|
71
|
+
const args = (val && val.args) ? val.args.join(' ') : '';
|
|
72
|
+
const cmd = val ? `${val.command || ''} ${args}` : '';
|
|
73
|
+
if (RUFLO_KEYS.includes(k) || /ruflo|claude-flow/.test(cmd)) {
|
|
74
|
+
delete obj.mcpServers[k];
|
|
75
|
+
removed.push(`mcpServers.${k}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// enabledMcpjsonServers array
|
|
80
|
+
if (Array.isArray(obj.enabledMcpjsonServers)) {
|
|
81
|
+
const before = obj.enabledMcpjsonServers.length;
|
|
82
|
+
obj.enabledMcpjsonServers = obj.enabledMcpjsonServers.filter((s) => !RUFLO_KEYS.includes(s));
|
|
83
|
+
if (obj.enabledMcpjsonServers.length < before) {
|
|
84
|
+
removed.push(`enabledMcpjsonServers (${before - obj.enabledMcpjsonServers.length} entries)`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// permissions.allow — strip mcp__claude-flow__* / mcp__ruflo__*
|
|
88
|
+
if (obj.permissions && Array.isArray(obj.permissions.allow)) {
|
|
89
|
+
const before = obj.permissions.allow.length;
|
|
90
|
+
obj.permissions.allow = obj.permissions.allow.filter(
|
|
91
|
+
(entry) => !/mcp__(claude-flow|ruflo)/.test(entry)
|
|
92
|
+
);
|
|
93
|
+
if (obj.permissions.allow.length < before) {
|
|
94
|
+
removed.push(`permissions.allow (${before - obj.permissions.allow.length} entries)`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { changed: removed.length > 0, removed };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function printHelp() {
|
|
101
|
+
console.log(`flo replace ruflo — cutover from ruflo MCP server to flo
|
|
102
|
+
|
|
103
|
+
Usage:
|
|
104
|
+
flo replace ruflo [--dry-run]
|
|
105
|
+
|
|
106
|
+
Removes ruflo / claude-flow entries from:
|
|
107
|
+
~/.claude/mcp.json (user-global MCP servers)
|
|
108
|
+
./.claude/settings.json (project mcpServers + permissions)
|
|
109
|
+
|
|
110
|
+
Specifically removes:
|
|
111
|
+
- mcpServers.{ruflo,claude-flow}
|
|
112
|
+
- mcpServers.* whose command/args contain 'ruflo' or 'claude-flow'
|
|
113
|
+
- enabledMcpjsonServers entries that match
|
|
114
|
+
- permissions.allow entries matching 'mcp__ruflo__*' or 'mcp__claude-flow__*'
|
|
115
|
+
|
|
116
|
+
Both files are backed up to <path>.flo-bak.<ts> before being rewritten.
|
|
117
|
+
|
|
118
|
+
Idempotent. Re-running on a clean config is a no-op.
|
|
119
|
+
|
|
120
|
+
This complements 'flo migrate', which only ADDED the flo entry. Run that first
|
|
121
|
+
to register flo, then 'flo replace ruflo' to remove the old server entry.
|
|
122
|
+
`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseFlags(args) {
|
|
126
|
+
const out = { positional: [] };
|
|
127
|
+
for (const a of args) {
|
|
128
|
+
if (a === '--help' || a === '-h') out.help = true;
|
|
129
|
+
else if (a === '--dry-run') out.dryRun = true;
|
|
130
|
+
else if (!a.startsWith('--')) out.positional.push(a);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
package/lib/sessions.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_DIR = '.claude/checkpoints';
|
|
6
|
+
|
|
7
|
+
export async function sessionsList(args) {
|
|
8
|
+
const parsed = parseArgs(args);
|
|
9
|
+
if (parsed.help) {
|
|
10
|
+
console.log(`flo sessions list — list Claude Code checkpoints
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
flo sessions list [--dir <path>] [--limit <n>] [--json]
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--dir <path> Checkpoints directory (default: ${DEFAULT_DIR}).
|
|
17
|
+
--limit <n> Max checkpoints to show (default: 25).
|
|
18
|
+
--json Output JSON.
|
|
19
|
+
-h, --help Show this help.
|
|
20
|
+
`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const records = await readCheckpoints(parsed.dir, parsed.limit);
|
|
24
|
+
if (parsed.json) {
|
|
25
|
+
console.log(JSON.stringify(records, null, 2));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (!records.length) {
|
|
29
|
+
console.log(`flo sessions: no checkpoints found in ${parsed.dir}`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
console.log(`tag type file`);
|
|
33
|
+
console.log(`----------------------------------- ---------- ----`);
|
|
34
|
+
for (const r of records) {
|
|
35
|
+
const tag = (r.tag || '(no-tag)').padEnd(36).slice(0, 36);
|
|
36
|
+
const type = (r.type || '').padEnd(10).slice(0, 10);
|
|
37
|
+
console.log(`${tag} ${type} ${r.file || ''}`);
|
|
38
|
+
}
|
|
39
|
+
console.log(`\n${records.length} checkpoint(s).`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function readCheckpoints(dir = DEFAULT_DIR, limit = 25) {
|
|
43
|
+
const fullDir = resolve(dir);
|
|
44
|
+
if (!existsSync(fullDir)) return [];
|
|
45
|
+
const entries = await readdir(fullDir);
|
|
46
|
+
const records = [];
|
|
47
|
+
for (const name of entries) {
|
|
48
|
+
if (!name.endsWith('.json')) continue;
|
|
49
|
+
const path = join(fullDir, name);
|
|
50
|
+
try {
|
|
51
|
+
const raw = await readFile(path, 'utf8');
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
const st = await stat(path);
|
|
54
|
+
records.push({
|
|
55
|
+
id: name.replace(/\.json$/, ''),
|
|
56
|
+
path,
|
|
57
|
+
mtime: st.mtimeMs,
|
|
58
|
+
tag: parsed.tag,
|
|
59
|
+
timestamp: parsed.timestamp,
|
|
60
|
+
type: parsed.type,
|
|
61
|
+
file: parsed.file,
|
|
62
|
+
branch: parsed.branch,
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
// skip malformed
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
records.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
|
|
69
|
+
return records.slice(0, limit);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseArgs(args) {
|
|
73
|
+
const out = { help: false, dir: DEFAULT_DIR, limit: 25, json: false };
|
|
74
|
+
for (let i = 0; i < args.length; i++) {
|
|
75
|
+
const a = args[i];
|
|
76
|
+
if (a === '--help' || a === '-h') out.help = true;
|
|
77
|
+
else if (a === '--dir') out.dir = args[++i];
|
|
78
|
+
else if (a === '--limit') out.limit = Number(args[++i]);
|
|
79
|
+
else if (a === '--json') out.json = true;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
package/lib/setup.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// `flo setup` — single-command onboarding.
|
|
2
|
+
// Creates ~/.flo home, registers flo as an MCP server, runs doctor, prints next steps.
|
|
3
|
+
// Idempotent. Safe to re-run.
|
|
4
|
+
|
|
5
|
+
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { migrate } from './migrate.js';
|
|
10
|
+
import { doctor } from './doctor.js';
|
|
11
|
+
|
|
12
|
+
const FLO_HOME = process.env.FLO_HOME || join(homedir(), '.flo');
|
|
13
|
+
|
|
14
|
+
export async function setupCommand(args) {
|
|
15
|
+
const opts = parseArgs(args);
|
|
16
|
+
if (opts.help) return printHelp();
|
|
17
|
+
|
|
18
|
+
console.log(`flo setup — onboarding for ${FLO_HOME}`);
|
|
19
|
+
console.log('');
|
|
20
|
+
|
|
21
|
+
// 1. Ensure ~/.flo exists with the expected subtree
|
|
22
|
+
const dirs = ['memory', 'messages', 'logs'];
|
|
23
|
+
await mkdir(FLO_HOME, { recursive: true });
|
|
24
|
+
for (const d of dirs) await mkdir(join(FLO_HOME, d), { recursive: true });
|
|
25
|
+
console.log(`✓ ~/.flo/ created (memory, messages, logs)`);
|
|
26
|
+
|
|
27
|
+
// 2. Touch registry files so they're discoverable from day one
|
|
28
|
+
for (const f of ['inboxes.json', 'terminals.json']) {
|
|
29
|
+
const path = join(FLO_HOME, f);
|
|
30
|
+
if (!existsSync(path)) {
|
|
31
|
+
await writeFile(path, JSON.stringify({ version: 1, [f.replace('.json', '')]: [] }, null, 2) + '\n', 'utf8');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
console.log(`✓ ~/.flo/inboxes.json and terminals.json initialized`);
|
|
35
|
+
|
|
36
|
+
// 3. Register flo as an MCP server in ~/.claude/mcp.json
|
|
37
|
+
if (!opts.skipMcp) {
|
|
38
|
+
try {
|
|
39
|
+
await migrate(opts.dryRun ? ['--dry-run'] : []);
|
|
40
|
+
console.log(`✓ MCP server registered at ~/.claude/mcp.json (key: 'flo')`);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.log(`× MCP registration failed: ${err.message}`);
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
console.log(`- MCP registration skipped (--skip-mcp)`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 4. Health check
|
|
49
|
+
console.log('');
|
|
50
|
+
console.log('Running flo doctor:');
|
|
51
|
+
console.log('');
|
|
52
|
+
try {
|
|
53
|
+
await doctor([]);
|
|
54
|
+
} catch { /* doctor exits non-zero on any FAIL; that's informational */ }
|
|
55
|
+
|
|
56
|
+
// 5. Print next steps
|
|
57
|
+
console.log('');
|
|
58
|
+
console.log('---');
|
|
59
|
+
console.log('Next steps:');
|
|
60
|
+
console.log(' flo guidance audit --out ~/Desktop/flo-audit.md # find capability dupes');
|
|
61
|
+
console.log(' flo inbox add ~/Downloads/inbox # register an inbox');
|
|
62
|
+
console.log(' flo notes "First note with #flo #setup" # quick capture');
|
|
63
|
+
console.log(' flo tasks create "Try the web UI" --tags ui # persistent task');
|
|
64
|
+
console.log(' cd web && pnpm dev --port 3030 # localhost dashboard');
|
|
65
|
+
console.log('');
|
|
66
|
+
console.log('Restart Claude Code to pick up the flo MCP server.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function printHelp() {
|
|
70
|
+
console.log(`flo setup — single-command onboarding
|
|
71
|
+
|
|
72
|
+
Usage:
|
|
73
|
+
flo setup [--skip-mcp] [--dry-run]
|
|
74
|
+
|
|
75
|
+
Idempotent. Re-running is safe — creates anything missing, leaves the rest.
|
|
76
|
+
Steps:
|
|
77
|
+
1. Create ~/.flo/{memory,messages,logs}/
|
|
78
|
+
2. Initialize ~/.flo/{inboxes,terminals}.json
|
|
79
|
+
3. Register flo as MCP server in ~/.claude/mcp.json
|
|
80
|
+
4. Run health check
|
|
81
|
+
5. Print next-step suggestions
|
|
82
|
+
`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseArgs(args) {
|
|
86
|
+
const out = {};
|
|
87
|
+
for (const a of args) {
|
|
88
|
+
if (a === '--help' || a === '-h') out.help = true;
|
|
89
|
+
else if (a === '--skip-mcp') out.skipMcp = true;
|
|
90
|
+
else if (a === '--dry-run') out.dryRun = true;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
package/lib/swarm.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { readFile, readdir, appendFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_DIR = '.swarm';
|
|
6
|
+
const CONSENSUS_FILE = 'consensus.jsonl';
|
|
7
|
+
|
|
8
|
+
export async function recordVote({ proposal, voter, vote, weight, metadata, dir = DEFAULT_DIR }) {
|
|
9
|
+
if (!proposal) throw new Error('recordVote: proposal is required');
|
|
10
|
+
if (!voter) throw new Error('recordVote: voter is required');
|
|
11
|
+
const fullDir = resolve(dir);
|
|
12
|
+
if (!existsSync(fullDir)) await mkdir(fullDir, { recursive: true });
|
|
13
|
+
const event = {
|
|
14
|
+
proposal: String(proposal),
|
|
15
|
+
voter: String(voter),
|
|
16
|
+
vote: String(vote ?? 'yes'),
|
|
17
|
+
weight: typeof weight === 'number' ? weight : 1,
|
|
18
|
+
metadata: metadata && typeof metadata === 'object' ? metadata : {},
|
|
19
|
+
ts: new Date().toISOString(),
|
|
20
|
+
};
|
|
21
|
+
await appendFile(join(fullDir, CONSENSUS_FILE), JSON.stringify(event) + '\n', 'utf8');
|
|
22
|
+
return event;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function listVotes({ proposal, dir = DEFAULT_DIR } = {}) {
|
|
26
|
+
const fullDir = resolve(dir);
|
|
27
|
+
const path = join(fullDir, CONSENSUS_FILE);
|
|
28
|
+
if (!existsSync(path)) return [];
|
|
29
|
+
let raw = '';
|
|
30
|
+
try { raw = await readFile(path, 'utf8'); } catch { return []; }
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
33
|
+
if (!line.trim()) continue;
|
|
34
|
+
try {
|
|
35
|
+
const row = JSON.parse(line);
|
|
36
|
+
if (!proposal || row.proposal === proposal) out.push(row);
|
|
37
|
+
} catch { /* skip */ }
|
|
38
|
+
}
|
|
39
|
+
return out.sort((a, b) => (a.ts < b.ts ? 1 : -1));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function tallyVotes({ proposal, dir = DEFAULT_DIR }) {
|
|
43
|
+
const votes = await listVotes({ proposal, dir });
|
|
44
|
+
const byVoter = new Map();
|
|
45
|
+
for (const v of votes) byVoter.set(v.voter, v);
|
|
46
|
+
const final = [...byVoter.values()];
|
|
47
|
+
const tally = {};
|
|
48
|
+
let totalWeight = 0;
|
|
49
|
+
for (const v of final) {
|
|
50
|
+
tally[v.vote] = (tally[v.vote] || 0) + v.weight;
|
|
51
|
+
totalWeight += v.weight;
|
|
52
|
+
}
|
|
53
|
+
return { proposal, totalVoters: final.length, totalWeight, tally };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function readSwarmState(dir = DEFAULT_DIR) {
|
|
57
|
+
const fullDir = resolve(dir);
|
|
58
|
+
if (!existsSync(fullDir)) return { available: false, dir: fullDir };
|
|
59
|
+
|
|
60
|
+
const state = await safeReadJson(join(fullDir, 'state.json'));
|
|
61
|
+
const qlearn = await safeReadJson(join(fullDir, 'q-learning-model.json'));
|
|
62
|
+
|
|
63
|
+
// Compact q-learning summary — the full qTable can be huge.
|
|
64
|
+
let qlearnSummary = null;
|
|
65
|
+
if (qlearn) {
|
|
66
|
+
const qStates = Object.keys(qlearn.qTable || {});
|
|
67
|
+
qlearnSummary = {
|
|
68
|
+
version: qlearn.version,
|
|
69
|
+
encoderVersion: qlearn.encoderVersion,
|
|
70
|
+
config: qlearn.config,
|
|
71
|
+
stats: qlearn.stats,
|
|
72
|
+
metadata: qlearn.metadata,
|
|
73
|
+
stateCount: qStates.length,
|
|
74
|
+
sampleStates: qStates.slice(0, 5).map((s) => ({
|
|
75
|
+
state: s,
|
|
76
|
+
visits: qlearn.qTable[s]?.visits ?? 0,
|
|
77
|
+
topQ: Math.max(...(qlearn.qTable[s]?.qValues || [0])),
|
|
78
|
+
})),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
available: true,
|
|
84
|
+
dir: fullDir,
|
|
85
|
+
state,
|
|
86
|
+
qlearn: qlearnSummary,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function safeReadJson(path) {
|
|
91
|
+
if (!existsSync(path)) return null;
|
|
92
|
+
try {
|
|
93
|
+
const raw = await readFile(path, 'utf8');
|
|
94
|
+
return JSON.parse(raw);
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function swarmCommand(args) {
|
|
101
|
+
const [sub = 'status', ...rest] = args;
|
|
102
|
+
if (sub === 'status') return swarmStatusCommand(rest);
|
|
103
|
+
if (sub === 'vote') return swarmVoteCommand(rest);
|
|
104
|
+
if (sub === 'tally') return swarmTallyCommand(rest);
|
|
105
|
+
if (sub === 'votes') return swarmVotesCommand(rest);
|
|
106
|
+
if (sub === 'help' || sub === '--help' || sub === '-h') return printSwarmHelp();
|
|
107
|
+
console.error(`flo swarm: unknown subcommand '${sub}'`);
|
|
108
|
+
console.error(`Available: status, vote, tally, votes, help`);
|
|
109
|
+
process.exit(2);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function printSwarmHelp() {
|
|
113
|
+
console.log(`flo swarm — coordination + lightweight consensus
|
|
114
|
+
|
|
115
|
+
Usage:
|
|
116
|
+
flo swarm status [--dir <path>] [--json]
|
|
117
|
+
flo swarm vote <proposal> --voter <id> [--vote yes|no|abstain] [--weight N] [--json]
|
|
118
|
+
flo swarm tally <proposal> [--json]
|
|
119
|
+
flo swarm votes [<proposal>] [--json]
|
|
120
|
+
|
|
121
|
+
State files under .swarm/ in the current project:
|
|
122
|
+
state.json — populated by 'npx ruflo swarm init' (read-only here)
|
|
123
|
+
q-learning-model.json — read-only
|
|
124
|
+
consensus.jsonl — append-only votes (last vote per voter wins)
|
|
125
|
+
`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function swarmVoteCommand(args) {
|
|
129
|
+
let proposal, voter, vote = 'yes', weight, json = false;
|
|
130
|
+
for (let i = 0; i < args.length; i++) {
|
|
131
|
+
const a = args[i];
|
|
132
|
+
if (a === '--voter') voter = args[++i];
|
|
133
|
+
else if (a === '--vote') vote = args[++i];
|
|
134
|
+
else if (a === '--weight') weight = Number(args[++i]);
|
|
135
|
+
else if (a === '--json') json = true;
|
|
136
|
+
else if (!a.startsWith('--') && !proposal) proposal = a;
|
|
137
|
+
}
|
|
138
|
+
if (!proposal) { console.error(`flo swarm vote: missing <proposal>`); process.exit(2); }
|
|
139
|
+
if (!voter) { console.error(`flo swarm vote: missing --voter <id>`); process.exit(2); }
|
|
140
|
+
const event = await recordVote({ proposal, voter, vote, weight });
|
|
141
|
+
if (json) console.log(JSON.stringify(event));
|
|
142
|
+
else console.log(`flo swarm vote: ${voter} voted '${vote}' on '${proposal}' (weight ${event.weight})`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function swarmTallyCommand(args) {
|
|
146
|
+
let proposal, json = false;
|
|
147
|
+
for (let i = 0; i < args.length; i++) {
|
|
148
|
+
const a = args[i];
|
|
149
|
+
if (a === '--json') json = true;
|
|
150
|
+
else if (!a.startsWith('--') && !proposal) proposal = a;
|
|
151
|
+
}
|
|
152
|
+
if (!proposal) { console.error(`flo swarm tally: missing <proposal>`); process.exit(2); }
|
|
153
|
+
const result = await tallyVotes({ proposal });
|
|
154
|
+
if (json) console.log(JSON.stringify(result));
|
|
155
|
+
else {
|
|
156
|
+
console.log(`flo swarm tally for '${proposal}':`);
|
|
157
|
+
console.log(` voters: ${result.totalVoters}, total weight: ${result.totalWeight}`);
|
|
158
|
+
for (const [vote, weight] of Object.entries(result.tally)) {
|
|
159
|
+
console.log(` ${vote}: ${weight}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function swarmVotesCommand(args) {
|
|
165
|
+
let proposal, json = false;
|
|
166
|
+
for (let i = 0; i < args.length; i++) {
|
|
167
|
+
const a = args[i];
|
|
168
|
+
if (a === '--json') json = true;
|
|
169
|
+
else if (!a.startsWith('--') && !proposal) proposal = a;
|
|
170
|
+
}
|
|
171
|
+
const votes = await listVotes({ proposal });
|
|
172
|
+
if (json) { console.log(JSON.stringify(votes, null, 2)); return; }
|
|
173
|
+
if (!votes.length) { console.log(`flo swarm votes: none${proposal ? ' for ' + proposal : ''}`); return; }
|
|
174
|
+
console.log(`when proposal voter vote weight`);
|
|
175
|
+
console.log(`------------------- -------------------- --------------- ------ ------`);
|
|
176
|
+
for (const v of votes) {
|
|
177
|
+
const when = v.ts.replace('T', ' ').slice(0, 19);
|
|
178
|
+
const p = v.proposal.padEnd(20).slice(0, 20);
|
|
179
|
+
const voter = v.voter.padEnd(15).slice(0, 15);
|
|
180
|
+
const vote = v.vote.padEnd(6).slice(0, 6);
|
|
181
|
+
console.log(`${when} ${p} ${voter} ${vote} ${v.weight}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function swarmStatusCommand(args) {
|
|
186
|
+
const parsed = parseArgs(args);
|
|
187
|
+
if (parsed.help) {
|
|
188
|
+
console.log(`flo swarm status — read .swarm/ state
|
|
189
|
+
|
|
190
|
+
Usage:
|
|
191
|
+
flo swarm status [--dir <path>] [--json]
|
|
192
|
+
`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const result = await readSwarmState(parsed.dir);
|
|
196
|
+
if (parsed.json) {
|
|
197
|
+
console.log(JSON.stringify(result, null, 2));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (!result.available) {
|
|
201
|
+
console.log(`flo swarm: no .swarm/ directory at ${result.dir}`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
console.log(`flo swarm status: ${result.dir}`);
|
|
205
|
+
if (result.state) {
|
|
206
|
+
console.log(` id: ${result.state.swarmId}`);
|
|
207
|
+
console.log(` objective: ${result.state.objective}`);
|
|
208
|
+
console.log(` strategy: ${result.state.strategy}`);
|
|
209
|
+
console.log(` status: ${result.state.status}`);
|
|
210
|
+
console.log(` agents: ${result.state.agents}`);
|
|
211
|
+
if (result.state.agentPlan?.length) {
|
|
212
|
+
console.log(` plan:`);
|
|
213
|
+
for (const p of result.state.agentPlan) {
|
|
214
|
+
console.log(` - ${p.count}× ${p.role} (${p.type}): ${p.purpose}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (result.qlearn) {
|
|
219
|
+
console.log(` q-learning:`);
|
|
220
|
+
console.log(` states: ${result.qlearn.stateCount}`);
|
|
221
|
+
console.log(` steps: ${result.qlearn.stats?.stepCount ?? 0}`);
|
|
222
|
+
console.log(` epsilon: ${result.qlearn.stats?.epsilon?.toFixed(4) ?? '—'}`);
|
|
223
|
+
console.log(` avg TD: ${result.qlearn.stats?.avgTDError?.toFixed(4) ?? '—'}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parseArgs(args) {
|
|
228
|
+
const out = { help: false, dir: DEFAULT_DIR, json: false };
|
|
229
|
+
for (let i = 0; i < args.length; i++) {
|
|
230
|
+
const a = args[i];
|
|
231
|
+
if (a === '--help' || a === '-h') out.help = true;
|
|
232
|
+
else if (a === '--dir') out.dir = args[++i];
|
|
233
|
+
else if (a === '--json') out.json = true;
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|