@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,215 @@
1
+ import { readdir, readFile, stat, writeFile, mkdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, basename, relative, sep } from 'node:path';
5
+
6
+ const KINDS = ['skills', 'commands', 'agents'];
7
+
8
+ export async function guidanceAudit(args) {
9
+ const parsed = parseArgs(args);
10
+ if (parsed.help) {
11
+ console.log(`flo guidance audit — capability dedup report
12
+
13
+ Usage:
14
+ flo guidance audit [--out <file>] [--json] [--scope user|project|all]
15
+
16
+ Options:
17
+ --out <file> Write markdown report to file (default: stdout).
18
+ --json Output JSON instead of markdown.
19
+ --scope <s> Restrict scope (user, project, all). Default: all.
20
+ --quiet Suppress progress lines on stderr.
21
+ -h, --help Show this help.
22
+ `);
23
+ return;
24
+ }
25
+
26
+ const scopes = collectScopes(parsed.scope);
27
+ const records = [];
28
+ for (const scope of scopes) {
29
+ for (const kind of KINDS) {
30
+ const dir = join(scope.root, kind);
31
+ if (!existsSync(dir)) continue;
32
+ const found = await scanCapabilityDir(dir, kind, scope.label);
33
+ records.push(...found);
34
+ }
35
+ }
36
+
37
+ if (!parsed.quiet) {
38
+ process.stderr.write(`flo guidance audit: scanned ${records.length} capabilities across ${scopes.length} scope(s)\n`);
39
+ }
40
+
41
+ const report = analyze(records);
42
+
43
+ if (parsed.json) {
44
+ const out = JSON.stringify({ records, ...report }, null, 2);
45
+ if (parsed.out) await writeOut(parsed.out, out);
46
+ else console.log(out);
47
+ return;
48
+ }
49
+
50
+ const md = renderMarkdown(records, report);
51
+ if (parsed.out) await writeOut(parsed.out, md);
52
+ else console.log(md);
53
+ }
54
+
55
+ function parseArgs(args) {
56
+ const out = { scope: 'all', json: false, quiet: false, help: false, out: null };
57
+ for (let i = 0; i < args.length; i++) {
58
+ const a = args[i];
59
+ if (a === '--help' || a === '-h') out.help = true;
60
+ else if (a === '--json') out.json = true;
61
+ else if (a === '--quiet') out.quiet = true;
62
+ else if (a === '--scope') out.scope = args[++i];
63
+ else if (a === '--out') out.out = args[++i];
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function collectScopes(scope) {
69
+ const scopes = [];
70
+ if (scope === 'all' || scope === 'user') {
71
+ scopes.push({ label: 'user', root: join(homedir(), '.claude') });
72
+ }
73
+ if (scope === 'all' || scope === 'project') {
74
+ scopes.push({ label: 'project', root: join(process.cwd(), '.claude') });
75
+ }
76
+ return scopes;
77
+ }
78
+
79
+ async function scanCapabilityDir(dir, kind, scope) {
80
+ const records = [];
81
+ const walk = async (current) => {
82
+ let entries;
83
+ try { entries = await readdir(current, { withFileTypes: true }); }
84
+ catch { return; }
85
+ for (const e of entries) {
86
+ const full = join(current, e.name);
87
+ if (e.isDirectory()) {
88
+ await walk(full);
89
+ } else if (e.isFile()) {
90
+ const ext = e.name.split('.').pop();
91
+ if (!['md', 'yaml', 'yml', 'json'].includes(ext)) continue;
92
+ records.push(await inspectFile(full, kind, scope, dir));
93
+ }
94
+ }
95
+ };
96
+ await walk(dir);
97
+ return records;
98
+ }
99
+
100
+ async function inspectFile(path, kind, scope, kindRoot) {
101
+ let content = '';
102
+ try { content = await readFile(path, 'utf8'); } catch {}
103
+ const fm = parseFrontmatter(content);
104
+ const name = fm.name || basename(path).replace(/\.(md|ya?ml|json)$/i, '');
105
+ return {
106
+ name,
107
+ kind,
108
+ scope,
109
+ path,
110
+ relPath: relative(kindRoot, path),
111
+ description: fm.description || '',
112
+ tags: Array.isArray(fm.tags) ? fm.tags : (typeof fm.tags === 'string' ? fm.tags.split(',').map(t => t.trim()) : []),
113
+ bytes: content.length,
114
+ };
115
+ }
116
+
117
+ function parseFrontmatter(content) {
118
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
119
+ if (!m) return {};
120
+ const fm = {};
121
+ for (const line of m[1].split(/\r?\n/)) {
122
+ const kv = line.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
123
+ if (!kv) continue;
124
+ const key = kv[1];
125
+ let val = kv[2].trim();
126
+ if (val.startsWith('[') && val.endsWith(']')) {
127
+ val = val.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
128
+ } else {
129
+ val = val.replace(/^["']|["']$/g, '');
130
+ }
131
+ fm[key] = val;
132
+ }
133
+ return fm;
134
+ }
135
+
136
+ function analyze(records) {
137
+ const byName = new Map();
138
+ for (const r of records) {
139
+ const key = `${r.kind}:${r.name}`;
140
+ if (!byName.has(key)) byName.set(key, []);
141
+ byName.get(key).push(r);
142
+ }
143
+ const duplicates = [];
144
+ for (const [key, list] of byName) {
145
+ if (list.length > 1) duplicates.push({ key, name: list[0].name, kind: list[0].kind, count: list.length, occurrences: list });
146
+ }
147
+ const missingDescription = records.filter(r => !r.description);
148
+ const scopeHistogram = {};
149
+ const kindHistogram = {};
150
+ for (const r of records) {
151
+ scopeHistogram[r.scope] = (scopeHistogram[r.scope] || 0) + 1;
152
+ const k = `${r.scope}:${r.kind}`;
153
+ kindHistogram[k] = (kindHistogram[k] || 0) + 1;
154
+ }
155
+ return { duplicates, missingDescription, scopeHistogram, kindHistogram, total: records.length };
156
+ }
157
+
158
+ function renderMarkdown(records, report) {
159
+ const lines = [];
160
+ lines.push(`# flo guidance audit`);
161
+ lines.push('');
162
+ lines.push(`Scanned **${report.total}** capabilities.`);
163
+ lines.push('');
164
+ lines.push(`## Scope distribution`);
165
+ lines.push('');
166
+ lines.push('| Scope | Count |');
167
+ lines.push('|---|---|');
168
+ for (const [scope, count] of Object.entries(report.scopeHistogram).sort()) {
169
+ lines.push(`| ${scope} | ${count} |`);
170
+ }
171
+ lines.push('');
172
+ lines.push(`## Kind × scope`);
173
+ lines.push('');
174
+ lines.push('| Scope:kind | Count |');
175
+ lines.push('|---|---|');
176
+ for (const [k, count] of Object.entries(report.kindHistogram).sort()) {
177
+ lines.push(`| ${k} | ${count} |`);
178
+ }
179
+ lines.push('');
180
+ lines.push(`## Duplicates (${report.duplicates.length})`);
181
+ lines.push('');
182
+ if (!report.duplicates.length) {
183
+ lines.push('_No duplicates detected._');
184
+ } else {
185
+ for (const dup of report.duplicates.sort((a, b) => b.count - a.count)) {
186
+ lines.push(`### \`${dup.kind}/${dup.name}\` (${dup.count} copies)`);
187
+ lines.push('');
188
+ for (const o of dup.occurrences) {
189
+ lines.push(`- \`${o.scope}\` — ${o.path}${o.description ? ' \n > ' + o.description : ''}`);
190
+ }
191
+ lines.push('');
192
+ }
193
+ }
194
+ lines.push(`## Missing description (${report.missingDescription.length})`);
195
+ lines.push('');
196
+ if (!report.missingDescription.length) {
197
+ lines.push('_All capabilities have descriptions._');
198
+ } else {
199
+ for (const r of report.missingDescription.slice(0, 100)) {
200
+ lines.push(`- \`${r.scope}/${r.kind}/${r.name}\` — ${r.path}`);
201
+ }
202
+ if (report.missingDescription.length > 100) {
203
+ lines.push(`- _…and ${report.missingDescription.length - 100} more._`);
204
+ }
205
+ }
206
+ lines.push('');
207
+ return lines.join('\n');
208
+ }
209
+
210
+ async function writeOut(path, content) {
211
+ const parent = path.split(sep).slice(0, -1).join(sep);
212
+ if (parent && !existsSync(parent)) await mkdir(parent, { recursive: true });
213
+ await writeFile(path, content, 'utf8');
214
+ process.stderr.write(`flo guidance audit: wrote ${path}\n`);
215
+ }
package/lib/help.js ADDED
@@ -0,0 +1,49 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+
7
+ export function printHelp() {
8
+ console.log(`flo — local-first developer workbench
9
+
10
+ Usage:
11
+ flo <command> [options]
12
+
13
+ Commands:
14
+ guidance audit Scan ~/.claude/{skills,commands,agents}/ and project
15
+ .claude/ for duplicate, missing-description, or
16
+ orphan capabilities. Output: markdown report.
17
+ migrate Rewrite ~/.claude/mcp.json to register 'flo' as an
18
+ MCP server. Idempotent. Backs up first.
19
+ sessions list List Claude Code session checkpoints in
20
+ .claude/checkpoints/ for the current project.
21
+ inbox watch <dir> Watch a folder for drops. Markdown frontmatter routes
22
+ to memory/SendMessage; .m4a/.wav/.mp3 transcribed
23
+ locally (whisper/mlx-whisper). Sidecar .txt written
24
+ next to each audio file. (Foreground; no launchd yet.)
25
+ inbox status Show registered inbox handlers and recent activity.
26
+ transcribe <file> Local audio transcription via whisper/mlx-whisper.
27
+ Use --save for a sidecar .txt; --detect to check
28
+ which tool is available.
29
+ doctor Quick health check: Node, git, .claude dir,
30
+ checkpoints, MCP config.
31
+ help, -h, --help Show this help.
32
+ version, -v, --version Show flo version.
33
+
34
+ Environment:
35
+ FLO_DEBUG=1 Print stack traces on error.
36
+ FLO_HOME=<path> Override ~/.flo (default).
37
+
38
+ Docs: https://github.com/therealsiege/myflo
39
+ `);
40
+ }
41
+
42
+ export function printVersion() {
43
+ try {
44
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
45
+ console.log(`flo ${pkg.version}`);
46
+ } catch {
47
+ console.log('flo (unknown version)');
48
+ }
49
+ }
@@ -0,0 +1,129 @@
1
+ // `flo hook <event>` — Claude Code hook dispatcher.
2
+ // Replaces .claude/helpers/hook-handler.cjs (and friends) with a single
3
+ // canonical entrypoint. Each event takes whatever env vars Claude Code
4
+ // supplies, does the corresponding flo work (memory store, task event,
5
+ // activity log), and exits 0. Hooks are best-effort and never fail loudly
6
+ // — a hook crashing should not break the user's Claude Code session.
7
+
8
+ import { appendFile, mkdir } from 'node:fs/promises';
9
+ import { existsSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { storeEntry } from './memory-store.js';
13
+
14
+ const FLO_HOME = process.env.FLO_HOME || join(homedir(), '.flo');
15
+ const HOOK_LOG = join(FLO_HOME, 'logs', 'hooks.jsonl');
16
+
17
+ const EVENTS = [
18
+ 'pre-task', 'post-task', 'pre-edit', 'post-edit',
19
+ 'pre-command', 'post-command', 'pre-bash', 'post-bash',
20
+ 'session-start', 'session-end', 'session-restore',
21
+ 'route', 'notify', 'pretrain', 'compact-manual', 'compact-auto',
22
+ 'subagent-stop', 'stop',
23
+ ];
24
+
25
+ export async function hookCommand(args) {
26
+ const [event = 'help', ...rest] = args;
27
+ if (event === 'help' || event === '--help' || event === '-h' || event === 'list') {
28
+ return printHelp();
29
+ }
30
+ // Hooks should NEVER fail the Claude Code session — wrap everything.
31
+ try {
32
+ await ensureLogDir();
33
+ await logEvent(event, rest);
34
+ if (event === 'post-task' || event === 'post-edit') {
35
+ await maybeRecordOutcome(event, rest);
36
+ }
37
+ if (event === 'route') {
38
+ // Print empty string — Claude Code's hook protocol expects stdout to be
39
+ // injected as user-facing context. Routing recommendations go to memory
40
+ // instead; the agent can call flo_memory_search if it wants them.
41
+ process.stdout.write('');
42
+ }
43
+ process.exit(0);
44
+ } catch (err) {
45
+ // Best-effort: log to stderr but exit 0 so we don't block Claude Code
46
+ process.stderr.write(`flo hook ${event}: ${err.message || err}\n`);
47
+ process.exit(0);
48
+ }
49
+ }
50
+
51
+ function printHelp() {
52
+ console.log(`flo hook — Claude Code hook dispatcher
53
+
54
+ Usage:
55
+ flo hook <event> [args...]
56
+
57
+ Events:
58
+ ${EVENTS.join(', ')}
59
+
60
+ All events append a record to ~/.flo/logs/hooks.jsonl. Some events also write
61
+ to flo memory (post-task / post-edit outcomes). Hooks are best-effort —
62
+ a crash never fails the Claude Code session.
63
+
64
+ Wire up by adding to .claude/settings.json hooks:
65
+ {
66
+ "hooks": {
67
+ "PreToolUse": [{
68
+ "matcher": "Bash",
69
+ "hooks": [{
70
+ "type": "command",
71
+ "command": "node \${CLAUDE_PROJECT_DIR}/apps/cli/bin/flo.js hook pre-bash",
72
+ "timeout": 5000
73
+ }]
74
+ }]
75
+ }
76
+ }
77
+ `);
78
+ }
79
+
80
+ async function ensureLogDir() {
81
+ const dir = join(FLO_HOME, 'logs');
82
+ if (!existsSync(dir)) await mkdir(dir, { recursive: true });
83
+ }
84
+
85
+ async function logEvent(event, args) {
86
+ const record = {
87
+ event,
88
+ ts: new Date().toISOString(),
89
+ args,
90
+ env: collectEnv(),
91
+ pid: process.pid,
92
+ };
93
+ await appendFile(HOOK_LOG, JSON.stringify(record) + '\n', 'utf8');
94
+ }
95
+
96
+ // Pull the env vars Claude Code typically provides; ignore the rest
97
+ function collectEnv() {
98
+ const wanted = [
99
+ 'CLAUDE_PROJECT_DIR', 'CLAUDE_TOOL_NAME', 'CLAUDE_HOOK_EVENT',
100
+ 'CLAUDE_SESSION_ID', 'CLAUDE_FILE_PATHS', 'CLAUDE_COMMAND',
101
+ 'CLAUDE_NOTIFICATION', 'CLAUDE_MESSAGE',
102
+ ];
103
+ const out = {};
104
+ for (const k of wanted) if (process.env[k]) out[k] = process.env[k];
105
+ return out;
106
+ }
107
+
108
+ // If Claude Code provides task / edit context via env, mirror into memory so
109
+ // flo's activity feed picks it up. Otherwise it's a quiet no-op.
110
+ async function maybeRecordOutcome(event, args) {
111
+ const env = collectEnv();
112
+ const tool = env.CLAUDE_TOOL_NAME;
113
+ const files = env.CLAUDE_FILE_PATHS;
114
+ if (!tool && !files && !args.length) return;
115
+ const summary = [tool, files, args.join(' ')].filter(Boolean).join(' / ').slice(0, 200);
116
+ try {
117
+ await storeEntry({
118
+ namespace: 'hooks',
119
+ key: `${event}:${Date.now()}`,
120
+ value: summary || event,
121
+ tags: [event, tool || 'unknown'].filter(Boolean),
122
+ metadata: { ...env, event, args },
123
+ });
124
+ } catch {
125
+ // jsonl store fails silently; that's fine
126
+ }
127
+ }
128
+
129
+ export const _internal = { HOOK_LOG, EVENTS };
@@ -0,0 +1,111 @@
1
+ // macOS launchd plist generator for `flo inbox watch --once` loops.
2
+ // Generates one launch agent per registered inbox slug.
3
+ // No automatic launchctl load — prints the bootstrap command instead, so the
4
+ // user keeps control. (Same pattern watchthis uses; less footgun.)
5
+
6
+ import { mkdir, writeFile, unlink, readdir, readFile } from 'node:fs/promises';
7
+ import { existsSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join, dirname } from 'node:path';
10
+ import { loadRegistry } from './inbox-registry.js';
11
+
12
+ const LAUNCH_AGENTS_DIR = join(homedir(), 'Library', 'LaunchAgents');
13
+ const LABEL_PREFIX = 'io.myflo.inbox';
14
+
15
+ function plistFor({ label, dir, interval, floBin, logDir }) {
16
+ return `<?xml version="1.0" encoding="UTF-8"?>
17
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
18
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
19
+ <plist version="1.0">
20
+ <dict>
21
+ <key>Label</key><string>${escapeXml(label)}</string>
22
+ <key>ProgramArguments</key>
23
+ <array>
24
+ <string>${escapeXml(process.execPath)}</string>
25
+ <string>${escapeXml(floBin)}</string>
26
+ <string>inbox</string>
27
+ <string>watch</string>
28
+ <string>${escapeXml(dir)}</string>
29
+ <string>--once</string>
30
+ </array>
31
+ <key>WorkingDirectory</key><string>${escapeXml(dirname(floBin))}</string>
32
+ <key>StartInterval</key><integer>${Number(interval) || 30}</integer>
33
+ <key>RunAtLoad</key><true/>
34
+ <key>StandardOutPath</key><string>${escapeXml(join(logDir, label + '.out.log'))}</string>
35
+ <key>StandardErrorPath</key><string>${escapeXml(join(logDir, label + '.err.log'))}</string>
36
+ <key>EnvironmentVariables</key>
37
+ <dict>
38
+ <key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
39
+ <key>HOME</key><string>${escapeXml(homedir())}</string>
40
+ </dict>
41
+ </dict>
42
+ </plist>
43
+ `;
44
+ }
45
+
46
+ function escapeXml(s) {
47
+ return String(s)
48
+ .replace(/&/g, '&amp;')
49
+ .replace(/</g, '&lt;')
50
+ .replace(/>/g, '&gt;')
51
+ .replace(/"/g, '&quot;')
52
+ .replace(/'/g, '&apos;');
53
+ }
54
+
55
+ function labelFor(slug) {
56
+ return `${LABEL_PREFIX}.${slug}`;
57
+ }
58
+
59
+ function plistPathFor(slug) {
60
+ return join(LAUNCH_AGENTS_DIR, `${labelFor(slug)}.plist`);
61
+ }
62
+
63
+ async function resolveFloBin() {
64
+ if (process.env.FLO_BIN) return process.env.FLO_BIN;
65
+ return new URL('../bin/flo.js', import.meta.url).pathname;
66
+ }
67
+
68
+ export async function installLaunchAgent({ slug, interval = 30 }) {
69
+ if (process.platform !== 'darwin') {
70
+ throw new Error(`launchd install is macOS-only (current: ${process.platform})`);
71
+ }
72
+ const reg = await loadRegistry();
73
+ const entry = reg.inboxes.find((i) => i.slug === slug);
74
+ if (!entry) {
75
+ throw new Error(`no inbox registered with slug '${slug}'. Run 'flo inbox add <dir>' first.`);
76
+ }
77
+ await mkdir(LAUNCH_AGENTS_DIR, { recursive: true });
78
+ const logDir = join(homedir(), '.flo', 'logs');
79
+ await mkdir(logDir, { recursive: true });
80
+ const floBin = await resolveFloBin();
81
+ const label = labelFor(slug);
82
+ const plistPath = plistPathFor(slug);
83
+ const content = plistFor({ label, dir: entry.dir, interval, floBin, logDir });
84
+ await writeFile(plistPath, content, 'utf8');
85
+ return { label, plistPath, dir: entry.dir, interval };
86
+ }
87
+
88
+ export async function uninstallLaunchAgent({ slug }) {
89
+ if (process.platform !== 'darwin') {
90
+ throw new Error(`launchd uninstall is macOS-only (current: ${process.platform})`);
91
+ }
92
+ const plistPath = plistPathFor(slug);
93
+ if (!existsSync(plistPath)) return { removed: false, plistPath };
94
+ await unlink(plistPath);
95
+ return { removed: true, plistPath };
96
+ }
97
+
98
+ export async function listInstalledAgents() {
99
+ if (!existsSync(LAUNCH_AGENTS_DIR)) return [];
100
+ const entries = await readdir(LAUNCH_AGENTS_DIR);
101
+ const out = [];
102
+ for (const name of entries) {
103
+ if (!name.startsWith(`${LABEL_PREFIX}.`) || !name.endsWith('.plist')) continue;
104
+ const slug = name.slice(`${LABEL_PREFIX}.`.length, -'.plist'.length);
105
+ out.push({ slug, plistPath: join(LAUNCH_AGENTS_DIR, name) });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ // Exported for tests / introspection
111
+ export const _internal = { plistFor, labelFor, plistPathFor, LAUNCH_AGENTS_DIR };
@@ -0,0 +1,122 @@
1
+ // ~/.flo/inboxes.json — small registry of named inboxes that flo manages.
2
+ // Used by `flo inbox list`, `/inbox` web panel, and the launchd installer.
3
+
4
+ import { mkdir, readFile, writeFile, readdir, stat } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join, resolve, basename } from 'node:path';
8
+
9
+ const FLO_HOME = process.env.FLO_HOME || join(homedir(), '.flo');
10
+ const REGISTRY_PATH = join(FLO_HOME, 'inboxes.json');
11
+
12
+ function slugify(input) {
13
+ return String(input)
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9-]+/g, '-')
16
+ .replace(/^-+|-+$/g, '')
17
+ .slice(0, 40) || 'inbox';
18
+ }
19
+
20
+ async function ensureHome() {
21
+ if (!existsSync(FLO_HOME)) await mkdir(FLO_HOME, { recursive: true });
22
+ }
23
+
24
+ export async function loadRegistry() {
25
+ await ensureHome();
26
+ if (!existsSync(REGISTRY_PATH)) return { version: 1, inboxes: [] };
27
+ try {
28
+ const raw = await readFile(REGISTRY_PATH, 'utf8');
29
+ const parsed = JSON.parse(raw);
30
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.inboxes)) {
31
+ return { version: 1, inboxes: [] };
32
+ }
33
+ return parsed;
34
+ } catch {
35
+ return { version: 1, inboxes: [] };
36
+ }
37
+ }
38
+
39
+ export async function saveRegistry(reg) {
40
+ await ensureHome();
41
+ await writeFile(REGISTRY_PATH, JSON.stringify(reg, null, 2) + '\n', 'utf8');
42
+ }
43
+
44
+ export async function addInbox({ dir, slug, handlerHints }) {
45
+ const reg = await loadRegistry();
46
+ const resolved = resolve(dir.replace(/^~/, homedir()));
47
+ const finalSlug = slug ? slugify(slug) : slugify(basename(resolved));
48
+ // Ensure slug uniqueness
49
+ const existingByDir = reg.inboxes.find((i) => i.dir === resolved);
50
+ if (existingByDir) {
51
+ // Idempotent: return existing entry
52
+ return existingByDir;
53
+ }
54
+ let uniqueSlug = finalSlug;
55
+ let n = 1;
56
+ while (reg.inboxes.find((i) => i.slug === uniqueSlug)) {
57
+ uniqueSlug = `${finalSlug}-${++n}`;
58
+ }
59
+ const entry = {
60
+ slug: uniqueSlug,
61
+ dir: resolved,
62
+ createdAt: new Date().toISOString(),
63
+ handlerHints: Array.isArray(handlerHints) ? handlerHints : [],
64
+ };
65
+ reg.inboxes.push(entry);
66
+ await saveRegistry(reg);
67
+ return entry;
68
+ }
69
+
70
+ export async function removeInbox(slug) {
71
+ const reg = await loadRegistry();
72
+ const before = reg.inboxes.length;
73
+ reg.inboxes = reg.inboxes.filter((i) => i.slug !== slug);
74
+ const removed = before - reg.inboxes.length;
75
+ await saveRegistry(reg);
76
+ return removed > 0;
77
+ }
78
+
79
+ export async function listInboxes() {
80
+ const reg = await loadRegistry();
81
+ const enriched = [];
82
+ for (const i of reg.inboxes) {
83
+ enriched.push({ ...i, ...(await statInbox(i.dir)) });
84
+ }
85
+ return enriched;
86
+ }
87
+
88
+ async function statInbox(dir) {
89
+ const out = { exists: false, pending: 0, processed: 0, failed: 0, lastActivity: null };
90
+ if (!existsSync(dir)) return out;
91
+ out.exists = true;
92
+ try {
93
+ const entries = await readdir(dir, { withFileTypes: true });
94
+ for (const e of entries) {
95
+ if (!e.isFile()) continue;
96
+ if (e.name.startsWith('.')) continue;
97
+ if (e.name === 'inbox.log') continue;
98
+ out.pending++;
99
+ }
100
+ } catch {}
101
+ for (const subdir of ['.processed', '.failed']) {
102
+ const p = join(dir, subdir);
103
+ if (existsSync(p)) {
104
+ try {
105
+ const list = await readdir(p);
106
+ if (subdir === '.processed') out.processed = list.length;
107
+ else out.failed = list.length;
108
+ } catch {}
109
+ }
110
+ }
111
+ const logPath = join(dir, 'inbox.log');
112
+ if (existsSync(logPath)) {
113
+ try {
114
+ const st = await stat(logPath);
115
+ out.lastActivity = st.mtimeMs;
116
+ } catch {}
117
+ }
118
+ return out;
119
+ }
120
+
121
+ export const REGISTRY = { FLO_HOME, REGISTRY_PATH };
122
+ export { slugify };