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