@persistmemory/cli 0.1.2 → 0.2.1

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/bin.js CHANGED
@@ -234,6 +234,27 @@ var HttpClient = class {
234
234
  ...options ? { options } : {}
235
235
  });
236
236
  }
237
+ /** POST with a file as the body. The type describes the bytes, not JSON. */
238
+ async postBytes(path, bytes, contentType, query, options) {
239
+ return this.#request({
240
+ method: "POST",
241
+ path,
242
+ rawBody: bytes,
243
+ contentType,
244
+ ...query ? { query } : {},
245
+ ...options ? { options } : {}
246
+ });
247
+ }
248
+ /** GET that returns bytes rather than JSON, for downloading a file. */
249
+ async getBytes(path, query, options) {
250
+ return this.#request({
251
+ method: "GET",
252
+ path,
253
+ rawResponse: true,
254
+ ...query ? { query } : {},
255
+ ...options ? { options } : {}
256
+ });
257
+ }
237
258
  async patch(path, body, options) {
238
259
  return this.#request({
239
260
  method: "PATCH",
@@ -286,11 +307,20 @@ var HttpClient = class {
286
307
  this.#fetch(url, {
287
308
  method: request.method,
288
309
  headers: this.#headers(request),
289
- ...request.body !== void 0 ? { body: JSON.stringify(request.body) } : {},
310
+ ...request.rawBody !== void 0 ? { body: request.rawBody } : request.body !== void 0 ? { body: JSON.stringify(request.body) } : {},
290
311
  signal: deadline.signal
291
312
  }),
292
313
  deadline.signal
293
314
  );
315
+ if (request.rawResponse && response.ok) {
316
+ const disposition = response.headers.get("content-disposition") ?? "";
317
+ const named = /filename="([^"]+)"/.exec(disposition)?.[1];
318
+ return {
319
+ bytes: new Uint8Array(await response.arrayBuffer()),
320
+ contentType: response.headers.get("content-type") ?? "application/octet-stream",
321
+ ...named ? { filename: named } : {}
322
+ };
323
+ }
294
324
  const payload = await readBody(response);
295
325
  if (!response.ok) throw errorFromResponse(response.status, payload, response.headers);
296
326
  return payload;
@@ -311,9 +341,11 @@ var HttpClient = class {
311
341
  return {
312
342
  // The only place the key is ever read.
313
343
  authorization: `Bearer ${this.#apiKey}`,
314
- accept: "application/json",
344
+ // A download route answers with the file's own type, so `*/*` rather
345
+ // than a promise to accept only JSON that the server would have to break.
346
+ accept: request.rawResponse ? "*/*" : "application/json",
315
347
  "user-agent": this.#userAgent,
316
- ...request.body !== void 0 ? { "content-type": "application/json" } : {},
348
+ ...request.rawBody !== void 0 ? { "content-type": request.contentType ?? "application/octet-stream" } : request.body !== void 0 ? { "content-type": "application/json" } : {},
317
349
  ...request.options?.idempotencyKey ? { "idempotency-key": request.options.idempotencyKey } : {}
318
350
  };
319
351
  }
@@ -344,7 +376,7 @@ var SignalFired = class extends Error {
344
376
  };
