@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
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {
|
|
2
|
+
storeEntry,
|
|
3
|
+
searchEntries,
|
|
4
|
+
listEntries,
|
|
5
|
+
getEntry,
|
|
6
|
+
deleteEntry,
|
|
7
|
+
namespaceStats,
|
|
8
|
+
listNamespaces,
|
|
9
|
+
} from './memory-store.js';
|
|
10
|
+
|
|
11
|
+
export async function memoryCommand(args) {
|
|
12
|
+
const [sub = 'help', ...rest] = args;
|
|
13
|
+
if (sub === 'help' || sub === '--help' || sub === '-h') return printHelp();
|
|
14
|
+
if (sub === 'store') return storeCommand(rest);
|
|
15
|
+
if (sub === 'search') return searchCommand(rest);
|
|
16
|
+
if (sub === 'list') return listCommand(rest);
|
|
17
|
+
if (sub === 'get') return getCommand(rest);
|
|
18
|
+
if (sub === 'delete') return deleteCommand(rest);
|
|
19
|
+
if (sub === 'namespaces' || sub === 'ns') return namespacesCommand(rest);
|
|
20
|
+
console.error(`flo memory: unknown subcommand '${sub}'`);
|
|
21
|
+
console.error(`Available: store, search, list, get, delete, namespaces, help`);
|
|
22
|
+
process.exit(2);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function printHelp() {
|
|
26
|
+
console.log(`flo memory — file-backed JSON store at ~/.flo/memory/
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
flo memory store --value <text> [--key <k>] [--namespace <ns>] [--tags a,b]
|
|
30
|
+
flo memory search <query> [--namespace <ns>] [--tags a,b] [--limit N] [--json]
|
|
31
|
+
flo memory list [--namespace <ns>] [--limit N] [--json]
|
|
32
|
+
flo memory get [--id <id> | --key <key>] [--namespace <ns>]
|
|
33
|
+
flo memory delete --id <id> [--namespace <ns>]
|
|
34
|
+
flo memory namespaces [--json]
|
|
35
|
+
|
|
36
|
+
Storage: append-only JSONL per namespace. Deletes use tombstones (history preserved).
|
|
37
|
+
Search: substring + tag scoring. No vector embeddings yet.
|
|
38
|
+
`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseFlags(args) {
|
|
42
|
+
const out = { positional: [] };
|
|
43
|
+
for (let i = 0; i < args.length; i++) {
|
|
44
|
+
const a = args[i];
|
|
45
|
+
if (a === '--json') out.json = true;
|
|
46
|
+
else if (a === '--namespace' || a === '-n') out.namespace = args[++i];
|
|
47
|
+
else if (a === '--key' || a === '-k') out.key = args[++i];
|
|
48
|
+
else if (a === '--value' || a === '-v') out.value = args[++i];
|
|
49
|
+
else if (a === '--id') out.id = args[++i];
|
|
50
|
+
else if (a === '--tags') out.tags = (args[++i] || '').split(',').map((t) => t.trim()).filter(Boolean);
|
|
51
|
+
else if (a === '--limit') out.limit = Number(args[++i]);
|
|
52
|
+
else if (!a.startsWith('--')) out.positional.push(a);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function storeCommand(args) {
|
|
58
|
+
const opts = parseFlags(args);
|
|
59
|
+
if (!opts.value && opts.positional.length === 0) {
|
|
60
|
+
console.error(`flo memory store: missing --value (or positional text)`);
|
|
61
|
+
process.exit(2);
|
|
62
|
+
}
|
|
63
|
+
const value = opts.value ?? opts.positional.join(' ');
|
|
64
|
+
const entry = await storeEntry({
|
|
65
|
+
namespace: opts.namespace,
|
|
66
|
+
key: opts.key,
|
|
67
|
+
value,
|
|
68
|
+
tags: opts.tags,
|
|
69
|
+
});
|
|
70
|
+
if (opts.json) console.log(JSON.stringify(entry));
|
|
71
|
+
else console.log(`flo memory store: ${entry.namespace}/${entry.id} (${value.length} chars)`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function searchCommand(args) {
|
|
75
|
+
const opts = parseFlags(args);
|
|
76
|
+
const query = opts.positional.join(' ');
|
|
77
|
+
if (!query && (!opts.tags || !opts.tags.length)) {
|
|
78
|
+
console.error(`flo memory search: missing query or --tags`);
|
|
79
|
+
process.exit(2);
|
|
80
|
+
}
|
|
81
|
+
const results = await searchEntries({
|
|
82
|
+
namespace: opts.namespace,
|
|
83
|
+
query,
|
|
84
|
+
tags: opts.tags || [],
|
|
85
|
+
limit: opts.limit || 20,
|
|
86
|
+
});
|
|
87
|
+
if (opts.json) {
|
|
88
|
+
console.log(JSON.stringify(results, null, 2));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!results.length) { console.log(`flo memory search: 0 results`); return; }
|
|
92
|
+
for (const r of results) {
|
|
93
|
+
const head = r.value.split('\n')[0].slice(0, 80);
|
|
94
|
+
console.log(`[score=${r._score}] ${r.namespace}/${r.id} ${r.key ? `(${r.key})` : ''}`);
|
|
95
|
+
console.log(` ${head}${r.value.length > 80 ? '…' : ''}`);
|
|
96
|
+
if (r.tags?.length) console.log(` tags: ${r.tags.join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function listCommand(args) {
|
|
101
|
+
const opts = parseFlags(args);
|
|
102
|
+
const entries = await listEntries({
|
|
103
|
+
namespace: opts.namespace || 'default',
|
|
104
|
+
limit: opts.limit || 50,
|
|
105
|
+
});
|
|
106
|
+
if (opts.json) { console.log(JSON.stringify(entries, null, 2)); return; }
|
|
107
|
+
if (!entries.length) {
|
|
108
|
+
console.log(`flo memory list: no entries in ${opts.namespace || 'default'}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
for (const e of entries) {
|
|
112
|
+
const head = e.value.split('\n')[0].slice(0, 80);
|
|
113
|
+
console.log(`${e.id} ${e.key ?? '—'} ${head}${e.value.length > 80 ? '…' : ''}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function getCommand(args) {
|
|
118
|
+
const opts = parseFlags(args);
|
|
119
|
+
const e = await getEntry({ namespace: opts.namespace, id: opts.id, key: opts.key });
|
|
120
|
+
if (!e) { console.error(`flo memory get: not found`); process.exit(1); }
|
|
121
|
+
if (opts.json) console.log(JSON.stringify(e, null, 2));
|
|
122
|
+
else console.log(e.value);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function deleteCommand(args) {
|
|
126
|
+
const opts = parseFlags(args);
|
|
127
|
+
if (!opts.id) {
|
|
128
|
+
console.error(`flo memory delete: missing --id`);
|
|
129
|
+
process.exit(2);
|
|
130
|
+
}
|
|
131
|
+
await deleteEntry({ namespace: opts.namespace, id: opts.id });
|
|
132
|
+
console.log(`flo memory delete: tombstoned ${opts.namespace || 'default'}/${opts.id}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function namespacesCommand(args) {
|
|
136
|
+
const opts = parseFlags(args);
|
|
137
|
+
const stats = await namespaceStats();
|
|
138
|
+
if (opts.json) { console.log(JSON.stringify(stats, null, 2)); return; }
|
|
139
|
+
if (!stats.length) { console.log(`flo memory: no namespaces yet`); return; }
|
|
140
|
+
console.log(`namespace count last entry`);
|
|
141
|
+
console.log(`----------------------- ------ --------------------`);
|
|
142
|
+
for (const s of stats) {
|
|
143
|
+
const ns = s.namespace.padEnd(23).slice(0, 23);
|
|
144
|
+
const count = String(s.count).padStart(6);
|
|
145
|
+
const last = s.lastEntryAt ? s.lastEntryAt.slice(0, 19).replace('T', ' ') : '—';
|
|
146
|
+
console.log(`${ns} ${count} ${last}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Memory store with two backends:
|
|
2
|
+
// - jsonl (default) — file-backed JSONL per namespace under ~/.flo/memory/<ns>.jsonl
|
|
3
|
+
// Pure JS, no native deps, BM25 ranking. Zero install cost.
|
|
4
|
+
// - agentdb — SQLite-backed via @myflo/memory's SqlJsBackend. FTS5
|
|
5
|
+
// keyword search, optional vector embeddings, scales further.
|
|
6
|
+
//
|
|
7
|
+
// Select via FLO_MEMORY_BACKEND env var or { backend } option on each call.
|
|
8
|
+
// Public API is identical regardless of backend.
|
|
9
|
+
|
|
10
|
+
import { mkdir, readFile, writeFile, appendFile, readdir } from 'node:fs/promises';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join, resolve } from 'node:path';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
const FLO_HOME = process.env.FLO_HOME || join(homedir(), '.flo');
|
|
17
|
+
const MEMORY_DIR = join(FLO_HOME, 'memory');
|
|
18
|
+
|
|
19
|
+
function currentBackend(opts) {
|
|
20
|
+
const explicit = opts && opts.backend;
|
|
21
|
+
if (explicit) return explicit;
|
|
22
|
+
return process.env.FLO_MEMORY_BACKEND === 'agentdb' ? 'agentdb' : 'jsonl';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function agentdb() {
|
|
26
|
+
// Lazy-load — only required when actually selecting agentdb backend
|
|
27
|
+
return await import('./memory-backend-agentdb.js');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function nsPath(ns) {
|
|
31
|
+
return join(MEMORY_DIR, `${sanitizeNs(ns)}.jsonl`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sanitizeNs(ns) {
|
|
35
|
+
return String(ns || 'default').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64) || 'default';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function ensureDir() {
|
|
39
|
+
if (!existsSync(MEMORY_DIR)) await mkdir(MEMORY_DIR, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function newId() {
|
|
43
|
+
return `${Date.now()}-${randomBytes(3).toString('hex')}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function storeEntry(input) {
|
|
47
|
+
if (currentBackend(input) === 'agentdb') return (await agentdb()).storeEntry(input);
|
|
48
|
+
return storeEntryJsonl(input);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function storeEntryJsonl({ namespace = 'default', key, value, tags = [], metadata = {} }) {
|
|
52
|
+
await ensureDir();
|
|
53
|
+
const entry = {
|
|
54
|
+
id: newId(),
|
|
55
|
+
namespace: sanitizeNs(namespace),
|
|
56
|
+
key: key ?? null,
|
|
57
|
+
value: String(value ?? ''),
|
|
58
|
+
tags: Array.isArray(tags) ? tags.map(String) : [],
|
|
59
|
+
metadata: metadata && typeof metadata === 'object' ? metadata : {},
|
|
60
|
+
createdAt: new Date().toISOString(),
|
|
61
|
+
deleted: false,
|
|
62
|
+
};
|
|
63
|
+
await appendFile(nsPath(entry.namespace), JSON.stringify(entry) + '\n', 'utf8');
|
|
64
|
+
return entry;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function deleteEntry(input) {
|
|
68
|
+
if (currentBackend(input) === 'agentdb') return (await agentdb()).deleteEntry(input);
|
|
69
|
+
return deleteEntryJsonl(input);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function deleteEntryJsonl({ namespace = 'default', id }) {
|
|
73
|
+
await ensureDir();
|
|
74
|
+
const tomb = { id, namespace: sanitizeNs(namespace), deleted: true, deletedAt: new Date().toISOString() };
|
|
75
|
+
await appendFile(nsPath(tomb.namespace), JSON.stringify(tomb) + '\n', 'utf8');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function readAllEntries(namespace) {
|
|
79
|
+
const ns = sanitizeNs(namespace);
|
|
80
|
+
const file = nsPath(ns);
|
|
81
|
+
if (!existsSync(file)) return [];
|
|
82
|
+
let raw = '';
|
|
83
|
+
try { raw = await readFile(file, 'utf8'); } catch { return []; }
|
|
84
|
+
const live = new Map();
|
|
85
|
+
const tombstones = new Set();
|
|
86
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
87
|
+
if (!line.trim()) continue;
|
|
88
|
+
let row;
|
|
89
|
+
try { row = JSON.parse(line); } catch { continue; }
|
|
90
|
+
if (row.deleted) {
|
|
91
|
+
tombstones.add(row.id);
|
|
92
|
+
live.delete(row.id);
|
|
93
|
+
} else if (!tombstones.has(row.id)) {
|
|
94
|
+
live.set(row.id, row);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return [...live.values()].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function listEntries(opts = {}) {
|
|
101
|
+
if (currentBackend(opts) === 'agentdb') return (await agentdb()).listEntries(opts);
|
|
102
|
+
const { namespace = 'default', limit = 50 } = opts;
|
|
103
|
+
const entries = await readAllEntries(namespace);
|
|
104
|
+
return entries.slice(0, limit);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function getEntry(opts = {}) {
|
|
108
|
+
if (currentBackend(opts) === 'agentdb') return (await agentdb()).getEntry(opts);
|
|
109
|
+
const { namespace = 'default', id, key } = opts;
|
|
110
|
+
const entries = await readAllEntries(namespace);
|
|
111
|
+
if (id) return entries.find((e) => e.id === id) || null;
|
|
112
|
+
if (key) return entries.find((e) => e.key === key) || null;
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// BM25 parameters. Reasonable defaults for short prose entries.
|
|
117
|
+
const BM25_K1 = 1.5;
|
|
118
|
+
const BM25_B = 0.75;
|
|
119
|
+
|
|
120
|
+
// Stopwords pruned aggressively — flo memory entries are short, every
|
|
121
|
+
// non-stopword carries signal. Plurals normalized with a simple suffix rule.
|
|
122
|
+
const STOPWORDS = new Set([
|
|
123
|
+
'a','an','the','is','are','was','were','be','been','being','to','of','in','on','at','for','and','or','but',
|
|
124
|
+
'with','from','by','as','it','its','this','that','these','those','i','you','we','they','he','she','him',
|
|
125
|
+
'her','them','us','our','your','my','their','if','then','else','so','not','no','do','does','did','have',
|
|
126
|
+
'has','had','will','would','can','could','should','may','might','must','shall','about','into','out','up','down',
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
function tokenize(text) {
|
|
130
|
+
if (!text) return [];
|
|
131
|
+
return String(text)
|
|
132
|
+
.toLowerCase()
|
|
133
|
+
.split(/[^a-z0-9_]+/)
|
|
134
|
+
.filter((t) => t && t.length > 1 && !STOPWORDS.has(t))
|
|
135
|
+
.map(stem);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function stem(word) {
|
|
139
|
+
// Tiny rule-based stemmer: drop common English suffixes. Cheap, no Porter
|
|
140
|
+
// library needed for the cardinality flo deals with.
|
|
141
|
+
if (word.length <= 3) return word;
|
|
142
|
+
for (const suffix of ['ization', 'ational', 'tional', 'iveness', 'fulness', 'ousness']) {
|
|
143
|
+
if (word.endsWith(suffix)) return word.slice(0, -suffix.length);
|
|
144
|
+
}
|
|
145
|
+
for (const suffix of ['ization', 'ization', 'ations', 'ations']) {
|
|
146
|
+
if (word.endsWith(suffix)) return word.slice(0, -suffix.length);
|
|
147
|
+
}
|
|
148
|
+
for (const suffix of ['ing', 'ies', 'ied', 'ies', 'ies', 'ous', 'ive']) {
|
|
149
|
+
if (word.endsWith(suffix) && word.length > suffix.length + 2) return word.slice(0, -suffix.length);
|
|
150
|
+
}
|
|
151
|
+
if (word.endsWith('es') && word.length > 4) return word.slice(0, -2);
|
|
152
|
+
if (word.endsWith('s') && !word.endsWith('ss') && word.length > 3) return word.slice(0, -1);
|
|
153
|
+
return word;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function buildCorpusStats(entries) {
|
|
157
|
+
// entries: pre-tokenized [{ id, tokens, tagTokens }]
|
|
158
|
+
const docFreq = new Map();
|
|
159
|
+
let totalLen = 0;
|
|
160
|
+
for (const e of entries) {
|
|
161
|
+
const unique = new Set(e.tokens);
|
|
162
|
+
for (const t of unique) docFreq.set(t, (docFreq.get(t) || 0) + 1);
|
|
163
|
+
totalLen += e.tokens.length;
|
|
164
|
+
}
|
|
165
|
+
const N = entries.length;
|
|
166
|
+
const avgdl = N > 0 ? totalLen / N : 0;
|
|
167
|
+
return { docFreq, N, avgdl };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function bm25Score(queryTokens, doc, stats) {
|
|
171
|
+
if (!queryTokens.length || stats.N === 0) return 0;
|
|
172
|
+
let score = 0;
|
|
173
|
+
// Term frequency table for this doc
|
|
174
|
+
const tf = new Map();
|
|
175
|
+
for (const t of doc.tokens) tf.set(t, (tf.get(t) || 0) + 1);
|
|
176
|
+
const dl = doc.tokens.length || 1;
|
|
177
|
+
for (const q of queryTokens) {
|
|
178
|
+
const f = tf.get(q);
|
|
179
|
+
if (!f) continue;
|
|
180
|
+
const df = stats.docFreq.get(q) || 0;
|
|
181
|
+
// BM25+ idf: log((N - df + 0.5) / (df + 0.5) + 1) — strictly positive
|
|
182
|
+
const idf = Math.log((stats.N - df + 0.5) / (df + 0.5) + 1);
|
|
183
|
+
const norm = 1 - BM25_B + BM25_B * (dl / (stats.avgdl || 1));
|
|
184
|
+
const termScore = idf * ((f * (BM25_K1 + 1)) / (f + BM25_K1 * norm));
|
|
185
|
+
score += termScore;
|
|
186
|
+
}
|
|
187
|
+
return score;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function searchEntries(opts = {}) {
|
|
191
|
+
if (currentBackend(opts) === 'agentdb') return (await agentdb()).searchEntries(opts);
|
|
192
|
+
return searchEntriesJsonl(opts);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function searchEntriesJsonl({ namespace, query, tags = [], limit = 20 }) {
|
|
196
|
+
const namespaces = namespace ? [namespace] : await listNamespaces();
|
|
197
|
+
const queryTokens = tokenize(query);
|
|
198
|
+
const wantTags = new Set(tags.map((t) => String(t).toLowerCase()));
|
|
199
|
+
const out = [];
|
|
200
|
+
|
|
201
|
+
for (const ns of namespaces) {
|
|
202
|
+
const raw = await readAllEntries(ns);
|
|
203
|
+
// Pre-tokenize every entry for IDF stats
|
|
204
|
+
const docs = raw.map((e) => ({
|
|
205
|
+
entry: e,
|
|
206
|
+
tokens: tokenize(`${e.key || ''} ${e.value}`),
|
|
207
|
+
tagSet: new Set((e.tags || []).map((t) => String(t).toLowerCase())),
|
|
208
|
+
}));
|
|
209
|
+
const stats = buildCorpusStats(docs);
|
|
210
|
+
|
|
211
|
+
for (const doc of docs) {
|
|
212
|
+
const tagOverlap = [...wantTags].filter((t) => doc.tagSet.has(t)).length;
|
|
213
|
+
const tagBoost = tagOverlap > 0 ? 2 + tagOverlap * 0.5 : 0;
|
|
214
|
+
const textScore = bm25Score(queryTokens, doc, stats);
|
|
215
|
+
// Optional small key boost — exact key match is highly intentional
|
|
216
|
+
const keyBoost = doc.entry.key && query && doc.entry.key.toLowerCase().includes(String(query).toLowerCase()) ? 1.5 : 0;
|
|
217
|
+
const score = textScore + tagBoost + keyBoost;
|
|
218
|
+
if (score <= 0) continue;
|
|
219
|
+
out.push({ ...doc.entry, _score: Number(score.toFixed(4)) });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
out.sort((a, b) => b._score - a._score);
|
|
223
|
+
return out.slice(0, limit);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Exported for direct testing
|
|
227
|
+
export const _searchInternals = { tokenize, stem, buildCorpusStats, bm25Score };
|
|
228
|
+
|
|
229
|
+
export async function listNamespaces(opts = {}) {
|
|
230
|
+
if (currentBackend(opts) === 'agentdb') return (await agentdb()).listNamespaces();
|
|
231
|
+
await ensureDir();
|
|
232
|
+
const entries = await readdir(MEMORY_DIR);
|
|
233
|
+
return entries
|
|
234
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
235
|
+
.map((f) => f.replace(/\.jsonl$/, ''));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function namespaceStats(opts = {}) {
|
|
239
|
+
if (currentBackend(opts) === 'agentdb') return (await agentdb()).namespaceStats();
|
|
240
|
+
return namespaceStatsJsonl();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function namespaceStatsJsonl() {
|
|
244
|
+
const names = await listNamespaces();
|
|
245
|
+
const out = [];
|
|
246
|
+
for (const ns of names) {
|
|
247
|
+
const entries = await readAllEntries(ns);
|
|
248
|
+
out.push({
|
|
249
|
+
namespace: ns,
|
|
250
|
+
count: entries.length,
|
|
251
|
+
lastEntryAt: entries[0]?.createdAt || null,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
out.sort((a, b) => (a.lastEntryAt && b.lastEntryAt ? (a.lastEntryAt < b.lastEntryAt ? 1 : -1) : 0));
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export const _internal = { FLO_HOME, MEMORY_DIR, sanitizeNs };
|
package/lib/messages.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Reads ~/.flo/messages/<recipient>/ mailbox files written by the inbox bridge.
|
|
2
|
+
|
|
3
|
+
import { readdir, readFile, stat, unlink } from 'node:fs/promises';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
const FLO_HOME = process.env.FLO_HOME || join(homedir(), '.flo');
|
|
9
|
+
const MAILBOX_ROOT = join(FLO_HOME, 'messages');
|
|
10
|
+
|
|
11
|
+
export async function messagesCommand(args) {
|
|
12
|
+
const [sub = 'help', ...rest] = args;
|
|
13
|
+
if (sub === 'help' || sub === '--help' || sub === '-h') return printHelp();
|
|
14
|
+
if (sub === 'list') return listCommand(rest);
|
|
15
|
+
if (sub === 'read') return readCommand(rest);
|
|
16
|
+
if (sub === 'archive') return archiveCommand(rest);
|
|
17
|
+
console.error(`flo messages: unknown subcommand '${sub}'`);
|
|
18
|
+
console.error(`Available: list, read, archive, help`);
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function printHelp() {
|
|
23
|
+
console.log(`flo messages — mailbox reader for inbox-bridged markdown drops
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
flo messages list [<recipient>] [--json]
|
|
27
|
+
flo messages read <recipient> <filename> [--json]
|
|
28
|
+
flo messages archive <recipient> <filename>
|
|
29
|
+
|
|
30
|
+
Mailboxes live at ~/.flo/messages/<recipient>/. The inbox watcher writes
|
|
31
|
+
into them when a .md drop has 'to: <recipient>' in its frontmatter.
|
|
32
|
+
`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function listRecipients() {
|
|
36
|
+
if (!existsSync(MAILBOX_ROOT)) return [];
|
|
37
|
+
const entries = await readdir(MAILBOX_ROOT, { withFileTypes: true });
|
|
38
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function listMessages(recipient) {
|
|
42
|
+
const dir = join(MAILBOX_ROOT, recipient);
|
|
43
|
+
if (!existsSync(dir)) return [];
|
|
44
|
+
const entries = await readdir(dir);
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const name of entries) {
|
|
47
|
+
if (!name.endsWith('.md')) continue;
|
|
48
|
+
const path = join(dir, name);
|
|
49
|
+
try {
|
|
50
|
+
const st = await stat(path);
|
|
51
|
+
out.push({ recipient, filename: name, path, size: st.size, mtime: st.mtimeMs });
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
return out.sort((a, b) => b.mtime - a.mtime);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function listCommand(args) {
|
|
58
|
+
const json = args.includes('--json');
|
|
59
|
+
const recipient = args.find((a) => !a.startsWith('--'));
|
|
60
|
+
if (recipient) {
|
|
61
|
+
const msgs = await listMessages(recipient);
|
|
62
|
+
if (json) { console.log(JSON.stringify(msgs, null, 2)); return; }
|
|
63
|
+
if (!msgs.length) { console.log(`flo messages: no messages for '${recipient}'`); return; }
|
|
64
|
+
for (const m of msgs) {
|
|
65
|
+
console.log(`${m.filename} (${m.size}b, ${new Date(m.mtime).toISOString()})`);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// No recipient: list all
|
|
70
|
+
const recipients = await listRecipients();
|
|
71
|
+
const summary = [];
|
|
72
|
+
for (const r of recipients) {
|
|
73
|
+
const msgs = await listMessages(r);
|
|
74
|
+
summary.push({ recipient: r, count: msgs.length });
|
|
75
|
+
}
|
|
76
|
+
if (json) { console.log(JSON.stringify(summary, null, 2)); return; }
|
|
77
|
+
if (!summary.length) { console.log(`flo messages: no mailboxes yet`); return; }
|
|
78
|
+
console.log(`recipient count`);
|
|
79
|
+
console.log(`----------------------- -----`);
|
|
80
|
+
for (const s of summary) {
|
|
81
|
+
console.log(`${s.recipient.padEnd(23).slice(0, 23)} ${String(s.count).padStart(5)}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function readCommand(args) {
|
|
86
|
+
const json = args.includes('--json');
|
|
87
|
+
const positional = args.filter((a) => !a.startsWith('--'));
|
|
88
|
+
if (positional.length < 2) {
|
|
89
|
+
console.error(`flo messages read: usage: flo messages read <recipient> <filename>`);
|
|
90
|
+
process.exit(2);
|
|
91
|
+
}
|
|
92
|
+
const [recipient, filename] = positional;
|
|
93
|
+
const path = join(MAILBOX_ROOT, recipient, filename);
|
|
94
|
+
if (!existsSync(path)) { console.error(`flo messages read: not found: ${path}`); process.exit(1); }
|
|
95
|
+
const raw = await readFile(path, 'utf8');
|
|
96
|
+
if (json) { console.log(JSON.stringify({ recipient, filename, content: raw })); return; }
|
|
97
|
+
console.log(raw);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function archiveCommand(args) {
|
|
101
|
+
const [recipient, filename] = args.filter((a) => !a.startsWith('--'));
|
|
102
|
+
if (!recipient || !filename) {
|
|
103
|
+
console.error(`flo messages archive: usage: flo messages archive <recipient> <filename>`);
|
|
104
|
+
process.exit(2);
|
|
105
|
+
}
|
|
106
|
+
const path = join(MAILBOX_ROOT, recipient, filename);
|
|
107
|
+
if (!existsSync(path)) { console.error(`flo messages archive: not found: ${path}`); process.exit(1); }
|
|
108
|
+
await unlink(path);
|
|
109
|
+
console.log(`flo messages archive: removed ${filename}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function listAllMailboxes() {
|
|
113
|
+
const recipients = await listRecipients();
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const r of recipients) {
|
|
116
|
+
out.push({ recipient: r, messages: await listMessages(r) });
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
package/lib/migrate.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFile, writeFile, copyFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export async function migrate(args) {
|
|
7
|
+
const parsed = parseArgs(args);
|
|
8
|
+
if (parsed.help) {
|
|
9
|
+
console.log(`flo migrate — register flo in ~/.claude/mcp.json
|
|
10
|
+
|
|
11
|
+
Adds (or updates) an mcpServers.flo entry pointing at the flo CLI mcp endpoint.
|
|
12
|
+
Leaves any existing ruflo/claude-flow entries intact — flo coexists, it does
|
|
13
|
+
not overwrite. Idempotent. Backs up first.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
flo migrate [--mcp-path <path>] [--dry-run]
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--mcp-path <path> Override default ~/.claude/mcp.json location.
|
|
20
|
+
--dry-run Print the diff without writing.
|
|
21
|
+
-h, --help Show this help.
|
|
22
|
+
`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const mcpPath = parsed.mcpPath || join(homedir(), '.claude', 'mcp.json');
|
|
27
|
+
let existing = { mcpServers: {} };
|
|
28
|
+
let raw = '';
|
|
29
|
+
if (existsSync(mcpPath)) {
|
|
30
|
+
raw = await readFile(mcpPath, 'utf8');
|
|
31
|
+
try { existing = JSON.parse(raw); } catch (e) {
|
|
32
|
+
throw new Error(`failed to parse ${mcpPath}: ${e.message}`);
|
|
33
|
+
}
|
|
34
|
+
if (!existing.mcpServers) existing.mcpServers = {};
|
|
35
|
+
} else {
|
|
36
|
+
process.stderr.write(`flo migrate: no existing config at ${mcpPath}, creating fresh\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const desired = {
|
|
40
|
+
command: 'node',
|
|
41
|
+
args: [resolveFloBinPath(), 'mcp', 'start'],
|
|
42
|
+
env: { FLO_MCP: '1' },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const before = JSON.stringify(existing.mcpServers.flo || null);
|
|
46
|
+
existing.mcpServers.flo = desired;
|
|
47
|
+
const after = JSON.stringify(existing.mcpServers.flo);
|
|
48
|
+
|
|
49
|
+
if (before === after) {
|
|
50
|
+
console.log(`flo migrate: already up to date (${mcpPath})`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const next = JSON.stringify(existing, null, 2) + '\n';
|
|
55
|
+
|
|
56
|
+
if (parsed.dryRun) {
|
|
57
|
+
console.log(`# flo migrate (DRY RUN)`);
|
|
58
|
+
console.log(`# would write to: ${mcpPath}`);
|
|
59
|
+
console.log(next);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await mkdir(join(homedir(), '.claude'), { recursive: true });
|
|
64
|
+
if (raw) {
|
|
65
|
+
const backup = `${mcpPath}.flo-bak.${Date.now()}`;
|
|
66
|
+
await copyFile(mcpPath, backup);
|
|
67
|
+
process.stderr.write(`flo migrate: backed up to ${backup}\n`);
|
|
68
|
+
}
|
|
69
|
+
await writeFile(mcpPath, next, 'utf8');
|
|
70
|
+
console.log(`flo migrate: registered 'flo' MCP server at ${mcpPath}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseArgs(args) {
|
|
74
|
+
const out = { help: false, dryRun: false, mcpPath: null };
|
|
75
|
+
for (let i = 0; i < args.length; i++) {
|
|
76
|
+
const a = args[i];
|
|
77
|
+
if (a === '--help' || a === '-h') out.help = true;
|
|
78
|
+
else if (a === '--dry-run') out.dryRun = true;
|
|
79
|
+
else if (a === '--mcp-path') out.mcpPath = args[++i];
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveFloBinPath() {
|
|
85
|
+
const fromEnv = process.env.FLO_BIN;
|
|
86
|
+
if (fromEnv) return fromEnv;
|
|
87
|
+
return new URL('../bin/flo.js', import.meta.url).pathname;
|
|
88
|
+
}
|