becki 0.5.0
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/LICENSE +32 -0
- package/README.md +57 -0
- package/bin/becki.js +31 -0
- package/dist/cli.js +77 -0
- package/dist/client.js +286 -0
- package/dist/commands.js +321 -0
- package/dist/config.js +138 -0
- package/dist/entry.js +14 -0
- package/dist/handoff.js +54 -0
- package/dist/loops.js +127 -0
- package/dist/menu.js +108 -0
- package/dist/prompt.js +121 -0
- package/dist/screens/_shared.js +67 -0
- package/dist/screens/account.js +35 -0
- package/dist/screens/backfills.js +92 -0
- package/dist/screens/diagnostics.js +87 -0
- package/dist/screens/git.js +211 -0
- package/dist/screens/install-token.js +65 -0
- package/dist/screens/logs.js +74 -0
- package/dist/screens/status.js +77 -0
- package/dist/screens/subscription.js +60 -0
- package/dist/screens/vault.js +155 -0
- package/dist/screens/watch-folders.js +406 -0
- package/dist/setup.js +71 -0
- package/dist/splash.js +27 -0
- package/dist/theme.js +38 -0
- package/dist/vault.js +357 -0
- package/package.json +59 -0
package/dist/vault.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vault.ts — local Becki vault read.
|
|
3
|
+
*
|
|
4
|
+
* Derives the splash data straight from the filesystem at ~/Documents/Becki/.
|
|
5
|
+
* No MCP call, no network, no auth.
|
|
6
|
+
*
|
|
7
|
+
* VAULT LAYOUT (verified on disk, 2026-05-18):
|
|
8
|
+
* ~/Documents/Becki/
|
|
9
|
+
* decisions/ *.md — one file per entry, YAML frontmatter
|
|
10
|
+
* dead-ends/ *.md
|
|
11
|
+
* conversations/ *.md
|
|
12
|
+
* meetings/ *.md
|
|
13
|
+
* activity/ *.md
|
|
14
|
+
* projects/<date|name>/... *.md (nested one level)
|
|
15
|
+
* _meta/
|
|
16
|
+
* open-loops.md — AGGREGATE file: a markdown checklist of open loops.
|
|
17
|
+
* "- [ ]" = unresolved, "- [x]" = resolved.
|
|
18
|
+
* commitments/, open-loops/ — exist but empty (loops live in _meta).
|
|
19
|
+
*
|
|
20
|
+
* Entry files carry YAML frontmatter:
|
|
21
|
+
* ---
|
|
22
|
+
* origin: remote-mcp
|
|
23
|
+
* source_type: decision
|
|
24
|
+
* source_id: 2026-05-18-becki-shell-v0-2-reframe
|
|
25
|
+
* created_at: 2026-05-18T13:53:13.191702+00:00
|
|
26
|
+
* synced_at: 2026-05-18T13:53:27Z
|
|
27
|
+
* ---
|
|
28
|
+
* Title is taken from the first "# Heading" line, falling back to source_id.
|
|
29
|
+
*
|
|
30
|
+
* Graceful: any read failure is reported via `warnings` and the rest of the
|
|
31
|
+
* splash still renders. A missing vault never blocks the CLI handoff.
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
/** Per-type subdirectories that hold one entry per .md file. */
|
|
36
|
+
const ENTRY_DIRS = ['decisions', 'dead-ends', 'conversations', 'meetings', 'activity'];
|
|
37
|
+
/** Directories whose entries are nested one level deeper (per-day / per-project folders). */
|
|
38
|
+
const NESTED_ENTRY_DIRS = ['projects'];
|
|
39
|
+
/** The aggregate file that holds open loops. */
|
|
40
|
+
const OPEN_LOOPS_REL = join('_meta', 'open-loops.md');
|
|
41
|
+
/** Parse the YAML frontmatter block at the top of an entry file (shallow, string values only). */
|
|
42
|
+
function parseFrontmatter(text) {
|
|
43
|
+
const fm = {};
|
|
44
|
+
if (!text.startsWith('---'))
|
|
45
|
+
return fm;
|
|
46
|
+
const end = text.indexOf('\n---', 3);
|
|
47
|
+
if (end === -1)
|
|
48
|
+
return fm;
|
|
49
|
+
const block = text.slice(3, end);
|
|
50
|
+
for (const line of block.split('\n')) {
|
|
51
|
+
const idx = line.indexOf(':');
|
|
52
|
+
if (idx === -1)
|
|
53
|
+
continue;
|
|
54
|
+
const key = line.slice(0, idx).trim();
|
|
55
|
+
const val = line.slice(idx + 1).trim();
|
|
56
|
+
if (key)
|
|
57
|
+
fm[key] = val;
|
|
58
|
+
}
|
|
59
|
+
return fm;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Turn an entry slug into a readable title when the file has no "# Heading".
|
|
63
|
+
* Strips a leading "YYYY-MM-DD-" date prefix, swaps hyphens/underscores for
|
|
64
|
+
* spaces, and sentence-cases the result.
|
|
65
|
+
* e.g. "2026-05-18-becki-shell-mvp-built" -> "Becki shell mvp built".
|
|
66
|
+
*/
|
|
67
|
+
function humanizeSlug(slug) {
|
|
68
|
+
const spaced = slug
|
|
69
|
+
.replace(/^\d{4}-\d{2}-\d{2}-/, '')
|
|
70
|
+
.replace(/[-_]+/g, ' ')
|
|
71
|
+
.trim();
|
|
72
|
+
if (!spaced)
|
|
73
|
+
return slug;
|
|
74
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
75
|
+
}
|
|
76
|
+
/** Extract the first markdown "# Heading" line from a file's body. */
|
|
77
|
+
function firstHeading(text) {
|
|
78
|
+
for (const line of text.split('\n')) {
|
|
79
|
+
const t = line.trim();
|
|
80
|
+
if (t.startsWith('# '))
|
|
81
|
+
return t.slice(2).trim();
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Read one entry file into a title + timestamp.
|
|
87
|
+
* Timestamp preference: frontmatter `created_at` → file mtime.
|
|
88
|
+
* Title preference: first "# Heading" → frontmatter `source_id` → filename.
|
|
89
|
+
*/
|
|
90
|
+
function readEntry(filePath, fileName) {
|
|
91
|
+
let text;
|
|
92
|
+
try {
|
|
93
|
+
text = readFileSync(filePath, 'utf8');
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const fm = parseFrontmatter(text);
|
|
99
|
+
let at = null;
|
|
100
|
+
let dated = false;
|
|
101
|
+
if (fm['created_at']) {
|
|
102
|
+
const parsed = new Date(fm['created_at']);
|
|
103
|
+
if (!Number.isNaN(parsed.getTime())) {
|
|
104
|
+
at = parsed;
|
|
105
|
+
dated = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!at) {
|
|
109
|
+
try {
|
|
110
|
+
at = statSync(filePath).mtime;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!at)
|
|
117
|
+
return null;
|
|
118
|
+
const heading = firstHeading(text);
|
|
119
|
+
const title = heading ??
|
|
120
|
+
humanizeSlug(fm['source_id'] ? fm['source_id'] : fileName.replace(/\.md$/i, ''));
|
|
121
|
+
return { title, at, dated };
|
|
122
|
+
}
|
|
123
|
+
/** List .md files directly inside a directory; missing dir yields []. */
|
|
124
|
+
function listMarkdown(dir) {
|
|
125
|
+
if (!existsSync(dir))
|
|
126
|
+
return [];
|
|
127
|
+
try {
|
|
128
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
129
|
+
.filter((d) => d.isFile() && d.name.toLowerCase().endsWith('.md'))
|
|
130
|
+
.map((d) => d.name);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** List .md files nested one directory level deep (e.g. projects/<name>/foo.md). */
|
|
137
|
+
function listNestedMarkdown(dir) {
|
|
138
|
+
if (!existsSync(dir))
|
|
139
|
+
return [];
|
|
140
|
+
const out = [];
|
|
141
|
+
let subdirs;
|
|
142
|
+
try {
|
|
143
|
+
subdirs = readdirSync(dir, { withFileTypes: true })
|
|
144
|
+
.filter((d) => d.isDirectory())
|
|
145
|
+
.map((d) => d.name);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
for (const sub of subdirs) {
|
|
151
|
+
const subPath = join(dir, sub);
|
|
152
|
+
for (const name of listMarkdown(subPath)) {
|
|
153
|
+
out.push({ dirPath: subPath, name });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Parse the open-loops aggregate file into OpenThread records.
|
|
160
|
+
*
|
|
161
|
+
* Format (verified): a flat markdown checklist. Each loop starts a line with
|
|
162
|
+
* "- [ ]" (unresolved) or "- [x]" (resolved). A loop item may span many lines
|
|
163
|
+
* — only the first line is the headline. Loops are listed newest-last in the
|
|
164
|
+
* file, so we reverse to surface the most recent first.
|
|
165
|
+
*
|
|
166
|
+
* Age: many loop lines carry an inline date — either a trailing "— added
|
|
167
|
+
* YYYY-MM-DD" / "— YYYY-MM-DD ..." note or a "(paused YYYY-MM-DD" / "(Month
|
|
168
|
+
* YYYY)" marker. We pull the first YYYY-MM-DD we find on the item's lines;
|
|
169
|
+
* failing that we fall back to the file's "# Open Loops — YYYY-MM-DD" header.
|
|
170
|
+
*/
|
|
171
|
+
function parseOpenLoops(filePath, warnings) {
|
|
172
|
+
let text;
|
|
173
|
+
try {
|
|
174
|
+
text = readFileSync(filePath, 'utf8');
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
warnings.push(`open loops unreadable (${err.message})`);
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
const lines = text.split('\n');
|
|
181
|
+
// File-level fallback date from the "# Open Loops — YYYY-MM-DD" header.
|
|
182
|
+
let headerDate = null;
|
|
183
|
+
const headerMatch = text.match(/^#\s*Open Loops\s*[—-]\s*(\d{4}-\d{2}-\d{2})/m);
|
|
184
|
+
if (headerMatch) {
|
|
185
|
+
const d = new Date(headerMatch[1] + 'T00:00:00Z');
|
|
186
|
+
if (!Number.isNaN(d.getTime()))
|
|
187
|
+
headerDate = d;
|
|
188
|
+
}
|
|
189
|
+
const DATE_RE = /(\d{4}-\d{2}-\d{2})/;
|
|
190
|
+
const threads = [];
|
|
191
|
+
for (let i = 0; i < lines.length; i++) {
|
|
192
|
+
const line = lines[i];
|
|
193
|
+
const m = line.match(/^- \[( |x|X)\]\s?(.*)$/);
|
|
194
|
+
if (!m)
|
|
195
|
+
continue;
|
|
196
|
+
const resolved = m[1].toLowerCase() === 'x';
|
|
197
|
+
if (resolved)
|
|
198
|
+
continue; // only unresolved loops are open threads
|
|
199
|
+
let body = m[2];
|
|
200
|
+
// The leading "↑" is a priority/flag marker — strip it from the title.
|
|
201
|
+
const flagged = body.startsWith('↑');
|
|
202
|
+
if (flagged)
|
|
203
|
+
body = body.replace(/^↑\s*/, '');
|
|
204
|
+
// Collect this item's continuation lines (until the next "- [ ]"/"- [x]").
|
|
205
|
+
const itemLines = [body];
|
|
206
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
207
|
+
if (/^- \[( |x|X)\]/.test(lines[j]))
|
|
208
|
+
break;
|
|
209
|
+
itemLines.push(lines[j]);
|
|
210
|
+
}
|
|
211
|
+
// Title: first non-empty line of the item. Loop lines in the real vault
|
|
212
|
+
// often lead with a date or a status word ("OPEN —", "2026-05-16 —")
|
|
213
|
+
// before the substance. Strip those so the headline is the substance —
|
|
214
|
+
// the date is already surfaced separately as the age column.
|
|
215
|
+
let title = (itemLines.find((l) => l.trim() !== '') ?? body).trim();
|
|
216
|
+
title = title
|
|
217
|
+
.replace(/^Open loop\s*(\([^)]*\))?\s*[:—-]\s*/i, '')
|
|
218
|
+
.replace(/^OPEN\s*[—-]\s*/i, '')
|
|
219
|
+
.replace(/^\d{4}-\d{2}-\d{2}\s*(\([^)]*\))?\s*[:—-]\s*/, '')
|
|
220
|
+
.trim();
|
|
221
|
+
if (title === '')
|
|
222
|
+
title = (itemLines.find((l) => l.trim() !== '') ?? body).trim();
|
|
223
|
+
if (title.length > 96)
|
|
224
|
+
title = title.slice(0, 95).trimEnd() + '…';
|
|
225
|
+
// Age: first date found on the item's lines, else the file header date.
|
|
226
|
+
let date = headerDate;
|
|
227
|
+
for (const l of itemLines) {
|
|
228
|
+
const dm = l.match(DATE_RE);
|
|
229
|
+
if (dm) {
|
|
230
|
+
const d = new Date(dm[1] + 'T00:00:00Z');
|
|
231
|
+
if (!Number.isNaN(d.getTime())) {
|
|
232
|
+
date = d;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
let ageDays = null;
|
|
238
|
+
if (date) {
|
|
239
|
+
const ms = Date.now() - date.getTime();
|
|
240
|
+
ageDays = Math.max(0, Math.floor(ms / 86_400_000));
|
|
241
|
+
}
|
|
242
|
+
threads.push({ title, flagged, ageDays });
|
|
243
|
+
}
|
|
244
|
+
// Newest-first: the file appends new loops at the bottom, and loops with a
|
|
245
|
+
// smaller age (more recent date) should lead. Unknown ages sort last.
|
|
246
|
+
threads.reverse();
|
|
247
|
+
threads.sort((a, b) => {
|
|
248
|
+
if (a.ageDays === null && b.ageDays === null)
|
|
249
|
+
return 0;
|
|
250
|
+
if (a.ageDays === null)
|
|
251
|
+
return 1;
|
|
252
|
+
if (b.ageDays === null)
|
|
253
|
+
return -1;
|
|
254
|
+
return a.ageDays - b.ageDays;
|
|
255
|
+
});
|
|
256
|
+
return threads;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Read the vault and derive everything the splash needs.
|
|
260
|
+
* `maxOpenThreads` caps the open-threads list. `showOpenThreads` skips the
|
|
261
|
+
* (cheap, but pointless) loop parse when the splash won't show them.
|
|
262
|
+
*
|
|
263
|
+
* Never throws. A missing or unreadable vault returns ok:false with a warning.
|
|
264
|
+
*/
|
|
265
|
+
export function readVault(vaultPath, maxOpenThreads, showOpenThreads) {
|
|
266
|
+
const warnings = [];
|
|
267
|
+
const data = {
|
|
268
|
+
ok: false,
|
|
269
|
+
path: vaultPath,
|
|
270
|
+
entryCount: 0,
|
|
271
|
+
lastEntryTitle: null,
|
|
272
|
+
lastEntryAt: null,
|
|
273
|
+
openThreads: [],
|
|
274
|
+
openThreadsTotal: 0,
|
|
275
|
+
warnings,
|
|
276
|
+
};
|
|
277
|
+
if (!existsSync(vaultPath)) {
|
|
278
|
+
warnings.push('vault: not found — launching anyway');
|
|
279
|
+
return data;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
if (!statSync(vaultPath).isDirectory()) {
|
|
283
|
+
warnings.push('vault: path is not a directory — launching anyway');
|
|
284
|
+
return data;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
warnings.push(`vault: unreadable (${err.message}) — launching anyway`);
|
|
289
|
+
return data;
|
|
290
|
+
}
|
|
291
|
+
data.ok = true;
|
|
292
|
+
// --- Entry count + last ingested ---------------------------------------
|
|
293
|
+
let newest = null;
|
|
294
|
+
let count = 0;
|
|
295
|
+
for (const dir of ENTRY_DIRS) {
|
|
296
|
+
const dirPath = join(vaultPath, dir);
|
|
297
|
+
for (const name of listMarkdown(dirPath)) {
|
|
298
|
+
count++;
|
|
299
|
+
const entry = readEntry(join(dirPath, name), name);
|
|
300
|
+
if (entry?.dated && (!newest || entry.at > newest.at))
|
|
301
|
+
newest = entry;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
for (const dir of NESTED_ENTRY_DIRS) {
|
|
305
|
+
const dirPath = join(vaultPath, dir);
|
|
306
|
+
for (const { dirPath: subPath, name } of listNestedMarkdown(dirPath)) {
|
|
307
|
+
count++;
|
|
308
|
+
const entry = readEntry(join(subPath, name), name);
|
|
309
|
+
if (entry?.dated && (!newest || entry.at > newest.at))
|
|
310
|
+
newest = entry;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
data.entryCount = count;
|
|
314
|
+
if (newest) {
|
|
315
|
+
data.lastEntryTitle = newest.title;
|
|
316
|
+
data.lastEntryAt = newest.at;
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
warnings.push('vault: no entries found');
|
|
320
|
+
}
|
|
321
|
+
// --- Open threads ------------------------------------------------------
|
|
322
|
+
if (showOpenThreads) {
|
|
323
|
+
const loopsPath = join(vaultPath, OPEN_LOOPS_REL);
|
|
324
|
+
if (existsSync(loopsPath)) {
|
|
325
|
+
const all = parseOpenLoops(loopsPath, warnings);
|
|
326
|
+
data.openThreadsTotal = all.length;
|
|
327
|
+
data.openThreads = all.slice(0, Math.max(0, maxOpenThreads));
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
warnings.push('vault: no open-loops file (_meta/open-loops.md)');
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return data;
|
|
334
|
+
}
|
|
335
|
+
/** Human-friendly relative age, e.g. "2h ago", "3d ago", "just now". */
|
|
336
|
+
export function relativeAge(at) {
|
|
337
|
+
if (!at)
|
|
338
|
+
return 'unknown';
|
|
339
|
+
const ms = Date.now() - at.getTime();
|
|
340
|
+
if (ms < 0)
|
|
341
|
+
return 'just now';
|
|
342
|
+
const mins = Math.floor(ms / 60_000);
|
|
343
|
+
if (mins < 1)
|
|
344
|
+
return 'just now';
|
|
345
|
+
if (mins < 60)
|
|
346
|
+
return `${mins}m ago`;
|
|
347
|
+
const hours = Math.floor(mins / 60);
|
|
348
|
+
if (hours < 24)
|
|
349
|
+
return `${hours}h ago`;
|
|
350
|
+
const days = Math.floor(hours / 24);
|
|
351
|
+
if (days < 30)
|
|
352
|
+
return `${days}d ago`;
|
|
353
|
+
const months = Math.floor(days / 30);
|
|
354
|
+
if (months < 12)
|
|
355
|
+
return `${months}mo ago`;
|
|
356
|
+
return `${Math.floor(months / 12)}y ago`;
|
|
357
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "becki",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Becki — cross-platform admin TUI for the Becki Core memory layer. Manage watch folders, run backfills, copy install tokens, check subscription, all from the terminal. Pairs with becki-mcp.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"becki",
|
|
7
|
+
"mcp",
|
|
8
|
+
"memory",
|
|
9
|
+
"ai",
|
|
10
|
+
"tui",
|
|
11
|
+
"ink",
|
|
12
|
+
"cli",
|
|
13
|
+
"neuravault",
|
|
14
|
+
"claude",
|
|
15
|
+
"cursor",
|
|
16
|
+
"codex"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://www.becki.io",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/bdsantosDEV/becki-vault/issues",
|
|
21
|
+
"email": "support@becki.io"
|
|
22
|
+
},
|
|
23
|
+
"license": "UNLICENSED",
|
|
24
|
+
"author": {
|
|
25
|
+
"name": "BECKI.IO LLC",
|
|
26
|
+
"url": "https://www.becki.io"
|
|
27
|
+
},
|
|
28
|
+
"type": "module",
|
|
29
|
+
"bin": {
|
|
30
|
+
"becki": "bin/becki.js"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"bin/",
|
|
34
|
+
"dist/",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc",
|
|
43
|
+
"bundle": "node scripts/bundle.mjs",
|
|
44
|
+
"start": "node bin/becki.js",
|
|
45
|
+
"prepublishOnly": "npm run build"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"better-sqlite3": "^12.10.0",
|
|
49
|
+
"ink": "^5.0.1",
|
|
50
|
+
"react": "^18.3.1"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
54
|
+
"@types/node": "^25.9.0",
|
|
55
|
+
"@types/react": "^18.3.3",
|
|
56
|
+
"esbuild": "^0.28.0",
|
|
57
|
+
"typescript": "^5.5.4"
|
|
58
|
+
}
|
|
59
|
+
}
|