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