@persistmemory/cli 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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((resolve6, 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
- resolve6(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((resolve6, 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
- resolve6();
406
+ resolve8();
375
407
  }, ms);
376
408
  function onAbort() {
377
409
  clearTimeout(timer);
@@ -558,6 +590,58 @@ var Spaces = class {
558
590
  async create(params, options) {
559
591
  return this.#http.post("/api/v1/spaces", params, options);
560
592
  }
593
+ /**
594
+ * Deletes a Space. You must say what happens to what is in it.
595
+ *
596
+ * There is no default, here or in the API, and that is deliberate: "delete
597
+ * this Space" means the label to some people and everything inside it to
598
+ * others, and a client that guessed would destroy or keep somebody's
599
+ * material without being asked.
600
+ *
601
+ * `delete` never destroys a memory that is filed in another Space as well —
602
+ * that one is detached and left alone. `deleted` and `kept` come back so you
603
+ * can say what actually happened.
604
+ */
605
+ async delete(id, params, options) {
606
+ return this.#http.delete(
607
+ `/api/v1/spaces/${encodeURIComponent(id)}`,
608
+ params,
609
+ options
610
+ );
611
+ }
612
+ /**
613
+ * Merges Spaces into a NEW one, leaving every source exactly as it was.
614
+ *
615
+ * Additive, not destructive: a memory ends up in the sources AND the result,
616
+ * every existing search over a source returns what it did before, and
617
+ * undoing it is deleting the Space this returns. A memory in two sources is
618
+ * filed once.
619
+ */
620
+ async merge(params, options) {
621
+ return this.#http.post(
622
+ "/api/v1/spaces/merge",
623
+ params,
624
+ options
625
+ );
626
+ }
627
+ /**
628
+ * The Space this account files into when a capture names none.
629
+ *
630
+ * `{}` — an object with no `space` — means there is no default, which is the
631
+ * normal state rather than a gap. It is also what comes back after the Space
632
+ * somebody chose has been deleted.
633
+ */
634
+ async getDefault(options) {
635
+ return this.#http.get("/api/v1/spaces/default", void 0, options);
636
+ }
637
+ /** `null` clears it. Not the same as omitting it, which is why the type says so. */
638
+ async setDefault(spaceId, options) {
639
+ return this.#http.patch(
640
+ "/api/v1/spaces/default",
641
+ { spaceId },
642
+ options
643
+ );
644
+ }
561
645
  /** Renaming, retention, and archiving - `archived` is a field, not a verb. */
562
646
  async update(id, params, options) {
563
647
  return this.#http.patch(`/api/v1/spaces/${encodeURIComponent(id)}`, params, options);
@@ -838,6 +922,119 @@ var Conversations = class {
838
922
  );
839
923
  }
840
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
+ };
841
1038
  var Integrations = class {
842
1039
  #http;
843
1040
  constructor(http) {
@@ -920,6 +1117,42 @@ var Health = class {
920
1117
  return this.#http.get("/health/ready", void 0, options);
921
1118
  }
922
1119
  };
1120
+ var Agent = class {
1121
+ #http;
1122
+ constructor(http) {
1123
+ this.#http = http;
1124
+ }
1125
+ /** The row, including whether it finished and how large the result is. */
1126
+ async request(id, options) {
1127
+ return this.#http.get(
1128
+ `/api/v1/agent/request/${encodeURIComponent(id)}`,
1129
+ void 0,
1130
+ options
1131
+ );
1132
+ }
1133
+ /**
1134
+ * A short-lived link to the bytes of a finished request.
1135
+ *
1136
+ * Returns the URL rather than the file, and that is a deliberate limit of
1137
+ * this package rather than an oversight. The transport under every other
1138
+ * method parses JSON, retries, and attaches the API key; none of those is
1139
+ * right for a hundred-megabyte binary body, and building a second request
1140
+ * path inside the SDK to serve one method is how a client ends up with two
1141
+ * retry policies that differ only during an outage. Fetch the URL with
1142
+ * whatever already streams in your runtime - it needs no credential, which
1143
+ * is the whole reason it is signed.
1144
+ *
1145
+ * Treat the URL as the file. It is a bearer credential for exactly one
1146
+ * object, it expires in minutes, and it should not be logged or stored.
1147
+ */
1148
+ async downloadLink(id, options) {
1149
+ return this.#http.get(
1150
+ `/api/v1/agent/request/${encodeURIComponent(id)}/download`,
1151
+ void 0,
1152
+ options
1153
+ );
1154
+ }
1155
+ };
923
1156
  var PersistMemory = class {
924
1157
  memories;
925
1158
  search;
@@ -932,7 +1165,10 @@ var PersistMemory = class {
932
1165
  conflicts;
933
1166
  conversations;
934
1167
  integrations;
1168
+ /** Drive, mail and contacts on the user's connected Google account. */
1169
+ google;
935
1170
  health;
1171
+ agent;
936
1172
  #http;
937
1173
  constructor(options) {
938
1174
  this.#http = new HttpClient(options);
@@ -947,7 +1183,9 @@ var PersistMemory = class {
947
1183
  this.conflicts = new Conflicts(this.#http);
948
1184
  this.conversations = new Conversations(this.#http);
949
1185
  this.integrations = new Integrations(this.#http);
1186
+ this.google = new Google(this.#http);
950
1187
  this.health = new Health(this.#http);
1188
+ this.agent = new Agent(this.#http);
951
1189
  }
952
1190
  /**
953
1191
  * An escape hatch for an endpoint this package has not caught up with.
@@ -1289,7 +1527,7 @@ function shortDate(iso) {
1289
1527
  }
1290
1528
 
1291
1529
  // src/help.ts
1292
- var VERSION = "0.1.1";
1530
+ var VERSION = "0.1.2";
1293
1531
  var PACKAGE = "@persistmemory/cli";
1294
1532
  var HELP = `
1295
1533
  pm \u2014 PersistMemory from your terminal
@@ -1334,8 +1572,15 @@ var HELP = `
1334
1572
  list spaces your Spaces
1335
1573
  get memory <id> one memory, in full
1336
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
+
1337
1581
  status is the service healthy
1338
1582
  requests file requests waiting for you to approve
1583
+ requests get <id> write a finished one to a file here
1339
1584
 
1340
1585
  update install the newest version
1341
1586
  uninstall remove pm from this machine
@@ -1479,8 +1724,8 @@ async function startLoopback(options = {}) {
1479
1724
  const timeoutMs = options.timeoutMs ?? 5 * 60 * 1e3;
1480
1725
  let resolveCallback;
1481
1726
  let rejectCallback;
1482
- const received = new Promise((resolve6, reject) => {
1483
- resolveCallback = resolve6;
1727
+ const received = new Promise((resolve8, reject) => {
1728
+ resolveCallback = resolve8;
1484
1729
  rejectCallback = reject;
1485
1730
  });
1486
1731
  const server = createServer((request, response) => {
@@ -1506,9 +1751,9 @@ async function startLoopback(options = {}) {
1506
1751
  response.end(donePage(callback));
1507
1752
  resolveCallback?.(callback);
1508
1753
  });
1509
- await new Promise((resolve6, reject) => {
1754
+ await new Promise((resolve8, reject) => {
1510
1755
  server.once("error", reject);
1511
- server.listen(0, "127.0.0.1", resolve6);
1756
+ server.listen(0, "127.0.0.1", resolve8);
1512
1757
  });
1513
1758
  const address = server.address();
1514
1759
  if (address === null || typeof address === "string") {
@@ -1770,7 +2015,7 @@ function safeEqual(a, b) {
1770
2015
  }
1771
2016
  async function openBrowser(url) {
1772
2017
  const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
1773
- await new Promise((resolve6, reject) => {
2018
+ await new Promise((resolve8, reject) => {
1774
2019
  const child = spawn(command, args, {
1775
2020
  stdio: "ignore",
1776
2021
  // Detached so closing the terminal does not close the browser, and so
@@ -1779,7 +2024,7 @@ async function openBrowser(url) {
1779
2024
  });
1780
2025
  child.once("error", reject);
1781
2026
  child.unref();
1782
- resolve6();
2027
+ resolve8();
1783
2028
  });
1784
2029
  }
1785
2030
  async function describe(response) {
@@ -1841,13 +2086,13 @@ async function currentCredential(resolved, deps) {
1841
2086
  // src/context.ts
1842
2087
  import { createInterface } from "node:readline";
1843
2088
  async function askOnTty(prompt) {
1844
- return new Promise((resolve6) => {
2089
+ return new Promise((resolve8) => {
1845
2090
  const readline = createInterface({ input: process.stdin, output: process.stdout });
1846
2091
  readline.question(prompt, (answer3) => {
1847
2092
  readline.close();
1848
- resolve6(answer3.trim());
2093
+ resolve8(answer3.trim());
1849
2094
  });
1850
- readline.once("close", () => resolve6(""));
2095
+ readline.once("close", () => resolve8(""));
1851
2096
  });
1852
2097
  }
1853
2098
  var ETX = "";
@@ -1856,13 +2101,13 @@ var BACKSPACE = "\b";
1856
2101
  async function readSecretFromTty(prompt) {
1857
2102
  const input = process.stdin;
1858
2103
  if (!input.isTTY) {
1859
- return new Promise((resolve6) => {
2104
+ return new Promise((resolve8) => {
1860
2105
  const readline = createInterface({ input });
1861
2106
  readline.once("line", (line) => {
1862
2107
  readline.close();
1863
- resolve6(line.trim());
2108
+ resolve8(line.trim());
1864
2109
  });
1865
- readline.once("close", () => resolve6(""));
2110
+ readline.once("close", () => resolve8(""));
1866
2111
  });
1867
2112
  }
1868
2113
  process.stdout.write(prompt);
@@ -1870,14 +2115,14 @@ async function readSecretFromTty(prompt) {
1870
2115
  input.setRawMode?.(true);
1871
2116
  input.resume();
1872
2117
  input.setEncoding("utf8");
1873
- return new Promise((resolve6) => {
2118
+ return new Promise((resolve8) => {
1874
2119
  let value = "";
1875
2120
  const finish2 = () => {
1876
2121
  input.removeListener("data", onData);
1877
2122
  input.setRawMode?.(previouslyRaw);
1878
2123
  input.pause();
1879
2124
  process.stdout.write("\n");
1880
- resolve6(value.trim());
2125
+ resolve8(value.trim());
1881
2126
  };
1882
2127
  const onData = (chunk) => {
1883
2128
  for (const character of chunk) {
@@ -1914,8 +2159,8 @@ async function readStdin() {
1914
2159
  // src/commands/agent.ts
1915
2160
  import { hostname } from "node:os";
1916
2161
  import { homedir as homedir2 } from "node:os";
1917
- import { resolve as resolve3 } from "node:path";
1918
- import { readFileSync as readFileSync4, statSync as statSync2 } from "node:fs";
2162
+ import { join as join4, resolve as resolve3 } from "node:path";
2163
+ import { readFileSync as readFileSync4, readdirSync, statSync as statSync2 } from "node:fs";
1919
2164
 
1920
2165
  // src/files.ts
1921
2166
  import { existsSync as existsSync3, readFileSync as readFileSync3, realpathSync, statSync, writeFileSync as writeFileSync3 } from "node:fs";
@@ -1928,11 +2173,13 @@ var OutsideWorkspace = class extends Error {
1928
2173
  };
1929
2174
  var TooLarge = class extends Error {
1930
2175
  constructor(path, bytes, limit) {
1931
- super(`${path} is ${Math.round(bytes / 1024)} KB, over the ${Math.round(limit / 1024)} KB limit.`);
2176
+ const say = (value) => value >= 1024 * 1024 ? `${(value / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(value / 1024)} KB`;
2177
+ super(`${path} is ${say(bytes)}, over the ${say(limit)} limit.`);
1932
2178
  this.name = "TooLarge";
1933
2179
  }
1934
2180
  };
1935
2181
  var MAX_READ_BYTES = 512 * 1024;
2182
+ var MAX_TRANSFER_BYTES = 100 * 1024 * 1024;
1936
2183
  function realLocation(absolute) {
1937
2184
  let existing = absolute;
1938
2185
  const trailing = [];
@@ -2046,19 +2293,41 @@ async function answer(context, apiUrl, token, roots, request) {
2046
2293
  return { ok: false, error: error instanceof Error ? error.message : "refused" };
2047
2294
  }
2048
2295
  let bytes;
2296
+ let filename = request.path.split("/").pop() ?? "file";
2297
+ if (request.kind === "list_dir") {
2298
+ try {
2299
+ const stats = statSync2(located);
2300
+ if (!stats.isDirectory()) return { ok: false, error: `${request.path} is not a folder.` };
2301
+ const entries = readdirSync(located, { withFileTypes: true }).filter((entry) => !entry.name.startsWith(".")).slice(0, MAX_LISTED).map((entry) => {
2302
+ if (entry.isDirectory()) return `${entry.name}/`;
2303
+ try {
2304
+ return `${entry.name} ${sizeOf(join4(located, entry.name))}`;
2305
+ } catch {
2306
+ return entry.name;
2307
+ }
2308
+ }).sort();
2309
+ const listing = entries.length > 0 ? entries.join("\n") : "(empty)";
2310
+ bytes = Buffer.from(`${request.path}
2311
+
2312
+ ${listing}
2313
+ `, "utf8");
2314
+ filename = `${request.path.split("/").filter(Boolean).pop() ?? "listing"}.txt`;
2315
+ const grant = await upload(apiUrl, token, filename, bytes);
2316
+ return grant;
2317
+ } catch (error) {
2318
+ return { ok: false, error: error instanceof Error ? error.message : "could not list it" };
2319
+ }
2320
+ }
2049
2321
  try {
2050
2322
  const stats = statSync2(located);
2051
2323
  if (!stats.isFile()) return { ok: false, error: `${request.path} is not a file.` };
2052
- if (stats.size > MAX_READ_BYTES) {
2053
- return {
2054
- ok: false,
2055
- error: new TooLarge(request.path, stats.size, MAX_READ_BYTES).message
2056
- };
2057
- }
2058
2324
  bytes = readFileSync4(located);
2059
2325
  } catch (error) {
2060
2326
  return { ok: false, error: error instanceof Error ? error.message : "could not read it" };
2061
2327
  }
2328
+ return upload(apiUrl, token, filename, bytes);
2329
+ }
2330
+ async function upload(apiUrl, token, filename, bytes) {
2062
2331
  const grant = await fetch(`${apiUrl}/api/v1/agent/upload-url`, {
2063
2332
  method: "POST",
2064
2333
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
@@ -2070,13 +2339,17 @@ async function answer(context, apiUrl, token, roots, request) {
2070
2339
  // No content type is sent: this machine has a path, not a declaration.
2071
2340
  // The server resolves it from the name against the one table that knows
2072
2341
  // which types it can read, and tells us below what it decided.
2073
- filename: request.path.split("/").pop() ?? "file"
2342
+ filename
2074
2343
  })
2075
2344
  });
2076
2345
  if (!grant.ok) {
2077
2346
  return { ok: false, error: await said(grant, "could not get an upload url") };
2078
2347
  }
2079
- const { uploadUrl, contentType } = await grant.json();
2348
+ const { uploadUrl, contentType, maxBytes } = await grant.json();
2349
+ const limit = maxBytes ?? MAX_TRANSFER_BYTES;
2350
+ if (bytes.length > limit) {
2351
+ return { ok: false, error: new TooLarge(filename, bytes.length, limit).message };
2352
+ }
2080
2353
  const put = await fetch(uploadUrl, {
2081
2354
  method: "PUT",
2082
2355
  // The type the grant was signed for. Anything else is refused.
@@ -2088,6 +2361,13 @@ async function answer(context, apiUrl, token, roots, request) {
2088
2361
  if (!stored.attachToken) return { ok: false, error: "the upload returned no reference" };
2089
2362
  return { ok: true, attachToken: stored.attachToken, bytes: bytes.length };
2090
2363
  }
2364
+ var MAX_LISTED = 200;
2365
+ function sizeOf(path) {
2366
+ const size = statSync2(path).size;
2367
+ if (size < 1024) return `${size} B`;
2368
+ if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
2369
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`;
2370
+ }
2091
2371
  async function agentCommand(context) {
2092
2372
  const credential = context.resolved.credential;
2093
2373
  if (!credential) {
@@ -2141,7 +2421,7 @@ async function agentCommand(context) {
2141
2421
  body: JSON.stringify(body)
2142
2422
  });
2143
2423
  let complaint;
2144
- const complain = (message2) => {
2424
+ const complain2 = (message2) => {
2145
2425
  if (complaint === message2) return;
2146
2426
  complaint = message2;
2147
2427
  context.error(` ${message2}`);
@@ -2156,7 +2436,7 @@ async function agentCommand(context) {
2156
2436
  try {
2157
2437
  const beat = await call("heartbeat", { hostname: name, platform: process.platform });
2158
2438
  if (!beat.ok) {
2159
- complain(await said(beat, "the service refused this machine"));
2439
+ complain2(await said(beat, "the service refused this machine"));
2160
2440
  } else {
2161
2441
  working();
2162
2442
  const state = await beat.json();
@@ -2171,7 +2451,7 @@ async function agentCommand(context) {
2171
2451
  }
2172
2452
  const claimed = await call("claim", { hostname: name, limit: 5 });
2173
2453
  if (!claimed.ok) {
2174
- complain(await said(claimed, "could not pick up work"));
2454
+ complain2(await said(claimed, "could not pick up work"));
2175
2455
  } else {
2176
2456
  const { items } = await claimed.json();
2177
2457
  for (const request of items) {
@@ -2196,8 +2476,174 @@ async function agentCommand(context) {
2196
2476
  return 0;
2197
2477
  }
2198
2478
 
2479
+ // src/commands/google.ts
2480
+ import { writeFileSync as writeFileSync4 } from "node:fs";
2481
+ import { basename, resolve as resolve4 } from "node:path";
2482
+ import { readFileSync as readFileSync5 } from "node:fs";
2483
+ async function callApi(context, path, init = {}) {
2484
+ const credential = context.resolved.credential;
2485
+ if (!credential) {
2486
+ context.error("Sign in first: pm auth login");
2487
+ return void 0;
2488
+ }
2489
+ const apiUrl = context.resolved.apiUrl.replace(/\/+$/, "");
2490
+ return fetch(`${apiUrl}${path}`, {
2491
+ ...init,
2492
+ headers: {
2493
+ authorization: `Bearer ${credential.token}`,
2494
+ ...init.headers ?? {}
2495
+ }
2496
+ });
2497
+ }
2498
+ async function complain(context, response) {
2499
+ const body = await response.json().catch(() => void 0);
2500
+ context.error(body?.error?.message ?? `That failed (${response.status}).`);
2501
+ return response.status === 409 ? 3 : 1;
2502
+ }
2503
+ async function driveCommand(context) {
2504
+ const [, noun, ...rest] = context.args.words;
2505
+ if (noun === "get") return driveGet(context, rest.join(" ").trim());
2506
+ if (noun === "put" || noun === "save") return drivePut(context, rest.join(" ").trim());
2507
+ const query = [noun, ...rest].filter(Boolean).join(" ").trim();
2508
+ const response = await callApi(
2509
+ context,
2510
+ `/api/v1/google/drive/files?limit=20${query ? `&query=${encodeURIComponent(query)}` : ""}`
2511
+ );
2512
+ if (!response) return 1;
2513
+ if (!response.ok) return complain(context, response);
2514
+ const { data } = await response.json();
2515
+ if (data.length === 0) {
2516
+ context.print(query ? `Nothing in Drive matches "${query}".` : "That Drive is empty.");
2517
+ return 0;
2518
+ }
2519
+ if (context.flags.output === "json") {
2520
+ context.print(JSON.stringify(data, void 0, 2));
2521
+ return 0;
2522
+ }
2523
+ for (const file of data) {
2524
+ context.print(` ${file.name}`);
2525
+ context.print(
2526
+ ` ${file.id}${file.size ? ` \xB7 ${Math.round(file.size / 1024)} KB` : ""}${file.modifiedTime ? ` \xB7 ${file.modifiedTime.slice(0, 10)}` : ""}`
2527
+ );
2528
+ }
2529
+ context.print("");
2530
+ context.print("Fetch one with: pm drive get <id>");
2531
+ return 0;
2532
+ }
2533
+ async function driveGet(context, fileId) {
2534
+ if (!fileId) {
2535
+ context.error("Say which file: pm drive get <id>");
2536
+ return 2;
2537
+ }
2538
+ const response = await callApi(
2539
+ context,
2540
+ `/api/v1/google/drive/files/${encodeURIComponent(fileId)}/content`
2541
+ );
2542
+ if (!response) return 1;
2543
+ if (!response.ok) return complain(context, response);
2544
+ const disposition = response.headers.get("content-disposition") ?? "";
2545
+ const named = /filename="([^"]+)"/.exec(disposition)?.[1];
2546
+ const out = stringFlag(context.args, "out");
2547
+ const target = resolve4(out ?? basename(named ?? fileId));
2548
+ writeFileSync4(target, Buffer.from(await response.arrayBuffer()));
2549
+ context.print(target);
2550
+ return 0;
2551
+ }
2552
+ async function drivePut(context, path) {
2553
+ if (!path) {
2554
+ context.error("Say which file: pm drive put ./notes.md");
2555
+ return 2;
2556
+ }
2557
+ let bytes;
2558
+ try {
2559
+ bytes = readFileSync5(resolve4(path));
2560
+ } catch {
2561
+ context.error(`Cannot read ${path}.`);
2562
+ return 1;
2563
+ }
2564
+ const name = stringFlag(context.args, "name") ?? basename(path);
2565
+ const response = await callApi(
2566
+ context,
2567
+ `/api/v1/google/drive/files?name=${encodeURIComponent(name)}`,
2568
+ {
2569
+ method: "POST",
2570
+ // The bytes as bytes. Base64 in JSON would be a third larger and would
2571
+ // make the limit somebody was told about stop matching the one they hit.
2572
+ headers: { "content-type": "application/octet-stream" },
2573
+ body: new Uint8Array(bytes)
2574
+ }
2575
+ );
2576
+ if (!response) return 1;
2577
+ if (!response.ok) return complain(context, response);
2578
+ const saved = await response.json();
2579
+ context.print(`Saved "${saved.name}" to Drive.${saved.link ? ` ${saved.link}` : ""}`);
2580
+ return 0;
2581
+ }
2582
+ async function mailCommand(context) {
2583
+ const [, noun, ...rest] = context.args.words;
2584
+ if (noun === "read" || noun === "get") {
2585
+ const messageId = rest.join(" ").trim();
2586
+ if (!messageId) {
2587
+ context.error("Say which message: pm mail read <id>");
2588
+ return 2;
2589
+ }
2590
+ const response2 = await callApi(
2591
+ context,
2592
+ `/api/v1/google/mail/${encodeURIComponent(messageId)}`
2593
+ );
2594
+ if (!response2) return 1;
2595
+ if (!response2.ok) return complain(context, response2);
2596
+ const message2 = await response2.json();
2597
+ if (context.flags.output === "json") {
2598
+ context.print(JSON.stringify(message2, void 0, 2));
2599
+ return 0;
2600
+ }
2601
+ context.print(`From: ${message2.from ?? "unknown"}`);
2602
+ context.print(`Subject: ${message2.subject ?? "(none)"}`);
2603
+ if (message2.date) context.print(`Date: ${message2.date}`);
2604
+ context.print("");
2605
+ context.print(message2.body);
2606
+ if (message2.attachments.length > 0) {
2607
+ context.print("");
2608
+ context.print("Attached:");
2609
+ for (const one of message2.attachments) context.print(` ${one.filename} (${one.mimeType})`);
2610
+ }
2611
+ return 0;
2612
+ }
2613
+ const query = [noun, ...rest].filter(Boolean).join(" ").trim();
2614
+ const response = await callApi(
2615
+ context,
2616
+ `/api/v1/google/mail?limit=20${query ? `&query=${encodeURIComponent(query)}` : ""}`
2617
+ );
2618
+ if (!response) return 1;
2619
+ if (!response.ok) return complain(context, response);
2620
+ const { data } = await response.json();
2621
+ if (data.length === 0) {
2622
+ context.print(query ? `No mail matches "${query}".` : "Nothing in that mailbox.");
2623
+ return 0;
2624
+ }
2625
+ if (context.flags.output === "json") {
2626
+ context.print(JSON.stringify(data, void 0, 2));
2627
+ return 0;
2628
+ }
2629
+ for (const message2 of data) {
2630
+ const marks = [message2.unread ? "unread" : "", message2.hasAttachments ? "attachment" : ""].filter(Boolean).join(", ");
2631
+ context.print(` ${message2.subject ?? "(no subject)"}${marks ? ` [${marks}]` : ""}`);
2632
+ context.print(
2633
+ ` ${message2.from ?? "unknown"}${message2.date ? ` \xB7 ${message2.date.slice(0, 10)}` : ""}`
2634
+ );
2635
+ context.print(` ${message2.id}`);
2636
+ }
2637
+ context.print("");
2638
+ context.print("Read one with: pm mail read <id>");
2639
+ return 0;
2640
+ }
2641
+
2199
2642
  // src/commands/requests.ts
2643
+ import { existsSync as existsSync4, writeFileSync as writeFileSync5 } from "node:fs";
2644
+ import { resolve as resolve5 } from "node:path";
2200
2645
  async function requestsCommand(context) {
2646
+ if (context.args.words[1] === "get") return collectCommand(context);
2201
2647
  const credential = context.resolved.credential;
2202
2648
  if (!credential) {
2203
2649
  context.error("Sign in first: pm auth login");
@@ -2235,16 +2681,53 @@ async function requestsCommand(context) {
2235
2681
  context.print("They cannot be approved from here \u2014 see `pm help requests`.");
2236
2682
  return 0;
2237
2683
  }
2684
+ async function collectCommand(context) {
2685
+ const credential = context.resolved.credential;
2686
+ if (!credential) {
2687
+ context.error("Sign in first: pm auth login");
2688
+ return 1;
2689
+ }
2690
+ const id = context.args.words[2];
2691
+ if (!id) {
2692
+ context.error("Which request? `pm requests` lists them with their ids.");
2693
+ return 2;
2694
+ }
2695
+ const apiUrl = context.resolved.apiUrl.replace(/\/+$/, "");
2696
+ const link = await fetch(
2697
+ `${apiUrl}/api/v1/agent/request/${encodeURIComponent(id)}/download`,
2698
+ { headers: { authorization: `Bearer ${credential.token}` } }
2699
+ );
2700
+ if (!link.ok) {
2701
+ const body = await link.json().catch(() => void 0);
2702
+ context.error(body?.error?.message ?? `Could not prepare that file (${link.status}).`);
2703
+ return 1;
2704
+ }
2705
+ const { downloadUrl, filename } = await link.json();
2706
+ const file = await fetch(downloadUrl);
2707
+ if (!file.ok) {
2708
+ context.error(`The download refused it (${file.status}). Links expire in minutes \u2014 try again.`);
2709
+ return 1;
2710
+ }
2711
+ const name = stringFlag(context.args, "output", "o") ?? filename;
2712
+ const target = resolve5(name);
2713
+ if (existsSync4(target)) {
2714
+ context.error(`${target} already exists. Pass --output to write somewhere else.`);
2715
+ return 1;
2716
+ }
2717
+ writeFileSync5(target, new Uint8Array(await file.arrayBuffer()));
2718
+ context.print(`Wrote ${target}`);
2719
+ return 0;
2720
+ }
2238
2721
 
2239
2722
  // src/workspace.ts
2240
- import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
2241
- import { dirname as dirname3, join as join4, resolve as resolvePath } from "node:path";
2723
+ import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "node:fs";
2724
+ import { dirname as dirname3, join as join5, resolve as resolvePath } from "node:path";
2242
2725
  var WORKSPACE_FILE = ".persistmemory.json";
2243
2726
  function findWorkspace(from = process.cwd()) {
2244
2727
  let dir = resolvePath(from);
2245
2728
  for (; ; ) {
2246
- const file = join4(dir, WORKSPACE_FILE);
2247
- if (existsSync4(file)) {
2729
+ const file = join5(dir, WORKSPACE_FILE);
2730
+ if (existsSync5(file)) {
2248
2731
  const config = readWorkspace(file);
2249
2732
  if (config) return { file, dir, config };
2250
2733
  }
@@ -2255,7 +2738,7 @@ function findWorkspace(from = process.cwd()) {
2255
2738
  }
2256
2739
  function readWorkspace(file) {
2257
2740
  try {
2258
- const parsed = JSON.parse(readFileSync5(file, "utf8"));
2741
+ const parsed = JSON.parse(readFileSync6(file, "utf8"));
2259
2742
  const space = parsed.space;
2260
2743
  if (space && typeof space === "object" && typeof space.id === "string" && space.id !== "" && typeof space.name === "string") {
2261
2744
  return { space: { id: space.id, name: space.name } };
@@ -2266,8 +2749,8 @@ function readWorkspace(file) {
2266
2749
  }
2267
2750
  }
2268
2751
  function writeWorkspace(dir, config) {
2269
- const file = join4(dir, WORKSPACE_FILE);
2270
- writeFileSync4(file, `${JSON.stringify(config, null, 2)}
2752
+ const file = join5(dir, WORKSPACE_FILE);
2753
+ writeFileSync6(file, `${JSON.stringify(config, null, 2)}
2271
2754
  `, "utf8");
2272
2755
  return file;
2273
2756
  }
@@ -2536,9 +3019,9 @@ function message(error) {
2536
3019
  }
2537
3020
 
2538
3021
  // src/commands/maintain.ts
2539
- import { existsSync as existsSync5, rmSync } from "node:fs";
3022
+ import { existsSync as existsSync6, rmSync } from "node:fs";
2540
3023
  import { spawnSync } from "node:child_process";
2541
- import { dirname as dirname4, resolve as resolve5 } from "node:path";
3024
+ import { dirname as dirname4, resolve as resolve7 } from "node:path";
2542
3025
  import { fileURLToPath } from "node:url";
2543
3026
  async function updateCommand(context) {
2544
3027
  const manager = installer();
@@ -2580,7 +3063,7 @@ Delete the file it runs from: ${processPath()}`
2580
3063
  return 0;
2581
3064
  }
2582
3065
  async function deleteCommand(context) {
2583
- if (!existsSync5(context.paths.dir)) {
3066
+ if (!existsSync6(context.paths.dir)) {
2584
3067
  context.print(`Nothing to delete: ${context.paths.dir} does not exist.`);
2585
3068
  return 0;
2586
3069
  }
@@ -2609,7 +3092,7 @@ async function deleteCommand(context) {
2609
3092
  return 0;
2610
3093
  }
2611
3094
  function removeEverything(context) {
2612
- const dir = resolve5(context.paths.dir);
3095
+ const dir = resolve7(context.paths.dir);
2613
3096
  if (dir === "/" || dir.split("/").filter(Boolean).length < 2) {
2614
3097
  context.error(`Refusing to delete ${dir}: that does not look like a data directory.`);
2615
3098
  return;
@@ -2621,7 +3104,7 @@ function installer() {
2621
3104
  }
2622
3105
  function processPath() {
2623
3106
  try {
2624
- return resolve5(dirname4(fileURLToPath(import.meta.url)));
3107
+ return resolve7(dirname4(fileURLToPath(import.meta.url)));
2625
3108
  } catch {
2626
3109
  return process.argv[1] ?? "";
2627
3110
  }
@@ -2633,12 +3116,12 @@ import { randomUUID } from "node:crypto";
2633
3116
  import { relative as relative2 } from "node:path";
2634
3117
 
2635
3118
  // src/events.ts
2636
- import { appendFileSync, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync6 } from "node:fs";
2637
- import { join as join5 } from "node:path";
3119
+ import { appendFileSync, existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7 } from "node:fs";
3120
+ import { join as join6 } from "node:path";
2638
3121
  function openSessionLog(paths, id) {
2639
- const directory = join5(paths.dir, "sessions");
3122
+ const directory = join6(paths.dir, "sessions");
2640
3123
  mkdirSync3(directory, { recursive: true, mode: 448 });
2641
- const path = join5(directory, `${id}.jsonl`);
3124
+ const path = join6(directory, `${id}.jsonl`);
2642
3125
  return {
2643
3126
  id,
2644
3127
  path,
@@ -2650,8 +3133,8 @@ function openSessionLog(paths, id) {
2650
3133
  }
2651
3134
  },
2652
3135
  read() {
2653
- if (!existsSync6(path)) return [];
2654
- return readFileSync6(path, "utf8").split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
3136
+ if (!existsSync7(path)) return [];
3137
+ return readFileSync7(path, "utf8").split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
2655
3138
  try {
2656
3139
  return [JSON.parse(line)];
2657
3140
  } catch {
@@ -2728,9 +3211,9 @@ async function sessionCommand(context) {
2728
3211
  context.print(` Ask anything. /help for commands, /exit to leave.
2729
3212
  `);
2730
3213
  const readline = createInterface2({ input: process.stdin, output: process.stdout });
2731
- const ask = (prompt) => new Promise((resolve6) => {
2732
- readline.question(prompt, resolve6);
2733
- readline.once("close", () => resolve6(void 0));
3214
+ const ask = (prompt) => new Promise((resolve8) => {
3215
+ readline.question(prompt, resolve8);
3216
+ readline.once("close", () => resolve8(void 0));
2734
3217
  });
2735
3218
  const root = process.cwd();
2736
3219
  let running = true;
@@ -2914,7 +3397,7 @@ async function write2(args) {
2914
3397
  }
2915
3398
 
2916
3399
  // src/commands/memory.ts
2917
- import { readFileSync as readFileSync7 } from "node:fs";
3400
+ import { readFileSync as readFileSync8 } from "node:fs";
2918
3401
 
2919
3402
  // src/spaces.ts
2920
3403
  async function spacesFor(context, client, env = process.env) {
@@ -2978,7 +3461,7 @@ async function rememberCommand(context) {
2978
3461
  let text;
2979
3462
  if (file) {
2980
3463
  try {
2981
- text = readFileSync7(file, "utf8");
3464
+ text = readFileSync8(file, "utf8");
2982
3465
  } catch {
2983
3466
  context.error(`Could not read ${file}.`);
2984
3467
  return 1;
@@ -3286,6 +3769,18 @@ async function dispatch(context) {
3286
3769
  return statusCommand(context);
3287
3770
  case "requests":
3288
3771
  return requestsCommand(context);
3772
+ /*
3773
+ Google, through the API rather than through Google.
3774
+
3775
+ A laptop holds a bearer token and no database connection, so the
3776
+ credential it can prove is the one the API accepts. Google's own tokens
3777
+ never leave the deployment — which is what stops a stolen
3778
+ `~/.persistmemory` from being a stolen mailbox.
3779
+ */
3780
+ case "drive":
3781
+ return driveCommand(context);
3782
+ case "mail":
3783
+ return mailCommand(context);
3289
3784
  /**
3290
3785
  * `pm <verb> <noun>`, the grammar the Harness CLI uses.
3291
3786
  *