345
377
  function untilAborted(work, signal) {
346
378
  work.catch(() => void 0);
347
- return new Promise((resolve7, reject) => {
379
+ return new Promise((resolve8, reject) => {
348
380
  if (signal.aborted) {
349
381
  reject(new SignalFired());
350
382
  return;
@@ -354,7 +386,7 @@ function untilAborted(work, signal) {
354
386
  work.then(
355
387
  (value) => {
356
388
  signal.removeEventListener("abort", onAbort);
357
- resolve7(value);
389
+ resolve8(value);
358
390
  },
359
391
  (error) => {
360
392
  signal.removeEventListener("abort", onAbort);
@@ -364,14 +396,14 @@ function untilAborted(work, signal) {
364
396
  });
365
397
  }
366
398
  function defaultSleep(ms, signal) {
367
- return new Promise((resolve7, reject) => {
399
+ return new Promise((resolve8, reject) => {
368
400
  if (signal?.aborted) {
369
401
  reject(new AbortError());
370
402
  return;
371
403
  }
372
404
  const timer = setTimeout(() => {
373
405
  signal?.removeEventListener("abort", onAbort);
374
- resolve7();
406
+ resolve8();
375
407
  }, ms);
376
408
  function onAbort() {
377
409
  clearTimeout(timer);
@@ -890,6 +922,119 @@ var Conversations = class {
890
922
  );
891
923
  }
892
924
  };
925
+ var Google = class {
926
+ #http;
927
+ constructor(http) {
928
+ this.#http = http;
929
+ }
930
+ /** Files by name, newest first. Omit the query for recently changed ones. */
931
+ async searchDrive(params = {}, options) {
932
+ return this.#http.get(
933
+ "/api/v1/google/drive/files",
934
+ {
935
+ ...params.query !== void 0 ? { query: params.query } : {},
936
+ ...params.limit !== void 0 ? { limit: params.limit } : {}
937
+ },
938
+ options
939
+ );
940
+ }
941
+ async getDriveFile(fileId, options) {
942
+ return this.#http.get(
943
+ `/api/v1/google/drive/files/${encodeURIComponent(fileId)}`,
944
+ void 0,
945
+ options
946
+ );
947
+ }
948
+ /**
949
+ * The bytes of a Drive file.
950
+ *
951
+ * A Google Doc, Sheet or Slide holds no bytes of its own and is exported on
952
+ * the way - a document as PDF, a spreadsheet as CSV - so `filename` comes
953
+ * back describing what it BECAME. Writing it under the id instead produces a
954
+ * file nothing will open.
955
+ */
956
+ async downloadDriveFile(fileId, options) {
957
+ return this.#http.getBytes(
958
+ `/api/v1/google/drive/files/${encodeURIComponent(fileId)}/content`,
959
+ void 0,
960
+ options
961
+ );
962
+ }
963
+ /**
964
+ * Writes a file into the user's Drive.
965
+ *
966
+ * Needs one of the Drive write permissions on their connection. A read-only
967
+ * grant is refused by Google, and the error names the missing permission
968
+ * rather than reporting a failed upload - one is fixed with a checkbox and
969
+ * the other sends somebody looking for a bug.
970
+ */
971
+ async saveToDrive(params, options) {
972
+ return this.#http.postBytes(
973
+ "/api/v1/google/drive/files",
974
+ params.bytes,
975
+ params.contentType ?? "application/octet-stream",
976
+ {
977
+ name: params.name,
978
+ ...params.folderId !== void 0 ? { folderId: params.folderId } : {}
979
+ },
980
+ options
981
+ );
982
+ }
983
+ /**
984
+ * Recent messages - senders, subjects and a one-line preview, never bodies.
985
+ *
986
+ * `query` is Gmail's own syntax passed through as written: `from:priya`,
987
+ * `has:attachment`, `newer_than:7d`. It selects within the connected mailbox
988
+ * and cannot reach another one.
989
+ */
990
+ async searchMail(params = {}, options) {
991
+ return this.#http.get(
992
+ "/api/v1/google/mail",
993
+ {
994
+ ...params.query !== void 0 ? { query: params.query } : {},
995
+ ...params.limit !== void 0 ? { limit: params.limit } : {}
996
+ },
997
+ options
998
+ );
999
+ }
1000
+ /** One message, with its body and the names of what is attached. */
1001
+ async readMail(messageId, options) {
1002
+ return this.#http.get(
1003
+ `/api/v1/google/mail/${encodeURIComponent(messageId)}`,
1004
+ void 0,
1005
+ options
1006
+ );
1007
+ }
1008
+ /**
1009
+ * The bytes of one attachment.
1010
+ *
1011
+ * Separate from `readMail` so listing a mailbox never drags attachments
1012
+ * across the network: a message with a 40 MB deck should not cost 40 MB to
1013
+ * summarise.
1014
+ */
1015
+ async downloadAttachment(messageId, attachmentId, options) {
1016
+ return this.#http.getBytes(
1017
+ `/api/v1/google/mail/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}`,
1018
+ void 0,
1019
+ options
1020
+ );
1021
+ }
1022
+ /** Sends as the connected account. Needs the send permission. */
1023
+ async sendMail(params, options) {
1024
+ return this.#http.post("/api/v1/google/mail/send", params, options);
1025
+ }
1026
+ /** People in the user's contacts. Omit the query to list them. */
1027
+ async contacts(params = {}, options) {
1028
+ return this.#http.get(
1029
+ "/api/v1/google/contacts",
1030
+ {
1031
+ ...params.query !== void 0 ? { query: params.query } : {},
1032
+ ...params.limit !== void 0 ? { limit: params.limit } : {}
1033
+ },
1034
+ options
1035
+ );
1036
+ }
1037
+ };
893
1038
  var Integrations = class {
894
1039
  #http;
895
1040
  constructor(http) {
@@ -1020,6 +1165,8 @@ var PersistMemory = class {
1020
1165
  conflicts;
1021
1166
  conversations;
1022
1167
  integrations;
1168
+ /** Drive, mail and contacts on the user's connected Google account. */
1169
+ google;
1023
1170
  health;
1024
1171
  agent;
1025
1172
  #http;
@@ -1036,6 +1183,7 @@ var PersistMemory = class {
1036
1183
  this.conflicts = new Conflicts(this.#http);
1037
1184
  this.conversations = new Conversations(this.#http);
1038
1185
  this.integrations = new Integrations(this.#http);
1186
+ this.google = new Google(this.#http);
1039
1187
  this.health = new Health(this.#http);
1040
1188
  this.agent = new Agent(this.#http);
1041
1189
  }
@@ -1379,7 +1527,7 @@ function shortDate(iso) {
1379
1527
  }
1380
1528
 
1381
1529
  // src/help.ts
1382
- var VERSION = "0.1.2";
1530
+ var VERSION = true ? "0.2.1" : versionFromManifest();
1383
1531
  var PACKAGE = "@persistmemory/cli";
1384
1532
  var HELP = `
1385
1533
  pm \u2014 PersistMemory from your terminal
@@ -1424,6 +1572,12 @@ var HELP = `
1424
1572
  list spaces your Spaces
1425
1573
  get memory <id> one memory, in full
1426
1574
 
1575
+ drive [name] search your Google Drive
1576
+ drive get <id> [--out path] download one file here
1577
+ drive put <file> [--name n] save a file into Drive
1578
+ mail [search] recent mail \u2014 from:priya, has:attachment
1579
+ mail read <id> one message, with its body
1580
+
1427
1581
  status is the service healthy
1428
1582
  requests file requests waiting for you to approve
1429
1583
  requests get <id> write a finished one to a file here
@@ -1570,8 +1724,8 @@ async function startLoopback(options = {}) {
1570
1724
  const timeoutMs = options.timeoutMs ?? 5 * 60 * 1e3;
1571
1725
  let resolveCallback;
1572
1726
  let rejectCallback;
1573
- const received = new Promise((resolve7, reject) => {
1574
- resolveCallback = resolve7;
1727
+ const received = new Promise((resolve8, reject) => {
1728
+ resolveCallback = resolve8;
1575
1729
  rejectCallback = reject;
1576
1730
  });
1577
1731
  const server = createServer((request, response) => {
@@ -1597,9 +1751,9 @@ async function startLoopback(options = {}) {
1597
1751
  response.end(donePage(callback));
1598
1752
  resolveCallback?.(callback);
1599
1753
  });
1600
- await new Promise((resolve7, reject) => {
1754
+ await new Promise((resolve8, reject) => {
1601
1755
  server.once("error", reject);
1602
- server.listen(0, "127.0.0.1", resolve7);
1756
+ server.listen(0, "127.0.0.1", resolve8);
1603
1757
  });
1604
1758
  const address = server.address();
1605
1759
  if (address === null || typeof address === "string") {
@@ -1861,7 +2015,7 @@ function safeEqual(a, b) {
1861
2015
  }
1862
2016
  async function openBrowser(url) {
1863
2017
  const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
1864
- await new Promise((resolve7, reject) => {
2018
+ await new Promise((resolve8, reject) => {
1865
2019
  const child = spawn(command, args, {
1866
2020
  stdio: "ignore",
1867
2021
  // Detached so closing the terminal does not close the browser, and so
@@ -1870,7 +2024,7 @@ async function openBrowser(url) {
1870
2024
  });
1871
2025
  child.once("error", reject);
1872
2026
  child.unref();
1873
- resolve7();
2027
+ resolve8();
1874
2028
  });
1875
2029
  }
1876
2030
  async function describe(response) {
@@ -1932,13 +2086,13 @@ async function currentCredential(resolved, deps) {
1932
2086
  // src/context.ts
1933
2087
  import { createInterface } from "node:readline";
1934
2088
  async function askOnTty(prompt) {
1935
- return new Promise((resolve7) => {
2089
+ return new Promise((resolve8) => {
1936
2090
  const readline = createInterface({ input: process.stdin, output: process.stdout });
1937
2091
  readline.question(prompt, (answer3) => {
1938
2092
  readline.close();
1939
- resolve7(answer3.trim());
2093
+ resolve8(answer3.trim());
1940
2094
  });
1941
- readline.once("close", () => resolve7(""));
2095
+ readline.once("close", () => resolve8(""));
1942
2096
  });
1943
2097
  }
1944
2098
  var ETX = "";
@@ -1947,13 +2101,13 @@ var BACKSPACE = "\b";
1947
2101
  async function readSecretFromTty(prompt) {
1948
2102
  const input = process.stdin;
1949
2103
  if (!input.isTTY) {
1950
- return new Promise((resolve7) => {
2104
+ return new Promise((resolve8) => {
1951
2105
  const readline = createInterface({ input });
1952
2106
  readline.once("line", (line) => {
1953
2107
  readline.close();
1954
- resolve7(line.trim());
2108
+ resolve8(line.trim());
1955
2109
  });
1956
- readline.once("close", () => resolve7(""));
2110
+ readline.once("close", () => resolve8(""));
1957
2111
  });
1958
2112
  }
1959
2113
  process.stdout.write(prompt);
@@ -1961,14 +2115,14 @@ async function readSecretFromTty(prompt) {
1961
2115
  input.setRawMode?.(true);
1962
2116
  input.resume();
1963
2117
  input.setEncoding("utf8");
1964
- return new Promise((resolve7) => {
2118
+ return new Promise((resolve8) => {
1965
2119
  let value = "";
1966
2120
  const finish2 = () => {
1967
2121
  input.removeListener("data", onData);
1968
2122
  input.setRawMode?.(previouslyRaw);
1969
2123
  input.pause();
1970
2124
  process.stdout.write("\n");
1971
- resolve7(value.trim());
2125
+ resolve8(value.trim());
1972
2126
  };
1973
2127
  const onData = (chunk) => {
1974
2128
  for (const character of chunk) {
@@ -2005,8 +2159,8 @@ async function readStdin() {
2005
2159
  // src/commands/agent.ts
2006
2160
  import { hostname } from "node:os";
2007
2161
  import { homedir as homedir2 } from "node:os";
2008
- import { join as join4, resolve as resolve3 } from "node:path";
2009
- import { readFileSync as readFileSync4, readdirSync, statSync as statSync2 } from "node:fs";
2162
+ import { basename, join as join4, resolve as resolve3 } from "node:path";
2163
+ import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync, statSync as statSync2, writeFileSync as writeFileSync4 } from "node:fs";
2010
2164
 
2011
2165
  // src/files.ts
2012
2166
  import { existsSync as existsSync3, readFileSync as readFileSync3, realpathSync, statSync, writeFileSync as writeFileSync3 } from "node:fs";
@@ -2131,6 +2285,17 @@ function locate(roots, requested) {
2131
2285
  }
2132
2286
  throw new Error(`${requested} is not inside any allowed folder (${roots.join(", ")})`);
2133
2287
  }
2288
+ function uncontested(target) {
2289
+ if (!existsSync4(target)) return target;
2290
+ const dot = target.lastIndexOf(".");
2291
+ const stem = dot > target.lastIndexOf("/") && dot !== -1 ? target.slice(0, dot) : target;
2292
+ const extension = stem === target ? "" : target.slice(dot);
2293
+ for (let n = 1; n < 1e3; n += 1) {
2294
+ const candidate = `${stem} (${n})${extension}`;
2295
+ if (!existsSync4(candidate)) return candidate;
2296
+ }
2297
+ throw new Error(`${target} and a thousand names beside it are taken.`);
2298
+ }
2134
2299
  async function answer(context, apiUrl, token, roots, request) {
2135
2300
  let located;
2136
2301
  try {
@@ -2138,6 +2303,43 @@ async function answer(context, apiUrl, token, roots, request) {
2138
2303
  } catch (error) {
2139
2304
  return { ok: false, error: error instanceof Error ? error.message : "refused" };
2140
2305
  }
2306
+ if (request.kind === "write_file") {
2307
+ if (!request.sourceUrl) return { ok: false, error: "There was nothing to write." };
2308
+ let downloaded;
2309
+ let fetched;
2310
+ try {
2311
+ fetched = await fetch(request.sourceUrl);
2312
+ if (!fetched.ok) {
2313
+ return { ok: false, error: await said(fetched, "The file could not be fetched") };
2314
+ }
2315
+ downloaded = Buffer.from(await fetched.arrayBuffer());
2316
+ } catch {
2317
+ return { ok: false, error: "The file could not be fetched from the service." };
2318
+ }
2319
+ try {
2320
+ let target = located;
2321
+ if (existsSync4(located) && statSync2(located).isDirectory()) {
2322
+ const disposition = fetched.headers.get("content-disposition") ?? "";
2323
+ const named = /filename="([^"]+)"/.exec(disposition)?.[1];
2324
+ target = join4(located, basename(named ?? "file"));
2325
+ }
2326
+ target = uncontested(target);
2327
+ writeFileSync4(target, downloaded, { flag: "wx" });
2328
+ return upload(
2329
+ apiUrl,
2330
+ token,
2331
+ "written.txt",
2332
+ Buffer.from(`Saved to ${target}
2333
+ ${downloaded.length} bytes
2334
+ `, "utf8")
2335
+ );
2336
+ } catch (error) {
2337
+ return {
2338
+ ok: false,
2339
+ error: error instanceof Error ? error.message : "could not write it"
2340
+ };
2341
+ }
2342
+ }
2141
2343
  let bytes;
2142
2344
  let filename = request.path.split("/").pop() ?? "file";
2143
2345
  if (request.kind === "list_dir") {
@@ -2267,7 +2469,7 @@ async function agentCommand(context) {
2267
2469
  body: JSON.stringify(body)
2268
2470
  });
2269
2471
  let complaint;
2270
- const complain = (message2) => {
2472
+ const complain2 = (message2) => {
2271
2473
  if (complaint === message2) return;
2272
2474
  complaint = message2;
2273
2475
  context.error(` ${message2}`);
@@ -2282,7 +2484,7 @@ async function agentCommand(context) {
2282
2484
  try {
2283
2485
  const beat = await call("heartbeat", { hostname: name, platform: process.platform });
2284
2486
  if (!beat.ok) {
2285
- complain(await said(beat, "the service refused this machine"));
2487
+ complain2(await said(beat, "the service refused this machine"));
2286
2488
  } else {
2287
2489
  working();
2288
2490
  const state = await beat.json();
@@ -2297,7 +2499,7 @@ async function agentCommand(context) {
2297
2499
  }
2298
2500
  const claimed = await call("claim", { hostname: name, limit: 5 });
2299
2501
  if (!claimed.ok) {
2300
- complain(await said(claimed, "could not pick up work"));
2502
+ complain2(await said(claimed, "could not pick up work"));
2301
2503
  } else {
2302
2504
  const { items } = await claimed.json();
2303
2505
  for (const request of items) {
@@ -2322,9 +2524,172 @@ async function agentCommand(context) {
2322
2524
  return 0;
2323
2525
  }
2324
2526
 
2527
+ // src/commands/google.ts
2528
+ import { writeFileSync as writeFileSync5 } from "node:fs";
2529
+ import { basename as basename2, resolve as resolve4 } from "node:path";
2530
+ import { readFileSync as readFileSync5 } from "node:fs";
2531
+ async function callApi(context, path, init = {}) {
2532
+ const credential = context.resolved.credential;
2533
+ if (!credential) {
2534
+ context.error("Sign in first: pm auth login");
2535
+ return void 0;
2536
+ }
2537
+ const apiUrl = context.resolved.apiUrl.replace(/\/+$/, "");
2538
+ return fetch(`${apiUrl}${path}`, {
2539
+ ...init,
2540
+ headers: {
2541
+ authorization: `Bearer ${credential.token}`,
2542
+ ...init.headers ?? {}
2543
+ }
2544
+ });
2545
+ }
2546
+ async function complain(context, response) {
2547
+ const body = await response.json().catch(() => void 0);
2548
+ context.error(body?.error?.message ?? `That failed (${response.status}).`);
2549
+ return response.status === 409 ? 3 : 1;
2550
+ }
2551
+ async function driveCommand(context) {
2552
+ const [, noun, ...rest] = context.args.words;
2553
+ if (noun === "get") return driveGet(context, rest.join(" ").trim());
2554
+ if (noun === "put" || noun === "save") return drivePut(context, rest.join(" ").trim());
2555
+ const query = [noun, ...rest].filter(Boolean).join(" ").trim();
2556
+ const response = await callApi(
2557
+ context,
2558
+ `/api/v1/google/drive/files?limit=20${query ? `&query=${encodeURIComponent(query)}` : ""}`
2559
+ );
2560
+ if (!response) return 1;
2561
+ if (!response.ok) return complain(context, response);
2562
+ const { data } = await response.json();
2563
+ if (data.length === 0) {
2564
+ context.print(query ? `Nothing in Drive matches "${query}".` : "That Drive is empty.");
2565
+ return 0;
2566
+ }
2567
+ if (context.flags.output === "json") {
2568
+ context.print(JSON.stringify(data, void 0, 2));
2569
+ return 0;
2570
+ }
2571
+ for (const file of data) {
2572
+ context.print(` ${file.name}`);
2573
+ context.print(
2574
+ ` ${file.id}${file.size ? ` \xB7 ${Math.round(file.size / 1024)} KB` : ""}${file.modifiedTime ? ` \xB7 ${file.modifiedTime.slice(0, 10)}` : ""}`
2575
+ );
2576
+ }
2577
+ context.print("");
2578
+ context.print("Fetch one with: pm drive get <id>");
2579
+ return 0;
2580
+ }
2581
+ async function driveGet(context, fileId) {
2582
+ if (!fileId) {
2583
+ context.error("Say which file: pm drive get <id>");
2584
+ return 2;
2585
+ }
2586
+ const response = await callApi(
2587
+ context,
2588
+ `/api/v1/google/drive/files/${encodeURIComponent(fileId)}/content`
2589
+ );
2590
+ if (!response) return 1;
2591
+ if (!response.ok) return complain(context, response);
2592
+ const disposition = response.headers.get("content-disposition") ?? "";
2593
+ const named = /filename="([^"]+)"/.exec(disposition)?.[1];
2594
+ const out = stringFlag(context.args, "out");
2595
+ const target = resolve4(out ?? basename2(named ?? fileId));
2596
+ writeFileSync5(target, Buffer.from(await response.arrayBuffer()));
2597
+ context.print(target);
2598
+ return 0;
2599
+ }
2600
+ async function drivePut(context, path) {
2601
+ if (!path) {
2602
+ context.error("Say which file: pm drive put ./notes.md");
2603
+ return 2;
2604
+ }
2605
+ let bytes;
2606
+ try {
2607
+ bytes = readFileSync5(resolve4(path));
2608
+ } catch {
2609
+ context.error(`Cannot read ${path}.`);
2610
+ return 1;
2611
+ }
2612
+ const name = stringFlag(context.args, "name") ?? basename2(path);
2613
+ const response = await callApi(
2614
+ context,
2615
+ `/api/v1/google/drive/files?name=${encodeURIComponent(name)}`,
2616
+ {
2617
+ method: "POST",
2618
+ // The bytes as bytes. Base64 in JSON would be a third larger and would
2619
+ // make the limit somebody was told about stop matching the one they hit.
2620
+ headers: { "content-type": "application/octet-stream" },
2621
+ body: new Uint8Array(bytes)
2622
+ }
2623
+ );
2624
+ if (!response) return 1;
2625
+ if (!response.ok) return complain(context, response);
2626
+ const saved = await response.json();
2627
+ context.print(`Saved "${saved.name}" to Drive.${saved.link ? ` ${saved.link}` : ""}`);
2628
+ return 0;
2629
+ }
2630
+ async function mailCommand(context) {
2631
+ const [, noun, ...rest] = context.args.words;
2632
+ if (noun === "read" || noun === "get") {
2633
+ const messageId = rest.join(" ").trim();
2634
+ if (!messageId) {
2635
+ context.error("Say which message: pm mail read <id>");
2636
+ return 2;
2637
+ }
2638
+ const response2 = await callApi(
2639
+ context,
2640
+ `/api/v1/google/mail/${encodeURIComponent(messageId)}`
2641
+ );
2642
+ if (!response2) return 1;
2643
+ if (!response2.ok) return complain(context, response2);
2644
+ const message2 = await response2.json();
2645
+ if (context.flags.output === "json") {
2646
+ context.print(JSON.stringify(message2, void 0, 2));
2647
+ return 0;
2648
+ }
2649
+ context.print(`From: ${message2.from ?? "unknown"}`);
2650
+ context.print(`Subject: ${message2.subject ?? "(none)"}`);
2651
+ if (message2.date) context.print(`Date: ${message2.date}`);
2652
+ context.print("");
2653
+ context.print(message2.body);
2654
+ if (message2.attachments.length > 0) {
2655
+ context.print("");
2656
+ context.print("Attached:");
2657
+ for (const one of message2.attachments) context.print(` ${one.filename} (${one.mimeType})`);
2658
+ }
2659
+ return 0;
2660
+ }
2661
+ const query = [noun, ...rest].filter(Boolean).join(" ").trim();
2662
+ const response = await callApi(
2663
+ context,
2664
+ `/api/v1/google/mail?limit=20${query ? `&query=${encodeURIComponent(query)}` : ""}`
2665
+ );
2666
+ if (!response) return 1;
2667
+ if (!response.ok) return complain(context, response);
2668
+ const { data } = await response.json();
2669
+ if (data.length === 0) {
2670
+ context.print(query ? `No mail matches "${query}".` : "Nothing in that mailbox.");
2671
+ return 0;
2672
+ }
2673
+ if (context.flags.output === "json") {
2674
+ context.print(JSON.stringify(data, void 0, 2));
2675
+ return 0;
2676
+ }
2677
+ for (const message2 of data) {
2678
+ const marks = [message2.unread ? "unread" : "", message2.hasAttachments ? "attachment" : ""].filter(Boolean).join(", ");
2679
+ context.print(` ${message2.subject ?? "(no subject)"}${marks ? ` [${marks}]` : ""}`);
2680
+ context.print(
2681
+ ` ${message2.from ?? "unknown"}${message2.date ? ` \xB7 ${message2.date.slice(0, 10)}` : ""}`
2682
+ );
2683
+ context.print(` ${message2.id}`);
2684
+ }
2685
+ context.print("");
2686
+ context.print("Read one with: pm mail read <id>");
2687
+ return 0;
2688
+ }
2689
+
2325
2690
  // src/commands/requests.ts
2326
- import { existsSync as existsSync4, writeFileSync as writeFileSync4 } from "node:fs";
2327
- import { resolve as resolve4 } from "node:path";
2691
+ import { existsSync as existsSync5, writeFileSync as writeFileSync6 } from "node:fs";
2692
+ import { resolve as resolve5 } from "node:path";
2328
2693
  async function requestsCommand(context) {
2329
2694
  if (context.args.words[1] === "get") return collectCommand(context);
2330
2695
  const credential = context.resolved.credential;
@@ -2392,25 +2757,25 @@ async function collectCommand(context) {
2392
2757
  return 1;
2393
2758
  }
2394
2759
  const name = stringFlag(context.args, "output", "o") ?? filename;
2395
- const target = resolve4(name);
2396
- if (existsSync4(target)) {
2760
+ const target = resolve5(name);
2761
+ if (existsSync5(target)) {
2397
2762
  context.error(`${target} already exists. Pass --output to write somewhere else.`);
2398
2763
  return 1;
2399
2764
  }
2400
- writeFileSync4(target, new Uint8Array(await file.arrayBuffer()));
2765
+ writeFileSync6(target, new Uint8Array(await file.arrayBuffer()));
2401
2766
  context.print(`Wrote ${target}`);
2402
2767
  return 0;
2403
2768
  }
2404
2769
 
2405
2770
  // src/workspace.ts
2406
- import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "node:fs";
2771
+ import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync7 } from "node:fs";
2407
2772
  import { dirname as dirname3, join as join5, resolve as resolvePath } from "node:path";
2408
2773
  var WORKSPACE_FILE = ".persistmemory.json";
2409
2774
  function findWorkspace(from = process.cwd()) {
2410
2775
  let dir = resolvePath(from);
2411
2776
  for (; ; ) {
2412
2777
  const file = join5(dir, WORKSPACE_FILE);
2413
- if (existsSync5(file)) {
2778
+ if (existsSync6(file)) {
2414
2779
  const config = readWorkspace(file);
2415
2780
  if (config) return { file, dir, config };
2416
2781
  }
@@ -2421,7 +2786,7 @@ function findWorkspace(from = process.cwd()) {
2421
2786
  }
2422
2787
  function readWorkspace(file) {
2423
2788
  try {
2424
- const parsed = JSON.parse(readFileSync5(file, "utf8"));
2789
+ const parsed = JSON.parse(readFileSync6(file, "utf8"));
2425
2790
  const space = parsed.space;
2426
2791
  if (space && typeof space === "object" && typeof space.id === "string" && space.id !== "" && typeof space.name === "string") {
2427
2792
  return { space: { id: space.id, name: space.name } };
@@ -2433,7 +2798,7 @@ function readWorkspace(file) {
2433
2798
  }
2434
2799
  function writeWorkspace(dir, config) {
2435
2800
  const file = join5(dir, WORKSPACE_FILE);
2436
- writeFileSync5(file, `${JSON.stringify(config, null, 2)}
2801
+ writeFileSync7(file, `${JSON.stringify(config, null, 2)}
2437
2802
  `, "utf8");
2438
2803
  return file;
2439
2804
  }
@@ -2702,9 +3067,9 @@ function message(error) {
2702
3067
  }
2703
3068
 
2704
3069
  // src/commands/maintain.ts
2705
- import { existsSync as existsSync6, rmSync } from "node:fs";
3070
+ import { existsSync as existsSync7, rmSync } from "node:fs";
2706
3071
  import { spawnSync } from "node:child_process";
2707
- import { dirname as dirname4, resolve as resolve6 } from "node:path";
3072
+ import { dirname as dirname4, resolve as resolve7 } from "node:path";
2708
3073
  import { fileURLToPath } from "node:url";
2709
3074
  async function updateCommand(context) {
2710
3075
  const manager = installer();
@@ -2746,7 +3111,7 @@ Delete the file it runs from: ${processPath()}`
2746
3111
  return 0;
2747
3112
  }
2748
3113
  async function deleteCommand(context) {
2749
- if (!existsSync6(context.paths.dir)) {
3114
+ if (!existsSync7(context.paths.dir)) {
2750
3115
  context.print(`Nothing to delete: ${context.paths.dir} does not exist.`);
2751
3116
  return 0;
2752
3117
  }
@@ -2775,7 +3140,7 @@ async function deleteCommand(context) {
2775
3140
  return 0;
2776
3141
  }
2777
3142
  function removeEverything(context) {
2778
- const dir = resolve6(context.paths.dir);
3143
+ const dir = resolve7(context.paths.dir);
2779
3144
  if (dir === "/" || dir.split("/").filter(Boolean).length < 2) {
2780
3145
  context.error(`Refusing to delete ${dir}: that does not look like a data directory.`);
2781
3146
  return;
@@ -2787,7 +3152,7 @@ function installer() {
2787
3152
  }
2788
3153
  function processPath() {
2789
3154
  try {
2790
- return resolve6(dirname4(fileURLToPath(import.meta.url)));
3155
+ return resolve7(dirname4(fileURLToPath(import.meta.url)));
2791
3156
  } catch {
2792
3157
  return process.argv[1] ?? "";
2793
3158
  }
@@ -2799,7 +3164,7 @@ import { randomUUID } from "node:crypto";
2799
3164
  import { relative as relative2 } from "node:path";
2800
3165
 
2801
3166
  // src/events.ts
2802
- import { appendFileSync, existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync6 } from "node:fs";
3167
+ import { appendFileSync, existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync7 } from "node:fs";
2803
3168
  import { join as join6 } from "node:path";
2804
3169
  function openSessionLog(paths, id) {
2805
3170
  const directory = join6(paths.dir, "sessions");
@@ -2816,8 +3181,8 @@ function openSessionLog(paths, id) {
2816
3181
  }
2817
3182
  },
2818
3183
  read() {
2819
- if (!existsSync7(path)) return [];
2820
- return readFileSync6(path, "utf8").split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
3184
+ if (!existsSync8(path)) return [];
3185
+ return readFileSync7(path, "utf8").split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
2821
3186
  try {
2822
3187
  return [JSON.parse(line)];
2823
3188
  } catch {
@@ -2894,9 +3259,9 @@ async function sessionCommand(context) {
2894
3259
  context.print(` Ask anything. /help for commands, /exit to leave.
2895
3260
  `);
2896
3261
  const readline = createInterface2({ input: process.stdin, output: process.stdout });
2897
- const ask = (prompt) => new Promise((resolve7) => {
2898
- readline.question(prompt, resolve7);
2899
- readline.once("close", () => resolve7(void 0));
3262
+ const ask = (prompt) => new Promise((resolve8) => {
3263
+ readline.question(prompt, resolve8);
3264
+ readline.once("close", () => resolve8(void 0));
2900
3265
  });
2901
3266
  const root = process.cwd();
2902
3267
  let running = true;
@@ -3080,7 +3445,7 @@ async function write2(args) {
3080
3445
  }
3081
3446
 
3082
3447
  // src/commands/memory.ts
3083
- import { readFileSync as readFileSync7 } from "node:fs";
3448
+ import { readFileSync as readFileSync8 } from "node:fs";
3084
3449
 
3085
3450
  // src/spaces.ts
3086
3451
  async function spacesFor(context, client, env = process.env) {
@@ -3144,7 +3509,7 @@ async function rememberCommand(context) {
3144
3509
  let text;
3145
3510
  if (file) {
3146
3511
  try {
3147
- text = readFileSync7(file, "utf8");
3512
+ text = readFileSync8(file, "utf8");
3148
3513
  } catch {
3149
3514
  context.error(`Could not read ${file}.`);
3150
3515
  return 1;
@@ -3432,6 +3797,16 @@ async function dispatch(context) {
3432
3797
  case "setup":
3433
3798
  case "init":
3434
3799
  return setupCommand(context);
3800
+ /*
3801
+ `pm version` as well as `--version`.
3802
+
3803
+ It is what people type, and answering "Unknown command" to somebody
3804
+ asking which version they are running — while a flag two characters away
3805
+ answers it — is a needless dead end.
3806
+ */
3807
+ case "version":
3808
+ context.print(VERSION);
3809
+ return 0;
3435
3810
  case "update":
3436
3811
  case "upgrade":
3437
3812
  return updateCommand(context);
@@ -3452,6 +3827,18 @@ async function dispatch(context) {
3452
3827
  return statusCommand(context);
3453
3828
  case "requests":
3454
3829
  return requestsCommand(context);
3830
+ /*
3831
+ Google, through the API rather than through Google.
3832
+
3833
+ A laptop holds a bearer token and no database connection, so the
3834
+ credential it can prove is the one the API accepts. Google's own tokens
3835
+ never leave the deployment — which is what stops a stolen
3836
+ `~/.persistmemory` from being a stolen mailbox.
3837
+ */
3838
+ case "drive":
3839
+ return driveCommand(context);
3840
+ case "mail":
3841
+ return mailCommand(context);
3455
3842
  /**
3456
3843
  * `pm <verb> <noun>`, the grammar the Harness CLI uses.
3457
3844
  *