@mnemahq/cli 0.1.1 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemahq/cli",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Mnema CLI — connect a repo to your Mnema workspace: install session capture, sweep past sessions, and search from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,9 +29,12 @@
29
29
  "devDependencies": {
30
30
  "vitest": "^3.2.7"
31
31
  },
32
+ "dependencies": {
33
+ "@mnemahq/sdk": "0.3.1"
34
+ },
32
35
  "scripts": {
33
36
  "build": "node -e \"process.exit(0)\"",
34
- "typecheck": "node --check bin/mnema.mjs && node --check src/cli.mjs && node --check src/util.mjs && node --check src/secrets.mjs",
37
+ "typecheck": "node --check bin/mnema.mjs && node --check src/cli.mjs && node --check src/util.mjs && node --check src/secrets.mjs && node --check src/client.mjs && node --check src/read-commands.mjs",
35
38
  "test": "vitest run"
36
39
  }
37
40
  }
package/src/cli.mjs CHANGED
@@ -12,6 +12,10 @@
12
12
 
13
13
  import { existsSync } from 'node:fs';
14
14
  import { cmdLogin, cmdLogout, accessToken } from './login.mjs';
15
+ import { makeClient, canAuthenticate, renderError } from './client.mjs';
16
+ import {
17
+ cmdTasks, cmdNext, cmdDocs, cmdDoc, cmdProjects, cmdAsk, cmdGraph, cmdBriefing,
18
+ } from './read-commands.mjs';
15
19
  import {
16
20
  DEFAULT_ORIGIN, DEFAULT_APP_URL, c, gitInfo, canonicalRepo, readConfig, writeConfig, removeConfigDir,
17
21
  apiFetch, prompt, promptHidden, localSessionsForRepo,
@@ -71,14 +75,13 @@ function fmtAge(mtimeMs) {
71
75
  */
72
76
  async function workspaceFromSession(origin) {
73
77
  try {
74
- const token = await accessToken();
75
- if (!token) return null;
76
- const res = await fetch(`${origin}/api/me`, { headers: { Authorization: `Bearer ${token}` } });
77
- if (!res.ok) return null;
78
- const body = await res.json();
79
- if (!body?.workspace_id) return null;
80
- console.log(c.green(`\u2713 Workspace: ${body.workspace_name || body.workspace_id} (from your login)`));
81
- return body.workspace_id;
78
+ if (!(await accessToken())) return null;
79
+ // Via the SDK: /api/public/v1/me returns camelCase, and the SDK is the only
80
+ // thing that should know that.
81
+ const me = await makeClient({ origin }).me();
82
+ if (!me?.workspaceId) return null;
83
+ console.log(c.green(`\u2713 Workspace: ${me.workspaceName || me.workspaceId} (from your login)`));
84
+ return me.workspaceId;
82
85
  } catch {
83
86
  return null;
84
87
  }
@@ -204,15 +207,24 @@ async function cmdSessions(flags) {
204
207
  console.log(` ${s.sessionId.slice(0, 8)}… ${fmtAge(s.mtimeMs).padStart(8)} ${c.dim((s.sizeBytes / 1024).toFixed(0) + ' KB')}`);
205
208
  }
206
209
 
207
- const apiKey = workspaceId ? getSecret(workspaceId, 'api-key') : null;
208
- if (!apiKey) { console.log(c.dim('\n (add an API key via `mnema init` to see server-side cost + status)')); return; }
210
+ if (!(await canAuthenticate(workspaceId))) {
211
+ console.log(c.dim('\n (run `mnema login` — or add an API key to see server-side cost + status)'));
212
+ return;
213
+ }
209
214
 
210
215
  const repo = canonicalRepo(git.remote);
211
- const q = new URLSearchParams({ limit: String(limit) });
212
- if (repo) q.set('repo', repo);
213
- const r = await apiFetch(origin, `/api/public/v1/sessions?${q}`, { token: apiKey });
214
- if (!r.ok) { console.log(c.yellow(`\n server sessions unavailable (HTTP ${r.status})`)); return; }
215
- const rows = r.json?.data?.sessions ?? [];
216
+ let rows = [];
217
+ try {
218
+ rows = await makeClient({ origin, workspaceId })
219
+ .sessions.list({ limit, ...(repo ? { repo } : {}) })
220
+ .all();
221
+ } catch (e) {
222
+ // Non-fatal by design: the LOCAL list above is the useful half and has
223
+ // already printed. Degrading here is deliberate, and it says which wall it
224
+ // hit rather than a bare HTTP number.
225
+ console.log(c.yellow(`\n server sessions unavailable — ${e?.message ?? e}`));
226
+ return;
227
+ }
216
228
  console.log(c.bold(`\nServer sessions (${rows.length})`));
217
229
  for (const s of rows) {
218
230
  const cost = typeof s.totalCostUsd === 'number' ? `$${s.totalCostUsd.toFixed(4)}` : '$0';
@@ -241,12 +253,15 @@ async function cmdSearch(flags, rest) {
241
253
  const { origin, workspaceId } = resolveContext(flags);
242
254
  const query = rest.join(' ').trim();
243
255
  if (!query) { console.error(c.red('Usage: mnema search "<query>"')); process.exit(1); }
244
- const apiKey = workspaceId ? getSecret(workspaceId, 'api-key') : (process.env.MNEMA_API_KEY || null);
245
- if (!apiKey) { console.error(c.red('Search needs an API key. Run `mnema init` and provide one.')); process.exit(1); }
256
+ if (!(await canAuthenticate(workspaceId))) {
257
+ console.error(c.red('Search needs a credential run `mnema login`, or `mnema init` with an API key.'));
258
+ process.exit(1);
259
+ }
246
260
 
247
- const r = await apiFetch(origin, `/api/public/v1/docs/search?q=${encodeURIComponent(query)}`, { token: apiKey });
248
- if (!r.ok) { console.error(c.red(`Search failed (HTTP ${r.status}): ${r.json?.error?.message || r.text || ''}`)); process.exit(1); }
249
- const results = r.json?.data?.results ?? [];
261
+ let results = [];
262
+ try {
263
+ results = await makeClient({ origin, workspaceId }).docs.search(query);
264
+ } catch (e) { renderError(e, { context: 'search' }); }
250
265
  if (!results.length) { console.log(c.dim('No results.')); return; }
251
266
  console.log(c.bold(`${results.length} result(s) for "${query}"`));
252
267
  for (const d of results) {
@@ -261,12 +276,17 @@ async function cmdPull(flags) {
261
276
  const { git, root, origin, workspaceId } = resolveContext(flags);
262
277
  const repo = flags.repo || canonicalRepo(git.remote);
263
278
  if (!repo) { console.error(c.red('No git remote to identify this repo. Set one, or pass --repo <url>.')); process.exit(1); }
264
- const apiKey = (workspaceId && getSecret(workspaceId, 'api-key')) || process.env.MNEMA_API_KEY;
265
- if (!apiKey) { console.error(c.red('`mnema pull` needs an API key. Run `mnema init` and provide one.')); process.exit(1); }
279
+ if (!(await canAuthenticate(workspaceId))) {
280
+ console.error(c.red('`mnema pull` needs a credential run `mnema login`, or `mnema init` with an API key.'));
281
+ process.exit(1);
282
+ }
266
283
 
267
- const r = await apiFetch(origin, `/api/public/v1/repo-context?repo=${encodeURIComponent(repo)}`, { token: apiKey });
268
- if (!r.ok) { console.error(c.red(`Pull failed (HTTP ${r.status}): ${r.json?.error?.message || r.text || ''}`)); process.exit(1); }
269
- const docs = r.json?.data?.context ?? [];
284
+ let docs = [];
285
+ try {
286
+ // `.context`, not the whole result — `.repo` is the URL the server matched.
287
+ // Reading the wrong one of these two is the bug this rewire found in the SDK.
288
+ docs = (await makeClient({ origin, workspaceId }).repos.context(repo)).context ?? [];
289
+ } catch (e) { renderError(e, { context: 'pull' }); }
270
290
 
271
291
  scaffold(root);
272
292
  const s = applyContext(root, docs);
@@ -308,7 +328,8 @@ async function cmdDoctor(flags) {
308
328
 
309
329
  const apiKey = workspaceId ? getSecret(workspaceId, 'api-key') : null;
310
330
  if (apiKey) {
311
- let keyOk = false; try { keyOk = (await apiFetch(origin, '/api/public/v1/docs?limit=1', { token: apiKey })).ok; } catch { /* */ }
331
+ let keyOk = false;
332
+ try { await makeClient({ origin, workspaceId }).docs.list({ limit: 1 }).pages().next(); keyOk = true; } catch { /* */ }
312
333
  ok('API key valid', keyOk);
313
334
  } else {
314
335
  ok('API key stored', false, 'optional — needed for search/sessions');
@@ -359,10 +380,23 @@ Commands:
359
380
  doctor Diagnose install, hooks, auth, connectivity
360
381
  uninstall Remove hooks and stored secrets
361
382
 
383
+ Read your workspace:
384
+ tasks List tasks [--status --project --limit]
385
+ next The next task to pick up, with a ready-made branch name
386
+ docs List documents [--limit]
387
+ doc <id> Print one document as markdown
388
+ projects List projects
389
+ briefing What deserves attention — pulse, deltas, findings
390
+
391
+ Ask the knowledge graph (paid feature):
392
+ ask "q" A cited answer, with the confidence it deserves
393
+ graph <a> [b] Neighbourhood of a, or the shortest path from a to b
394
+
362
395
  Options:
363
396
  --workspace <id> Workspace id (else prompted / MNEMA_WORKSPACE_ID)
364
397
  --origin <url> API origin (default ${DEFAULT_ORIGIN})
365
- --limit <n> Row limit for sessions
398
+ --limit <n> Row limit
399
+ --json Machine-readable output (every read command)
366
400
  --yes Non-interactive; skip optional prompts
367
401
  --purge uninstall: also delete .mnema/config.json
368
402
  --version, --help
@@ -384,6 +418,17 @@ export async function run(argv) {
384
418
  case 'search': return cmdSearch(flags, rest);
385
419
  case 'doctor': return cmdDoctor(flags);
386
420
  case 'uninstall': return cmdUninstall(flags);
421
+
422
+ // Reads. Each resolves context once and hands the SDK client to a wrapper;
423
+ // none of them knows a URL or an envelope key (§A2, t-623).
424
+ case 'tasks': return cmdTasks(flags, resolveContext(flags));
425
+ case 'next': return cmdNext(flags, resolveContext(flags));
426
+ case 'docs': return cmdDocs(flags, resolveContext(flags));
427
+ case 'doc': return cmdDoc(flags, resolveContext(flags), rest);
428
+ case 'projects': return cmdProjects(flags, resolveContext(flags));
429
+ case 'ask': return cmdAsk(flags, resolveContext(flags), rest);
430
+ case 'graph': return cmdGraph(flags, resolveContext(flags), rest);
431
+ case 'briefing': return cmdBriefing(flags, resolveContext(flags));
387
432
  case undefined:
388
433
  case 'help': return help();
389
434
  default:
package/src/client.mjs ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The one place the CLI talks to Mnema — through @mnemahq/sdk (§A2).
3
+ *
4
+ * ⭐ WHY THE RULE EXISTS, DEMONSTRATED THE HOUR IT WAS FOLLOWED. Rewiring the
5
+ * first command onto the SDK immediately turned up that `repos.context()` had
6
+ * been unwrapping the wrong envelope key since it shipped — it returned the repo
7
+ * URL string instead of the documents. The CLI's own raw fetch read the right key
8
+ * and had been right all along. An SDK its own CLI routes around has no first
9
+ * user, so its mistakes are found by everyone else instead.
10
+ *
11
+ * ⚠️ NOT EVERY REQUEST BELONGS HERE. `/install/mnema-hook.mjs` is a static asset,
12
+ * not an API call, and stays on plain fetch in hook-install.mjs. Routing it
13
+ * through a typed API client would imply an envelope and a credential that the
14
+ * endpoint neither sends nor wants.
15
+ */
16
+
17
+ import { Mnema } from '@mnemahq/sdk';
18
+ import { getSecret } from './secrets.mjs';
19
+ import { accessToken } from './login.mjs';
20
+ import { c, DEFAULT_APP_URL } from './util.mjs';
21
+
22
+ /**
23
+ * A client carrying whichever credential this machine actually has.
24
+ *
25
+ * Order is deliberate: an API key is explicit, scoped, and what CI uses, so it
26
+ * wins. The OAuth token from `mnema login` is the human fallback. `getToken` is
27
+ * passed as a FUNCTION rather than a resolved string so the SDK re-reads it per
28
+ * request — that is what makes a refresh mid-command work instead of failing on
29
+ * a token that expired thirty seconds ago.
30
+ */
31
+ export function makeClient({ origin, workspaceId }) {
32
+ const apiKey = (workspaceId && getSecret(workspaceId, 'api-key')) || process.env.MNEMA_API_KEY || null;
33
+ return new Mnema(
34
+ apiKey
35
+ ? { apiKey, baseUrl: origin }
36
+ : { getToken: accessToken, baseUrl: origin },
37
+ );
38
+ }
39
+
40
+ /** True when an explicit API key is configured. Cheap; no network. */
41
+ export function hasApiKey(workspaceId) {
42
+ return Boolean((workspaceId && getSecret(workspaceId, 'api-key')) || process.env.MNEMA_API_KEY);
43
+ }
44
+
45
+ /**
46
+ * True when this machine can authenticate AT ALL — by key or by login.
47
+ *
48
+ * ⭐ THE COMMANDS USED TO DEMAND A KEY, and that was a leftover from before the
49
+ * public API accepted both. `/api/public/v1` has taken an OAuth access token
50
+ * since the login flow shipped, so "Search needs an API key" was refusing work
51
+ * the server would happily have done for someone who had already run
52
+ * `mnema login`. Asking a person to go generate a credential they do not need is
53
+ * the kind of friction that makes a tool feel broken.
54
+ */
55
+ export async function canAuthenticate(workspaceId) {
56
+ if (hasApiKey(workspaceId)) return true;
57
+ try { return Boolean(await accessToken()); } catch { return false; }
58
+ }
59
+
60
+ /**
61
+ * Turn a thrown SDK error into something a person can act on, and exit.
62
+ *
63
+ * ⭐ THIS IS THE POINT OF THE REWIRE, not a nicety. Every failure used to print
64
+ * `HTTP 402` and a raw body — which tells you a wall exists and nothing about
65
+ * which one. The hierarchy already knows: whether you need to log in, upgrade,
66
+ * top up, wait, or accept that this server does not have the feature at all.
67
+ * Four of those five have different fixes and the fifth cannot be fixed by
68
+ * paying.
69
+ *
70
+ * Errors are matched by NAME rather than instanceof: the CLI may load a
71
+ * different copy of @mnemahq/core than the SDK did (npm dedupes, pnpm does not
72
+ * always), and a cross-realm instanceof silently falls through to the generic
73
+ * branch — turning a good message back into a bad one for the exact reason this
74
+ * function exists.
75
+ */
76
+ export function renderError(e, { context = '' } = {}) {
77
+ const name = e?.constructor?.name ?? '';
78
+ const where = context ? `${context}: ` : '';
79
+
80
+ switch (name) {
81
+ case 'AuthError':
82
+ console.error(c.red(`${where}not authenticated.`));
83
+ console.error(` ${e.fix ?? 'Run `mnema login`.'}`);
84
+ break;
85
+
86
+ case 'PlanRequiredError':
87
+ console.error(c.yellow(`${where}your ${e.plan ?? 'current'} plan does not include ${e.feature ?? 'this'}.`));
88
+ console.error(` Needs the ${e.required ?? 'a paid'} plan.`);
89
+ console.error(` ${c.dim(e.upgradeUrl ?? `${DEFAULT_APP_URL}/app/settings/billing`)}`);
90
+ break;
91
+
92
+ case 'FeatureUnavailableError':
93
+ // No upgrade link here on purpose. There is nothing to buy: this server's
94
+ // build does not contain the feature, and pointing at billing would sell
95
+ // someone a plan that cannot change their binary.
96
+ console.error(c.yellow(`${where}${e.feature ?? 'that feature'} is not available on this server.`));
97
+ console.error(` ${c.dim(`This is a ${e.edition ?? 'core'} build — the enterprise modules are not installed.`)}`);
98
+ break;
99
+
100
+ case 'InsufficientCreditError': {
101
+ const need = typeof e.topUpNeededCents === 'number' ? `$${(e.topUpNeededCents / 100).toFixed(2)}` : 'more credit';
102
+ console.error(c.yellow(`${where}not enough credit for ${e.action ?? 'this action'}.`));
103
+ console.error(` Top up ${need}. ${c.dim(`${DEFAULT_APP_URL}/app/settings/wallet`)}`);
104
+ break;
105
+ }
106
+
107
+ case 'RateLimitError': {
108
+ const secs = typeof e.retryAfterMs === 'number' ? Math.ceil(e.retryAfterMs / 1000) : null;
109
+ console.error(c.yellow(`${where}rate limited${secs ? ` — retry in ${secs}s` : ''}.`));
110
+ break;
111
+ }
112
+
113
+ case 'ValidationError':
114
+ console.error(c.red(`${where}${e.message}`));
115
+ if (e.fields) for (const [f, msgs] of Object.entries(e.fields)) console.error(` ${f}: ${[].concat(msgs).join(', ')}`);
116
+ break;
117
+
118
+ case 'NetworkError':
119
+ console.error(c.red(`${where}could not reach the server.`));
120
+ console.error(` ${c.dim(e.message)}`);
121
+ break;
122
+
123
+ default:
124
+ console.error(c.red(`${where}${e?.message ?? String(e)}`));
125
+ if (e?.status) console.error(` ${c.dim(`HTTP ${e.status}`)}`);
126
+ }
127
+ process.exit(1);
128
+ }
package/src/keychain.mjs CHANGED
@@ -181,8 +181,19 @@ export function deleteSecret(account) {
181
181
  }
182
182
 
183
183
  /** Where the CLI keeps non-secret login state (origin, workspace, expiry). */
184
+ /**
185
+ * Where the CLI keeps non-secret login state.
186
+ *
187
+ * ⚠️ MNEMA_STATE_DIR EXISTS BECAUSE THE TEST SUITE ATE A REAL LOGIN. keychain's
188
+ * own test calls writeState({ origin: 'https://api.example', … }) to check the
189
+ * file round-trips — and with the path hard-wired to homedir() that wrote over
190
+ * the developer's actual ~/.mnema/auth.json. Running `pnpm test` silently
191
+ * repointed a working CLI at a fake host; the next command failed with
192
+ * `getaddrinfo api.example`, which reads like a network fault and is nothing of
193
+ * the kind. Tests must not be able to reach a real home directory.
194
+ */
184
195
  export function statePath() {
185
- const dir = join(homedir(), '.mnema');
196
+ const dir = process.env.MNEMA_STATE_DIR || join(homedir(), '.mnema');
186
197
  mkdirSync(dir, { recursive: true });
187
198
  return join(dir, 'auth.json');
188
199
  }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The read commands — tasks, docs, projects, ask, graph, briefing (t-624).
3
+ *
4
+ * ⭐ EVERY ONE OF THESE IS A WRAPPER, NOT A CLIENT. They exist because t-623 put
5
+ * the CLI on @mnemahq/sdk; each is four lines of SDK call plus formatting, and
6
+ * none of them knows a URL, an envelope key or a header. That is the payoff §A2
7
+ * was arguing for: the surface grew without the transport growing with it.
8
+ *
9
+ * ⚠️ THE FORMATTING IS THE PART WITH JUDGEMENT IN IT, and two of these can lie by
10
+ * omission if it is done carelessly:
11
+ *
12
+ * ask the interpreter HEDGES below its confidence threshold. Printing the
13
+ * sentence without the number turns a hedge into a claim, which is
14
+ * worse than printing nothing.
15
+ * briefing a short list has three quite different causes and only one is good
16
+ * news. On a core build six of seven finding families cannot produce
17
+ * anything at all, and `coverage.notice` is the only thing that says so.
18
+ *
19
+ * --json everywhere: a CLI you cannot pipe is half a CLI, and the SDK already
20
+ * returns the shape worth emitting, so it costs one line each.
21
+ */
22
+
23
+ import { makeClient, canAuthenticate, renderError } from './client.mjs';
24
+ import { c } from './util.mjs';
25
+
26
+ /** Machine output is the whole object; humans get the formatted view. */
27
+ function emit(flags, value, render) {
28
+ if (flags.json) { console.log(JSON.stringify(value, null, 2)); return; }
29
+ render();
30
+ }
31
+
32
+ async function guard(workspaceId) {
33
+ if (await canAuthenticate(workspaceId)) return;
34
+ console.error(c.red('Not authenticated — run `mnema login`, or `mnema init` with an API key.'));
35
+ process.exit(1);
36
+ }
37
+
38
+ const trunc = (s, n) => (s && s.length > n ? `${s.slice(0, n - 1)}…` : (s ?? ''));
39
+
40
+ // ── tasks ─────────────────────────────────────────────────────────────────────
41
+ export async function cmdTasks(flags, ctx) {
42
+ await guard(ctx.workspaceId);
43
+ const limit = Number(flags.limit) || 20;
44
+ try {
45
+ const rows = await makeClient(ctx).tasks.list({
46
+ limit,
47
+ ...(flags.status ? { status: flags.status } : {}),
48
+ ...(flags.project ? { project: flags.project } : {}),
49
+ }).all();
50
+ emit(flags, rows, () => {
51
+ if (!rows.length) return console.log(c.dim('No tasks match.'));
52
+ console.log(c.bold(`${rows.length} task(s)`));
53
+ for (const t of rows) {
54
+ const id = (t.publicId || t.id.slice(0, 8)).padEnd(8);
55
+ console.log(` ${c.cyan(id)} ${String(t.status).padEnd(12)} ${c.dim(String(t.priority).padEnd(8))} ${trunc(t.title, 70)}`);
56
+ }
57
+ });
58
+ } catch (e) { renderError(e, { context: 'tasks' }); }
59
+ }
60
+
61
+ export async function cmdNext(flags, ctx) {
62
+ await guard(ctx.workspaceId);
63
+ try {
64
+ const t = await makeClient(ctx).tasks.next();
65
+ emit(flags, t, () => {
66
+ if (!t) return console.log(c.dim('Nothing queued — the board is clear.'));
67
+ console.log(`${c.bold(t.publicId || t.id)} ${t.title}`);
68
+ if (t.description) console.log(c.dim(trunc(t.description.replace(/\s+/g, ' '), 300)));
69
+ // The branch name is the load-bearing part of this project's dev loop, so
70
+ // the CLI hands it over ready to paste rather than making it be derived.
71
+ if (t.publicId) {
72
+ const slug = t.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/g, '');
73
+ console.log(`\n ${c.dim('branch:')} git checkout -b ${t.publicId}${slug ? `-${slug}` : ''}`);
74
+ }
75
+ });
76
+ } catch (e) { renderError(e, { context: 'next' }); }
77
+ }
78
+
79
+ // ── docs ──────────────────────────────────────────────────────────────────────
80
+ export async function cmdDocs(flags, ctx) {
81
+ await guard(ctx.workspaceId);
82
+ const limit = Number(flags.limit) || 20;
83
+ try {
84
+ const rows = await makeClient(ctx).docs.list({ limit }).all();
85
+ emit(flags, rows, () => {
86
+ if (!rows.length) return console.log(c.dim('No docs.'));
87
+ console.log(c.bold(`${rows.length} doc(s)`));
88
+ for (const d of rows) {
89
+ console.log(` ${c.dim(d.id.slice(0, 8))} ${trunc(d.title, 60).padEnd(60)} ${c.dim(String(d.updatedAt).slice(0, 10))}`);
90
+ }
91
+ });
92
+ } catch (e) { renderError(e, { context: 'docs' }); }
93
+ }
94
+
95
+ export async function cmdDoc(flags, ctx, rest) {
96
+ await guard(ctx.workspaceId);
97
+ const id = rest[0];
98
+ if (!id) { console.error(c.red('Usage: mnema doc <id>')); process.exit(1); }
99
+ try {
100
+ const d = await makeClient(ctx).docs.get(id);
101
+ // Markdown goes to stdout unadorned so `mnema doc <id> > file.md` works and
102
+ // the heading is not silently baked into the file.
103
+ emit(flags, d, () => {
104
+ console.error(c.bold(d.title));
105
+ console.error(c.dim(`${d.path} · updated ${String(d.updatedAt).slice(0, 10)}`));
106
+ if (d.markdown) console.log(d.markdown);
107
+ });
108
+ } catch (e) { renderError(e, { context: 'doc' }); }
109
+ }
110
+
111
+ // ── projects ──────────────────────────────────────────────────────────────────
112
+ export async function cmdProjects(flags, ctx) {
113
+ await guard(ctx.workspaceId);
114
+ try {
115
+ const rows = await makeClient(ctx).projects.list().all();
116
+ emit(flags, rows, () => {
117
+ if (!rows.length) return console.log(c.dim('No projects.'));
118
+ console.log(c.bold(`${rows.length} project(s)`));
119
+ for (const p of rows) console.log(` ${c.dim(p.id.slice(0, 8))} ${trunc(p.name, 50)}`);
120
+ });
121
+ } catch (e) { renderError(e, { context: 'projects' }); }
122
+ }
123
+
124
+ // ── graph: ask + traverse ─────────────────────────────────────────────────────
125
+ export async function cmdAsk(flags, ctx, rest) {
126
+ await guard(ctx.workspaceId);
127
+ const q = rest.join(' ').trim();
128
+ if (!q) { console.error(c.red('Usage: mnema ask "<question>"')); process.exit(1); }
129
+ try {
130
+ const a = await makeClient(ctx).graph.ask(q, { ...(flags.budget ? { budgetMs: Number(flags.budget) } : {}) });
131
+ emit(flags, a, () => {
132
+ console.log(a.answer);
133
+ // ⚠️ NEVER WITHOUT THE CONFIDENCE. The interpreter deliberately hedges
134
+ // rather than asserts when the graph is thin; a terminal that prints only
135
+ // the sentence promotes a hedge to a fact.
136
+ const pct = Math.round((a.confidence ?? 0) * 100);
137
+ const why = a.confidenceReason ? ` (${a.confidenceReason})` : '';
138
+ const line = `confidence ${pct}%${why} · tier ${a.tier}${a.usedFallback ? ' · fallback' : ''}`;
139
+ console.log(pct >= 60 ? c.dim(`\n${line}`) : c.yellow(`\n${line} — treat as a lead, not a fact`));
140
+ if (a.sources?.length) {
141
+ console.log(c.dim(`\nsources (${a.sources.length}):`));
142
+ for (const s of a.sources.slice(0, 5)) console.log(c.dim(` ${trunc(s.title, 70)}`));
143
+ }
144
+ });
145
+ } catch (e) { renderError(e, { context: 'ask' }); }
146
+ }
147
+
148
+ export async function cmdGraph(flags, ctx, rest) {
149
+ await guard(ctx.workspaceId);
150
+ const [from, to] = rest;
151
+ if (!from) { console.error(c.red('Usage: mnema graph <from> [to]')); process.exit(1); }
152
+ try {
153
+ const g = await makeClient(ctx).graph.traverse(from, to);
154
+ emit(flags, g, () => {
155
+ if (to) {
156
+ // connected:false is an ANSWER, not a failure — say so plainly rather
157
+ // than printing an empty list and letting it read as an error.
158
+ if (!g.connected) return console.log(c.yellow(`No route between "${from}" and "${to}".`));
159
+ console.log(c.bold(`${g.hopCount} hop(s) from "${from}" to "${to}"`));
160
+ for (const id of g.path) {
161
+ const n = g.nodes.find((x) => x.id === id);
162
+ console.log(` ${n ? trunc(n.label, 60) : id}${n?.type ? c.dim(` [${n.type}]`) : ''}`);
163
+ }
164
+ return;
165
+ }
166
+ console.log(c.bold(`${g.nodes.length} node(s), ${g.edges.length} edge(s) around "${from}"`));
167
+ for (const n of g.nodes.slice(0, 25)) console.log(` ${trunc(n.label, 60).padEnd(60)} ${c.dim(n.type ?? '')}`);
168
+ });
169
+ } catch (e) { renderError(e, { context: 'graph' }); }
170
+ }
171
+
172
+ // ── briefing ──────────────────────────────────────────────────────────────────
173
+ export async function cmdBriefing(flags, ctx) {
174
+ await guard(ctx.workspaceId);
175
+ try {
176
+ const b = await makeClient(ctx).findings.briefing();
177
+ emit(flags, b, () => {
178
+ const p = b.pulse;
179
+ console.log(c.bold('Pulse'));
180
+ console.log(` ${p.activeProjects} active project(s) · ${p.problemsInActiveProjects} problem(s) · $${p.cost7dUsd} last 7d`);
181
+
182
+ // ⚠️ THE CAVEATS COME BEFORE THE LIST, on purpose. A short list has three
183
+ // causes and only one is good news; printing the findings first invites the
184
+ // reader to conclude "all clear" before reaching the reason it is short.
185
+ if (b.neverComputed) console.log(c.yellow('\n The findings engine has never run for this workspace.'));
186
+ if (b.coverage?.degraded) console.log(c.yellow(`\n ${b.coverage.notice}`));
187
+
188
+ console.log(c.bold(`\nFindings (${b.findings.length})`));
189
+ if (!b.findings.length) console.log(c.dim(' nothing surfaced'));
190
+ for (const f of b.findings) {
191
+ const count = f.grouped ? c.dim(` ×${f.count}`) : '';
192
+ console.log(` ${c.dim(String(f.kind).padEnd(14))} ${trunc(f.headline, 90)}${count}`);
193
+ }
194
+ });
195
+ } catch (e) { renderError(e, { context: 'briefing' }); }
196
+ }