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/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
+ }