dazzer-connect 0.2.0 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +68 -0
  2. package/dist/index.js +751 -106
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -67,6 +67,74 @@ The second carries a pass, for clients that cannot do that:
67
67
 
68
68
  Merge either one with any `mcpServers` entries you already have, save, and restart your client.
69
69
 
70
+ ## Put files into Dazzer
71
+
72
+ Dazzer takes a new file only with a description of it. A single new file needs an entry under its own name in a descriptions file, passed with `--described-in`, just as a folder does. The descriptions file is shown below.
73
+
74
+ ```bash
75
+ dazzer add report.pdf
76
+ dazzer add ./backlog
77
+ ```
78
+
79
+ A folder is walked with its subfolders. Hidden files are left alone, and nothing outside the folder is ever sent. The first time, you are asked which space things go in: run the same command again with `--space "<name>"`, or with `--keep-to-myself`. A folder run ends with a count of what went in and what was skipped. Add `--why` to see each file that was skipped, and why. A run that left a file out exits with a failure code. A file already in Dazzer was not left out. In a folder run, neither was a file held while you are asked whether it is a newer version of one already there.
80
+
81
+ A folder run without a descriptions file sends nothing and says so.
82
+
83
+ ### With what your assistant wrote about each file
84
+
85
+ Your own AI assistant can read the files and say what each one is. It writes that into one JSON file, called the descriptions file. Each key is a file's path inside the folder, or the file's name when you add just one, and what sits under a key is that file's entry:
86
+
87
+ ```json
88
+ {
89
+ "notes/plan.md": {
90
+ "description": {
91
+ "what_it_is": { "text": "the plan for the spring launch", "quote": "Spring launch plan" },
92
+ "what_it_covers": { "text": "who ships what, week by week", "quote": "Week 1: design review" },
93
+ "what_it_decides": null,
94
+ "how_much_was_read": { "all": true }
95
+ },
96
+ "facts": [{ "text": "the launch is on 14 April", "quote": "Launch: 14 April" }],
97
+ "document_date": "2026-03-01"
98
+ },
99
+ "scan.png": {
100
+ "description": {
101
+ "what_it_is": { "text": "a photo of the gate log", "quote": "GATE LOG" },
102
+ "what_it_covers": { "text": "who signed for the gate overnight", "quote": "night porter" },
103
+ "what_it_decides": null,
104
+ "how_much_was_read": { "all": true }
105
+ },
106
+ "transcription": "GATE LOG\nSigned by the night porter at four."
107
+ }
108
+ }
109
+ ```
110
+
111
+ Save it beside the folder, never inside it — for example as `backlog-descriptions.json` next to `backlog`. Inside the folder, it is one more file a run has to tell apart from the documents. Then:
112
+
113
+ ```bash
114
+ dazzer add ./backlog --described-in ./backlog-descriptions.json
115
+ ```
116
+
117
+ Each file goes with its own entry. The descriptions file named by `--described-in` is not sent with them, even when it sits inside the folder, and the run says to move it.
118
+
119
+ - Each entry's form is checked before its file is sent. An entry in a form Dazzer refuses, such as a date written as "March 2026", holds its file back, and a line names the field and the form it takes. What the entry says is judged by Dazzer once the file has arrived.
120
+ - A file with no entry, or whose entry has no description, is not sent. The count says how many, and names them while there are few.
121
+ - An entry that names no file being added is reported, and nothing is sent for it. If no entry names a file being added, or none has a description, nothing is sent at all.
122
+ - A file Dazzer refuses is named, with why it was not added. A file refused for its kind or its size is only counted, and `--why` names it.
123
+ - Whatever Dazzer did not keep of an entry is printed file by file, in plain words.
124
+ - Sent again, a folder already in Dazzer says how many files took the description sent now, how many already had one, and how many did not take the one sent.
125
+
126
+ An assistant that can run commands runs this one itself. One that cannot gives you the descriptions file to save beside the folder, and the line to run.
127
+
128
+ ## Keep it up to date
129
+
130
+ Dazzer takes files only from a recent enough copy of this tool. An older copy is told so on its first call, before it sends anything, with the one command that updates it:
131
+
132
+ ```bash
133
+ npm install -g dazzer-connect@latest
134
+ ```
135
+
136
+ Once a day, the tool also tells you when a newer version is out. It looks at most once a day, never makes a command wait for the answer, and stays quiet if it cannot find out. What it remembers sits in the same settings folder as the rest of its state, below.
137
+
70
138
  ## Other commands
71
139
 
72
140
  ```bash
package/dist/index.js CHANGED
@@ -8,6 +8,10 @@ import { Command } from "commander";
8
8
  import { open, stat as statPath } from "node:fs/promises";
9
9
  import { basename, extname } from "node:path";
10
10
 
11
+ // src/lib/api-client.ts
12
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+
11
15
  // src/lib/output.ts
12
16
  function writeOut(message) {
13
17
  process.stdout.write(`${message}
@@ -19,6 +23,8 @@ function writeErr(message) {
19
23
  }
20
24
  var CANNOT_REACH_DAZZER = "I can't reach Dazzer right now \u2014 nothing was sent. Try again shortly.";
21
25
  var SIGN_IN_NO_LONGER_VALID = "Your Dazzer sign-in is no longer valid. Reconnect your client (or run `dazzer auth login`).";
26
+ var UPDATE_THE_TOOL = "npm install -g dazzer-connect@latest";
27
+ var newerToolIsOut = (version2) => `Version ${version2} of dazzer is out. Update it with: ${UPDATE_THE_TOOL}`;
22
28
  function printDeviceInstructions(deviceAuth) {
23
29
  writeOut("");
24
30
  if (deviceAuth.verification_uri_complete) {
@@ -265,6 +271,7 @@ function assertHttpsServiceAddress(address) {
265
271
  }
266
272
  return address.replace(/\/$/, "");
267
273
  }
274
+ var CLIENT_HEADER = "x-dazzer-client";
268
275
  function readOfferedPlaces(offered) {
269
276
  if (!Array.isArray(offered)) return [];
270
277
  return offered.flatMap((place) => {
@@ -314,6 +321,31 @@ function refusalPlaces(body) {
314
321
  ...typeof keep === "boolean" ? { keepToYourself: keep } : {}
315
322
  };
316
323
  }
324
+ var wordsIn = (value) => typeof value === "string" && value.trim() !== "" ? value : void 0;
325
+ function refusalAbout(body) {
326
+ if (typeof body !== "object" || body === null) return {};
327
+ const flat = body;
328
+ const enveloped = typeof flat.error === "object" && flat.error !== null ? flat.error.details : void 0;
329
+ const fields = [
330
+ ...wordsIn(flat.field) === void 0 ? [] : [flat.field],
331
+ ...Array.isArray(enveloped) ? enveloped.flatMap((detail) => {
332
+ const named2 = typeof detail === "object" && detail !== null ? wordsIn(detail.field) : void 0;
333
+ return named2 === void 0 ? [] : [named2];
334
+ }) : []
335
+ ];
336
+ const dropped = Array.isArray(flat.dropped) ? flat.dropped.flatMap((entry) => {
337
+ if (typeof entry !== "object" || entry === null) return [];
338
+ const line = wordsIn(entry.line);
339
+ const reason = wordsIn(entry.reason);
340
+ return line === void 0 || reason === void 0 ? [] : [{ line, reason }];
341
+ }) : [];
342
+ const howMuch = typeof flat.how_much_was_read === "object" && flat.how_much_was_read !== null ? wordsIn(flat.how_much_was_read.reason) : void 0;
343
+ return {
344
+ ...fields.length > 0 ? { fields } : {},
345
+ ...dropped.length > 0 ? { dropped } : {},
346
+ ...howMuch === void 0 ? {} : { howMuchWasRead: howMuch }
347
+ };
348
+ }
317
349
  async function resolveCredential(auth2) {
318
350
  let held;
319
351
  try {
@@ -357,7 +389,8 @@ async function callDazzer(request) {
357
389
  const fetchFn = request.fetchFn ?? fetch;
358
390
  const headers = {
359
391
  accept: "application/json",
360
- authorization: `Bearer ${credential.held}`
392
+ authorization: `Bearer ${credential.held}`,
393
+ [CLIENT_HEADER]: request.client
361
394
  };
362
395
  if (request.body !== void 0) headers["content-type"] = "application/json";
363
396
  let res;
@@ -382,15 +415,99 @@ async function callDazzer(request) {
382
415
  httpStatus: res.status,
383
416
  ...detail === void 0 ? {} : { detail },
384
417
  ...code === void 0 ? {} : { code },
385
- ...refusalPlaces(body)
418
+ ...refusalPlaces(body),
419
+ ...refusalAbout(body)
386
420
  };
387
421
  }
388
422
  return { status: "ok", httpStatus: res.status, body };
389
423
  }
424
+ var REGISTRY = "https://registry.npmjs.org";
425
+ var LOOK_AT_MOST_MS = 3e3;
426
+ var A_DAY_MS = 24 * 60 * 60 * 1e3;
427
+ var NEWER_TOOL_FILE = "newer-version.json";
428
+ function releaseOf(version2) {
429
+ if (typeof version2 !== "string") return null;
430
+ const said = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})$/.exec(version2);
431
+ return said === null ? null : [Number(said[1]), Number(said[2]), Number(said[3])];
432
+ }
433
+ function isLater(published, mine) {
434
+ const theirs = releaseOf(published);
435
+ const own = /^(\d{1,6}\.\d{1,6}\.\d{1,6})(-[^+]+)?(\+.+)?$/.exec(mine);
436
+ const ours = own === null ? null : releaseOf(own[1]);
437
+ if (theirs === null || own === null || ours === null) return false;
438
+ for (let at = 0; at < 3; at += 1) {
439
+ if (theirs[at] !== ours[at]) return (theirs[at] ?? 0) > (ours[at] ?? 0);
440
+ }
441
+ return own[2] !== void 0;
442
+ }
443
+ async function recall(file) {
444
+ try {
445
+ const parsed = JSON.parse(await readFile(file, "utf8"));
446
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
447
+ } catch {
448
+ return {};
449
+ }
450
+ }
451
+ async function remember(folder, file, what) {
452
+ try {
453
+ await mkdir(folder, { recursive: true });
454
+ await writeFile(file, `${JSON.stringify(what)}
455
+ `, "utf8");
456
+ return true;
457
+ } catch {
458
+ return false;
459
+ }
460
+ }
461
+ var withinADay = (then, now) => typeof then === "number" && Number.isFinite(then) && then <= now && now - then < A_DAY_MS;
462
+ function lookForNewerTool(check) {
463
+ const now = check.now ?? Date.now;
464
+ const file = join(check.folder, NEWER_TOOL_FILE);
465
+ const stop = new AbortController();
466
+ const looked = (async () => {
467
+ try {
468
+ const before = await recall(file);
469
+ if (withinADay(before.looked_at, now()) || stop.signal.aborted) return;
470
+ const signal = AbortSignal.any([
471
+ stop.signal,
472
+ AbortSignal.timeout(check.lookAtMostMs ?? LOOK_AT_MOST_MS)
473
+ ]);
474
+ const res = await (check.fetchFn ?? fetch)(
475
+ `${REGISTRY}/${encodeURIComponent(check.name)}/latest`,
476
+ { headers: { accept: "application/json" }, signal }
477
+ );
478
+ const published = res.ok ? (await res.json()).version : void 0;
479
+ if (stop.signal.aborted) return;
480
+ const latest = releaseOf(published) === null ? void 0 : published;
481
+ await remember(check.folder, file, {
482
+ ...await recall(file),
483
+ looked_at: now(),
484
+ ...latest === void 0 ? {} : { latest }
485
+ });
486
+ } catch {
487
+ }
488
+ })();
489
+ return {
490
+ looked,
491
+ finish: async () => {
492
+ stop.abort();
493
+ try {
494
+ await looked;
495
+ const known = await recall(file);
496
+ const latest = known.latest;
497
+ if (typeof latest !== "string" || !isLater(latest, check.version)) return null;
498
+ if (withinADay(known.told_at, now())) return null;
499
+ const kept = await remember(check.folder, file, { ...known, told_at: now() });
500
+ return kept ? newerToolIsOut(latest) : null;
501
+ } catch {
502
+ return null;
503
+ }
504
+ }
505
+ };
506
+ }
390
507
 
391
508
  // src/lib/config-store.ts
392
- import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
393
- import { join } from "node:path";
509
+ import { mkdir as mkdir2, readFile as readFile2, unlink, writeFile as writeFile2 } from "node:fs/promises";
510
+ import { join as join2 } from "node:path";
394
511
  import envPaths from "env-paths";
395
512
 
396
513
  // src/lib/discovery.ts
@@ -454,9 +571,9 @@ async function discoverEndpoints(issuer, fetchFn = fetch) {
454
571
 
455
572
  // src/lib/config-store.ts
456
573
  var paths = envPaths("dazzer", { suffix: "" });
457
- var CONFIG_FILE = join(paths.config, "auth.json");
574
+ var CONFIG_FILE = join2(paths.config, "auth.json");
458
575
  async function ensureConfigDir() {
459
- await mkdir(paths.config, { recursive: true });
576
+ await mkdir2(paths.config, { recursive: true });
460
577
  }
461
578
  var MAX_CLIENT_ID_LENGTH = 512;
462
579
  async function saveAuthMetadata(meta) {
@@ -469,12 +586,12 @@ async function saveAuthMetadata(meta) {
469
586
  const savedToStore = typeof meta.savedToStore === "boolean" ? meta.savedToStore : void 0;
470
587
  await ensureConfigDir();
471
588
  const checked = { issuer, clientId, expiresAt, savedToStore };
472
- await writeFile(CONFIG_FILE, `${JSON.stringify(checked, null, 2)}
589
+ await writeFile2(CONFIG_FILE, `${JSON.stringify(checked, null, 2)}
473
590
  `, "utf8");
474
591
  }
475
592
  async function loadAuthMetadata() {
476
593
  try {
477
- const raw = await readFile(CONFIG_FILE, "utf8");
594
+ const raw = await readFile2(CONFIG_FILE, "utf8");
478
595
  const parsed = JSON.parse(raw);
479
596
  if (typeof parsed.issuer !== "string" || typeof parsed.clientId !== "string") {
480
597
  return null;
@@ -640,7 +757,7 @@ function createTokenStore() {
640
757
 
641
758
  // src/lib/walk.ts
642
759
  import { readdir, realpath, stat } from "node:fs/promises";
643
- import { join as join2, sep } from "node:path";
760
+ import { join as join3, sep } from "node:path";
644
761
  function inside(base, candidate) {
645
762
  return candidate === base || candidate.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
646
763
  }
@@ -665,7 +782,7 @@ async function walkFolder(root) {
665
782
  entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
666
783
  for (const entry of entries) {
667
784
  if (entry.name.startsWith(".")) continue;
668
- const full = join2(dir, entry.name);
785
+ const full = join3(dir, entry.name);
669
786
  const relative = `${prefix}${entry.name}`;
670
787
  if (entry.isSymbolicLink()) {
671
788
  await followShortcut(full, relative);
@@ -733,7 +850,7 @@ async function walkFolder(root) {
733
850
  var SPACE_PATH = "/documents/v1/space";
734
851
  var LANDING_PATH = "/documents/v1/landing";
735
852
  var LANDED_PATH = "/documents/v1/landed";
736
- var cannotOpen = (name) => `Couldn't open ${name} \u2014 check its permissions.`;
853
+ var cannotOpen = (name2) => `Couldn't open ${name2} \u2014 check its permissions.`;
737
854
  var SLOWING_DOWN = "Slowing down to stay within limits \u2014 still going.";
738
855
  var ALREADY_IN = "That one's already in \u2014 nothing to do.";
739
856
  var waitingOnYou = (many) => `${many} waiting on your answer.`;
@@ -756,9 +873,38 @@ var NUMBER_WORDS = [
756
873
  "ten"
757
874
  ];
758
875
  var inWords = (many) => NUMBER_WORDS[many] ?? String(many);
759
- var cannotFind = (name) => `Couldn't find ${name}.`;
760
- var didNotFinish = (name) => `The send didn't finish \u2014 ${name} is not in Dazzer. Try again shortly.`;
761
- var wentWrong = (name) => `Something went wrong and ${name} is not in Dazzer. Try again shortly.`;
876
+ var cannotFind = (name2) => `Couldn't find ${name2}.`;
877
+ var didNotFinish = (name2) => `The send didn't finish \u2014 ${name2} is not in Dazzer. Try again shortly.`;
878
+ var wentWrong = (name2) => `Something went wrong and ${name2} is not in Dazzer. Try again shortly.`;
879
+ var descriptionsNotFound = (name2) => `Couldn't find the descriptions file ${name2} \u2014 nothing was sent.`;
880
+ var descriptionsUnreadable = (name2) => `Couldn't read the descriptions file ${name2} \u2014 nothing was sent.`;
881
+ var descriptionsNotJson = (name2) => `The descriptions file ${name2} is not JSON \u2014 nothing was sent.`;
882
+ var descriptionsNotAnObject = (name2) => `The descriptions file ${name2} is not one JSON object keyed by file path \u2014 nothing was sent.`;
883
+ var isTheDescriptionsFile = (name2) => `${name2} is the descriptions file \u2014 nothing was sent.`;
884
+ function namingNoFile(keys) {
885
+ const many = keys.length;
886
+ const which = many <= NAMED_WHEN_AT_MOST ? ` \u2014 ${listed(keys.map(asPrinted))}` : "";
887
+ return `${many} ${many === 1 ? "entry names" : "entries name"} no file being added${which}. Nothing was sent for ${many === 1 ? "it" : "them"}.`;
888
+ }
889
+ var noEntryMatches = (name2, example) => `No entry in ${name2} names a file being added \u2014 nothing was sent.` + (example === void 0 ? "" : ` A key looks like ${JSON.stringify(asPrinted(example))}.`);
890
+ var keepItBeside = "The descriptions file is inside the folder. Keep it beside the folder instead, so no later run sends it as a document.";
891
+ var itHadNoDescription = "It had no description.";
892
+ var NOTHING_DESCRIBED = "Nothing was sent. No file here has a description.";
893
+ var DESCRIBE_THEM = "Write what each file is in a descriptions file. Then run this again with --described-in.";
894
+ var notSentNoDescription = (name2) => `${name2} was not sent. It has no description.`;
895
+ var notAdded = (name2, because2) => [`${name2} was not added.`, ...because2].join(" ");
896
+ function alreadyThereDescriptions(counted) {
897
+ const parts = [
898
+ ...counted.filed > 0 ? [`${counted.filed} took the description sent now`] : [],
899
+ ...counted.alreadyDescribed > 0 ? [`${counted.alreadyDescribed} already had a description`] : [],
900
+ ...counted.notKept > 0 ? [`${counted.notKept} did not take the description sent`] : []
901
+ ];
902
+ return `Of those already there: ${parts.join(", ")}.`;
903
+ }
904
+ var itsDescriptionFiledNow = "Its description was filed now.";
905
+ var itAlreadyHasOne = "It already has a description, and keeps it.";
906
+ var itsDescriptionNotKept = "The description sent was not kept.";
907
+ var NAMED_WHEN_AT_MOST = 5;
762
908
  var choiceLine = (space, scopeId) => ` ${asPrinted(space)} \u2014 ${scopeId}`;
763
909
  var answerWith = (keepToYourself, numbered) => {
764
910
  const ways = [
@@ -788,8 +934,8 @@ var DECLARED_BY_EXTENSION = {
788
934
  ".webp": "image/webp"
789
935
  };
790
936
  var UNDECLARED = "application/octet-stream";
791
- function asPrinted(name) {
792
- return name.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ").trim();
937
+ function asPrinted(name2) {
938
+ return name2.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ").trim();
793
939
  }
794
940
  function readEnvelope(body) {
795
941
  if (typeof body !== "object" || body === null) return null;
@@ -865,10 +1011,11 @@ function askedWith(options) {
865
1011
  if (answer === void 0 || answer === "") return {};
866
1012
  return { space: /^\d+$/.test(answer) ? Number(answer) : answer };
867
1013
  }
868
- async function settleSpace(options, auth2, deps, voice, name) {
1014
+ async function settleSpace(options, auth2, deps, voice, name2) {
869
1015
  const answer = await callPaced(
870
1016
  {
871
1017
  baseUrl: options.baseUrl,
1018
+ client: options.client,
872
1019
  path: SPACE_PATH,
873
1020
  method: "POST",
874
1021
  body: askedWith(options),
@@ -879,12 +1026,12 @@ async function settleSpace(options, auth2, deps, voice, name) {
879
1026
  voice
880
1027
  );
881
1028
  if (answer.status !== "ok") {
882
- say(voice.write, answer, name);
1029
+ say(voice.write, answer, name2);
883
1030
  return null;
884
1031
  }
885
1032
  const data = readEnvelope(answer.body);
886
1033
  if (data === null) {
887
- voice.write(wentWrong(name));
1034
+ voice.write(wentWrong(name2));
888
1035
  return null;
889
1036
  }
890
1037
  const space = typeof data.space === "string" && data.space.trim() !== "" ? data.space : null;
@@ -893,7 +1040,7 @@ async function settleSpace(options, auth2, deps, voice, name) {
893
1040
  }
894
1041
  const question = typeof data.question === "string" && data.question !== "" ? data.question : null;
895
1042
  if (question === null) {
896
- voice.write(wentWrong(name));
1043
+ voice.write(wentWrong(name2));
897
1044
  return null;
898
1045
  }
899
1046
  voice.write(question);
@@ -905,6 +1052,7 @@ async function settleSpace(options, auth2, deps, voice, name) {
905
1052
  var HELD_FOR_AN_ANSWER = "same_name_different_contents";
906
1053
  function fromAnswer(answer) {
907
1054
  if (answer.status === "refused") {
1055
+ if (aboutThisTool(answer)) return { kind: "stop", answer };
908
1056
  if (answer.code === HELD_FOR_AN_ANSWER && answer.detail !== void 0) {
909
1057
  return { kind: "held", question: answer.detail };
910
1058
  }
@@ -915,16 +1063,23 @@ function fromAnswer(answer) {
915
1063
  // Carried, not filled in: absent stays absent, so nothing downstream can print an offer the
916
1064
  // service did not make.
917
1065
  ...answer.choices === void 0 ? {} : { choices: answer.choices },
918
- ...answer.keepToYourself === void 0 ? {} : { keepToYourself: answer.keepToYourself }
1066
+ ...answer.keepToYourself === void 0 ? {} : { keepToYourself: answer.keepToYourself },
1067
+ ...answer.fields === void 0 ? {} : { fields: answer.fields },
1068
+ ...answer.dropped === void 0 ? {} : { dropped: answer.dropped },
1069
+ ...answer.howMuchWasRead === void 0 ? {} : { howMuchWasRead: answer.howMuchWasRead }
919
1070
  };
920
1071
  }
921
1072
  return { kind: "stop", answer };
922
1073
  }
1074
+ function aboutThisTool(answer) {
1075
+ return answer.status === "refused" && answer.fields?.includes(CLIENT_HEADER) === true;
1076
+ }
923
1077
  async function sendOne(input) {
924
1078
  const { deps, handle, options, voice } = input;
925
1079
  const described = await callPaced(
926
1080
  {
927
1081
  baseUrl: options.baseUrl,
1082
+ client: options.client,
928
1083
  path: LANDING_PATH,
929
1084
  method: "POST",
930
1085
  body: {
@@ -984,9 +1139,10 @@ async function sendOne(input) {
984
1139
  const landed = await callPaced(
985
1140
  {
986
1141
  baseUrl: options.baseUrl,
1142
+ client: options.client,
987
1143
  path: LANDED_PATH,
988
1144
  method: "POST",
989
- body: { ticket: landing.ticket },
1145
+ body: { ticket: landing.ticket, ...input.described },
990
1146
  auth: input.auth,
991
1147
  fetchFn: deps.fetchFn
992
1148
  },
@@ -996,11 +1152,310 @@ async function sendOne(input) {
996
1152
  if (landed.status !== "ok") return fromAnswer(landed);
997
1153
  const stored = readEnvelope(landed.body);
998
1154
  if (stored === null) return { kind: "went-wrong" };
999
- if (stored.already_here === true) return { kind: "already-here" };
1155
+ const alreadyHere = stored.already_here === true;
1156
+ const becameOfIt = whatBecameOfIt(stored, alreadyHere, "description" in (input.described ?? {}));
1157
+ if (alreadyHere) return { kind: "already-here", ...becameOfIt };
1000
1158
  return {
1001
1159
  kind: "added",
1002
1160
  fileName: typeof stored.file_name === "string" ? asPrinted(stored.file_name) : asPrinted(basename(input.path)),
1003
- space: typeof stored.space_name === "string" ? stored.space_name : null
1161
+ space: typeof stored.space_name === "string" ? stored.space_name : null,
1162
+ ...becameOfIt
1163
+ };
1164
+ }
1165
+ var ENTRY_FIELDS = ["description", "facts", "document_date", "transcription"];
1166
+ var DESCRIPTION_FIELDS = [
1167
+ "what_it_is",
1168
+ "what_it_covers",
1169
+ "what_it_decides",
1170
+ "how_much_was_read"
1171
+ ];
1172
+ var QUOTED_FIELDS = ["text", "quote"];
1173
+ var MOST_FACTS = 100;
1174
+ var MOST_TYPED_CHARACTERS = 1e6;
1175
+ var MOST_MESSAGE_BYTES = 1024 * 1024;
1176
+ var LONGEST_TICKET = 4096;
1177
+ var A_DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
1178
+ var A_MOMENT_ON_THE_DAY = /^T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
1179
+ var EARLIEST_YEAR = 1e3;
1180
+ function aDateTheDoorReads(value) {
1181
+ const written = value.trim();
1182
+ const day = A_DAY.exec(written.slice(0, 10));
1183
+ if (day === null) return false;
1184
+ const [year, month, date] = [Number(day[1]), Number(day[2]), Number(day[3])];
1185
+ if (year < EARLIEST_YEAR) return false;
1186
+ const midnight = new Date(Date.UTC(year, month - 1, date));
1187
+ if (midnight.getUTCFullYear() !== year || midnight.getUTCMonth() !== month - 1 || midnight.getUTCDate() !== date) {
1188
+ return false;
1189
+ }
1190
+ if (written.length === 10) return true;
1191
+ return A_MOMENT_ON_THE_DAY.test(written.slice(10)) && !Number.isNaN(new Date(written).getTime());
1192
+ }
1193
+ var sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino;
1194
+ var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1195
+ function asKey(path) {
1196
+ let key = path.normalize("NFC").replaceAll("\\", "/");
1197
+ while (key.startsWith("./")) key = key.slice(2);
1198
+ return key;
1199
+ }
1200
+ async function readDescriptions(path) {
1201
+ const name2 = asPrinted(basename(path));
1202
+ let handle;
1203
+ try {
1204
+ handle = await open(path, "r");
1205
+ } catch (err) {
1206
+ const missing = err?.code === "ENOENT";
1207
+ return { said: missing ? descriptionsNotFound(name2) : descriptionsUnreadable(name2) };
1208
+ }
1209
+ let text;
1210
+ let identity;
1211
+ try {
1212
+ const info = await handle.stat({ bigint: true });
1213
+ identity = { dev: info.dev, ino: info.ino };
1214
+ text = await handle.readFile("utf8");
1215
+ } catch {
1216
+ return { said: descriptionsUnreadable(name2) };
1217
+ } finally {
1218
+ await handle.close();
1219
+ }
1220
+ const json = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
1221
+ let parsed;
1222
+ try {
1223
+ parsed = JSON.parse(json);
1224
+ } catch {
1225
+ return { said: descriptionsNotJson(name2) };
1226
+ }
1227
+ if (!isPlainObject(parsed)) return { said: descriptionsNotAnObject(name2) };
1228
+ const entries = /* @__PURE__ */ new Map();
1229
+ for (const [key, value] of Object.entries(parsed)) {
1230
+ const matched = asKey(key);
1231
+ const already = entries.get(matched);
1232
+ entries.set(matched, { keys: [...already?.keys ?? [], key], value: already?.value ?? value });
1233
+ }
1234
+ return { name: name2, entries, identity };
1235
+ }
1236
+ function quoted(text) {
1237
+ const flat = asPrinted(text);
1238
+ return `"${flat.length > 40 ? `${flat.slice(0, 40)}\u2026` : flat}"`;
1239
+ }
1240
+ var A_QUOTED_SENTENCE = '{"text": "...", "quote": "..."}';
1241
+ function strayKey(value, fields, where, mayHold) {
1242
+ const stray = Object.keys(value).find((key) => !fields.includes(key));
1243
+ return stray === void 0 ? void 0 : `${where} holds ${quoted(stray)}. It holds only ${mayHold}.`;
1244
+ }
1245
+ function quotedSentenceWrong(value, where) {
1246
+ const stray = strayKey(value, QUOTED_FIELDS, where, "text and quote");
1247
+ if (stray !== void 0) return stray;
1248
+ for (const field of QUOTED_FIELDS) {
1249
+ const said = value[field];
1250
+ if (said !== void 0 && said !== null && typeof said !== "string") {
1251
+ return `the ${field} of ${where} is not text. Write it in quotes, like "Spring launch plan".`;
1252
+ }
1253
+ }
1254
+ return void 0;
1255
+ }
1256
+ function lineWrong(description, line, mayBeNull) {
1257
+ if (!(line in description)) return void 0;
1258
+ const value = description[line];
1259
+ if (value === null && mayBeNull) return void 0;
1260
+ const where = `description.${line}`;
1261
+ if (!isPlainObject(value)) {
1262
+ return `${where} is not an object. Write it like ${A_QUOTED_SENTENCE}${mayBeNull ? ", or null when the document settles nothing" : ""}.`;
1263
+ }
1264
+ return quotedSentenceWrong(value, where);
1265
+ }
1266
+ function descriptionWrong(value) {
1267
+ if (!isPlainObject(value)) {
1268
+ return `description is not an object. Write it like {"what_it_is": ${A_QUOTED_SENTENCE}, ...}.`;
1269
+ }
1270
+ return strayKey(
1271
+ value,
1272
+ DESCRIPTION_FIELDS,
1273
+ "description",
1274
+ "what_it_is, what_it_covers, what_it_decides and how_much_was_read"
1275
+ ) ?? lineWrong(value, "what_it_is", false) ?? lineWrong(value, "what_it_covers", false) ?? lineWrong(value, "what_it_decides", true);
1276
+ }
1277
+ function factsWrong(value) {
1278
+ if (!Array.isArray(value)) return `facts is not a list. Write it like [${A_QUOTED_SENTENCE}].`;
1279
+ if (value.length > MOST_FACTS) {
1280
+ return `facts holds ${value.length} facts. It takes at most ${MOST_FACTS}.`;
1281
+ }
1282
+ for (const [at, fact] of value.entries()) {
1283
+ const where = `fact ${at + 1}`;
1284
+ if (!isPlainObject(fact)) {
1285
+ return `${where} is not an object. Write each fact like ${A_QUOTED_SENTENCE}.`;
1286
+ }
1287
+ const wrong = quotedSentenceWrong(fact, where);
1288
+ if (wrong !== void 0) return wrong;
1289
+ }
1290
+ return void 0;
1291
+ }
1292
+ function dateWrong(value) {
1293
+ if (typeof value !== "string") return 'document_date is not text. Write it like "2026-03-01".';
1294
+ return aDateTheDoorReads(value) ? void 0 : `document_date ${quoted(value)} is not a date. Write it like "2026-03-01".`;
1295
+ }
1296
+ function typedWrong(value) {
1297
+ if (typeof value !== "string") {
1298
+ return "transcription is not text. Write the typed-out words as one string.";
1299
+ }
1300
+ return /\S/.test(value) && value.length > MOST_TYPED_CHARACTERS ? `transcription runs to ${value.length.toLocaleString("en-US")} characters. It takes at most ${MOST_TYPED_CHARACTERS.toLocaleString("en-US")}.` : void 0;
1301
+ }
1302
+ function sizeWrong(entry) {
1303
+ const bytes = Buffer.byteLength(
1304
+ JSON.stringify({ ticket: "t".repeat(LONGEST_TICKET), ...entry }),
1305
+ "utf8"
1306
+ );
1307
+ return bytes > MOST_MESSAGE_BYTES ? `its entry comes to ${bytes.toLocaleString("en-US")} bytes. One message takes at most ${MOST_MESSAGE_BYTES.toLocaleString("en-US")}.` : void 0;
1308
+ }
1309
+ function checkEntry(found) {
1310
+ if (found.keys.length > 1) {
1311
+ return {
1312
+ wrong: `${found.keys.length} entries name it \u2014 ${listed(found.keys.map(quoted))}. Keep one.`
1313
+ };
1314
+ }
1315
+ const entry = found.value;
1316
+ if (!isPlainObject(entry)) {
1317
+ return {
1318
+ wrong: 'its entry is not an object. Write it like {"description": {...}, "facts": [...], "document_date": "2026-03-01"}.'
1319
+ };
1320
+ }
1321
+ const wrong = strayKey(
1322
+ entry,
1323
+ ENTRY_FIELDS,
1324
+ "its entry",
1325
+ "description, facts, document_date and transcription"
1326
+ ) ?? ("description" in entry ? descriptionWrong(entry.description) : void 0) ?? ("facts" in entry ? factsWrong(entry.facts) : void 0) ?? ("document_date" in entry ? dateWrong(entry.document_date) : void 0) ?? ("transcription" in entry ? typedWrong(entry.transcription) : void 0) ?? sizeWrong(entry);
1327
+ return wrong === void 0 ? { described: entry } : { wrong };
1328
+ }
1329
+ var wordsOf = (value) => typeof value === "string" && value.trim() !== "" ? value : void 0;
1330
+ var listOf = (value) => Array.isArray(value) ? value : [];
1331
+ var LINE_DROPPED = {
1332
+ "line-empty": "it has no words",
1333
+ "line-too-long": "it is too long",
1334
+ "line-verbatim-from-document": "its words are copied out of the document",
1335
+ "quote-empty": "it quotes nothing",
1336
+ "quote-too-long": "its quote is too long",
1337
+ "unstorable-characters": "it holds a character that cannot be stored",
1338
+ "quote-not-in-document": "the words it quotes are not in the document"
1339
+ };
1340
+ var A_LINE = {
1341
+ what_it_is: "The line saying what it is",
1342
+ what_it_covers: "The line saying what it covers",
1343
+ what_it_decides: "The line saying what it decides"
1344
+ };
1345
+ var HOW_MUCH_NOT_KEPT = {
1346
+ "not-said": "Its description does not say how much of the file was read.",
1347
+ "which-part-empty": "Its description says only part of the file was read, and not which part.",
1348
+ "which-part-unstorable-characters": "The part it says was read holds a character that cannot be stored.",
1349
+ "which-part-too-long": "The part it says was read is too long. Say it in one short sentence.",
1350
+ "which-part-line-break-or-marker": "The part it says was read is not one plain line."
1351
+ };
1352
+ var HOW_MUCH_NOT_KEPT_OTHERWISE = "Its description does not say how much of the file was read in a form Dazzer keeps.";
1353
+ var FACT_DROPPED = {
1354
+ "fact-empty": "it has no words",
1355
+ "fact-too-long": "it is too long",
1356
+ "quote-empty": "it quotes nothing",
1357
+ "quote-too-long": "its quote is too long",
1358
+ "unstorable-characters": "it holds a character that cannot be stored",
1359
+ "quote-not-in-document": "the words it quotes are not in the document",
1360
+ "nothing-to-check-it-against": "there are no words in the file to check it against"
1361
+ };
1362
+ var DATE_NOT_KEPT = {
1363
+ "later-than-today": "it is later than today",
1364
+ "document-already-held": "the document was already here, with the date it came with",
1365
+ "stored-as-the-moment-it-arrived": "that day had not begun in universal time, so the moment it arrived is kept"
1366
+ };
1367
+ var DESCRIPTION_NOT_FILED = {
1368
+ nothing_to_check_it_against: "there are no words in the file to check it against"
1369
+ };
1370
+ var TYPING_NOT_USED = {
1371
+ not_used_file_has_its_own_text: "the file has words of its own",
1372
+ not_used_document_already_held: "the document was already here"
1373
+ };
1374
+ var because = (words, name2) => {
1375
+ const said = words[name2];
1376
+ return said === void 0 ? ` (${name2}).` : `, because ${said}.`;
1377
+ };
1378
+ var lineNotKept = (dropped) => `${A_LINE[dropped.line] ?? "A line of its description"} was not kept${because(LINE_DROPPED, dropped.reason)}`;
1379
+ var howMuchNotKept = (reason) => HOW_MUCH_NOT_KEPT[reason] ?? HOW_MUCH_NOT_KEPT_OTHERWISE;
1380
+ function whatToDoAbout(refused) {
1381
+ return refused.code === "description_missing" ? DESCRIBE_THEM : void 0;
1382
+ }
1383
+ var ADD_THE_TEXT = "Add the text you read in it to its entry.";
1384
+ function notAddedBecause(refused) {
1385
+ const dropped = (refused.dropped ?? []).map(lineNotKept);
1386
+ const howMuch = refused.howMuchWasRead === void 0 ? [] : [howMuchNotKept(refused.howMuchWasRead)];
1387
+ switch (refused.code) {
1388
+ case "description_missing":
1389
+ return ["It has no description."];
1390
+ case "description_not_kept": {
1391
+ const noLineKept = refused.fields?.includes("description") === true;
1392
+ const said = [...noLineKept ? ["No line of its description was kept."] : [], ...dropped];
1393
+ const all = [...said, ...howMuch];
1394
+ return all.length > 0 ? all : ["Its description was not kept."];
1395
+ }
1396
+ case "nothing_to_check_it_against":
1397
+ return [
1398
+ "Dazzer holds no words of it to check its description against.",
1399
+ ADD_THE_TEXT,
1400
+ ...howMuch
1401
+ ];
1402
+ case "transcription_too_long":
1403
+ return ["The text typed out of it is longer than Dazzer keeps for one file."];
1404
+ case "try_again_shortly":
1405
+ return ["It could not be read just now. Try again shortly."];
1406
+ case "could_not_read_it":
1407
+ return ["Dazzer could not read its words.", ADD_THE_TEXT];
1408
+ default:
1409
+ return void 0;
1410
+ }
1411
+ }
1412
+ function whatBecameOfIt(stored, alreadyHere, descriptionSent) {
1413
+ const lines = [];
1414
+ let filing;
1415
+ const description = isPlainObject(stored.description) ? stored.description : null;
1416
+ if (description !== null) {
1417
+ const state = wordsOf(description.state);
1418
+ const what = wordsOf(description.what);
1419
+ if (descriptionSent && state !== void 0) {
1420
+ filing = state === "filed" ? "filed" : alreadyHere && state === "not_filed" && what === void 0 ? "already-described" : "not-kept";
1421
+ }
1422
+ if (state === "not_filed" && what !== void 0) {
1423
+ lines.push("Its description was not kept.");
1424
+ } else if (state !== void 0 && state !== "filed" && state !== "none_supplied" && !(alreadyHere && state === "not_filed")) {
1425
+ lines.push(
1426
+ state === "not_filed" ? "Its description was not filed." : `Its description was not filed${because(DESCRIPTION_NOT_FILED, state)}`
1427
+ );
1428
+ }
1429
+ for (const dropped of listOf(description.dropped)) {
1430
+ if (!isPlainObject(dropped)) continue;
1431
+ const line = wordsOf(dropped.line);
1432
+ const reason = wordsOf(dropped.reason);
1433
+ if (line !== void 0 && reason !== void 0) lines.push(lineNotKept({ line, reason }));
1434
+ }
1435
+ const howMuch = isPlainObject(description.how_much_was_read) ? description.how_much_was_read : null;
1436
+ if (howMuch !== null && wordsOf(howMuch.what) !== void 0) {
1437
+ lines.push(howMuchNotKept(wordsOf(howMuch.reason) ?? ""));
1438
+ }
1439
+ }
1440
+ const facts = isPlainObject(stored.facts) ? stored.facts : null;
1441
+ for (const dropped of listOf(facts?.dropped)) {
1442
+ if (!isPlainObject(dropped)) continue;
1443
+ const reason = wordsOf(dropped.reason);
1444
+ if (typeof dropped.fact === "number" && reason !== void 0) {
1445
+ lines.push(`Fact ${dropped.fact} was not kept${because(FACT_DROPPED, reason)}`);
1446
+ }
1447
+ }
1448
+ const date = isPlainObject(stored.date_not_kept) ? wordsOf(stored.date_not_kept.reason) : void 0;
1449
+ if (date !== void 0 && !(alreadyHere && date === "document-already-held")) {
1450
+ lines.push(`Its date was not kept${because(DATE_NOT_KEPT, date)}`);
1451
+ }
1452
+ const typed = wordsOf(stored.transcription);
1453
+ if (typed !== void 0 && typed !== "used" && !(alreadyHere && typed === "not_used_document_already_held")) {
1454
+ lines.push(`The text typed out of it was not used${because(TYPING_NOT_USED, typed)}`);
1455
+ }
1456
+ return {
1457
+ notKept: lines.map(asPrinted),
1458
+ ...filing === void 0 ? {} : { description: filing }
1004
1459
  };
1005
1460
  }
1006
1461
  async function sendOneSafely(input) {
@@ -1017,6 +1472,7 @@ async function openOne(path) {
1017
1472
  return err?.code === "ENOENT" ? "missing" : "cannot-open";
1018
1473
  }
1019
1474
  }
1475
+ var LEAVES_A_FILE_OUT = (skip) => skip.reason !== "already-here" && skip.reason !== "the-descriptions-file";
1020
1476
  var REASON_ORDER = [
1021
1477
  "type-not-readable",
1022
1478
  "too-large",
@@ -1024,8 +1480,13 @@ var REASON_ORDER = [
1024
1480
  "refused",
1025
1481
  "cannot-open",
1026
1482
  "did-not-finish",
1027
- "leads-out"
1483
+ "leads-out",
1484
+ "entry-wrong-shape",
1485
+ "no-description",
1486
+ "the-descriptions-file"
1028
1487
  ];
1488
+ var NAMED_ALWAYS = /* @__PURE__ */ new Set(["entry-wrong-shape"]);
1489
+ var namedWithoutWhy = (skip) => NAMED_ALWAYS.has(skip.reason) || skip.named === true;
1029
1490
  var FROM_THE_SERVICE = {
1030
1491
  type_not_readable: "type-not-readable",
1031
1492
  too_large: "too-large"
@@ -1037,7 +1498,10 @@ var COUNTED = {
1037
1498
  refused: (many) => `${many} refused`,
1038
1499
  "cannot-open": (many) => `${many} I couldn't open`,
1039
1500
  "did-not-finish": (many) => `${many} that didn't finish`,
1040
- "leads-out": (many) => `${many} shortcut${many === 1 ? "" : "s"} out of the folder`
1501
+ "leads-out": (many) => `${many} shortcut${many === 1 ? "" : "s"} out of the folder`,
1502
+ "entry-wrong-shape": (many) => `${many} with an entry in the wrong shape`,
1503
+ "no-description": (many) => `${many} with no description`,
1504
+ "the-descriptions-file": (many) => `${many} that ${many === 1 ? "is" : "are"} the descriptions file`
1041
1505
  };
1042
1506
  var IN_DETAIL = {
1043
1507
  "type-not-readable": "a file type I can't read",
@@ -1046,7 +1510,10 @@ var IN_DETAIL = {
1046
1510
  refused: "refused",
1047
1511
  "cannot-open": "I couldn't open it",
1048
1512
  "did-not-finish": "the send didn't finish",
1049
- "leads-out": "a shortcut out of the folder"
1513
+ "leads-out": "a shortcut out of the folder",
1514
+ "entry-wrong-shape": "not sent: its entry is in the wrong shape.",
1515
+ "no-description": "not sent: it has no description.",
1516
+ "the-descriptions-file": "the descriptions file itself, never sent"
1050
1517
  };
1051
1518
  function countLine(added, space, skipped, why) {
1052
1519
  const documents = `${added} document${added === 1 ? "" : "s"} in ${named(space)}.`;
@@ -1055,11 +1522,13 @@ function countLine(added, space, skipped, why) {
1055
1522
  const many = skipped.filter((skip) => skip.reason === reason).length;
1056
1523
  return many === 0 ? [] : [COUNTED[reason](many)];
1057
1524
  }).join(", ");
1058
- return `${documents} ${skipped.length} skipped \u2014 ${reasons}.${why ? "" : " Run with --why to see each."}`;
1525
+ const pointAtWhy = !why && skipped.some((skip) => !namedWithoutWhy(skip) && skip.reason !== "the-descriptions-file");
1526
+ return `${documents} ${skipped.length} skipped \u2014 ${reasons}.${pointAtWhy ? " Run with --why to see each." : ""}`;
1059
1527
  }
1060
1528
  function whyLine(skip) {
1061
- const because = skip.said === void 0 ? IN_DETAIL[skip.reason] : skip.reason === "refused" ? `refused: ${skip.said}` : skip.said;
1062
- return `${asPrinted(skip.relative)} \u2014 ${because}`;
1529
+ if (skip.line !== void 0) return skip.line;
1530
+ const because2 = skip.said === void 0 ? IN_DETAIL[skip.reason] : skip.reason === "refused" ? `refused: ${skip.said}` : skip.said;
1531
+ return `${asPrinted(skip.relative)} \u2014 ${because2}`;
1063
1532
  }
1064
1533
  var FROM_THE_WALK = {
1065
1534
  "leads-out": "leads-out",
@@ -1068,90 +1537,146 @@ var FROM_THE_WALK = {
1068
1537
  async function runAdd(options, deps = {}) {
1069
1538
  const voice = voiceFor(deps.write ?? ((line) => process.stdout.write(`${line}
1070
1539
  `)));
1071
- const name = asPrinted(basename(options.file));
1540
+ const name2 = asPrinted(basename(options.file));
1072
1541
  let isFolder;
1073
1542
  try {
1074
1543
  isFolder = (await statPath(options.file)).isDirectory();
1075
1544
  } catch (err) {
1076
1545
  voice.write(
1077
- err?.code === "ENOENT" ? cannotFind(name) : cannotOpen(name)
1546
+ err?.code === "ENOENT" ? cannotFind(name2) : cannotOpen(name2)
1078
1547
  );
1079
1548
  return { ok: false };
1080
1549
  }
1081
- return isFolder ? await addFolder(options, deps, voice) : await addOneFile(options, deps, voice);
1550
+ let descriptions;
1551
+ if (options.describedIn !== void 0) {
1552
+ const read = await readDescriptions(options.describedIn);
1553
+ if ("said" in read) {
1554
+ voice.write(read.said);
1555
+ return { ok: false };
1556
+ }
1557
+ descriptions = read;
1558
+ }
1559
+ return isFolder ? await addFolder(options, deps, voice, descriptions) : await addOneFile(options, deps, voice, descriptions);
1082
1560
  }
1083
- async function addOneFile(options, deps, voice) {
1084
- const name = asPrinted(basename(options.file));
1561
+ async function addOneFile(options, deps, voice, descriptions) {
1562
+ const name2 = asPrinted(basename(options.file));
1085
1563
  const handle = await openOne(options.file);
1086
1564
  if (typeof handle === "string") {
1087
- voice.write(handle === "missing" ? cannotFind(name) : cannotOpen(name));
1565
+ voice.write(handle === "missing" ? cannotFind(name2) : cannotOpen(name2));
1088
1566
  return { ok: false };
1089
1567
  }
1090
1568
  try {
1091
- const byteSize = (await handle.stat()).size;
1569
+ const info = await handle.stat({ bigint: true });
1570
+ const byteSize = Number(info.size);
1571
+ let described;
1572
+ if (descriptions !== void 0) {
1573
+ if (sameFile({ dev: info.dev, ino: info.ino }, descriptions.identity)) {
1574
+ voice.write(isTheDescriptionsFile(name2));
1575
+ return { ok: false };
1576
+ }
1577
+ const key = asKey(basename(options.file));
1578
+ const found = descriptions.entries.get(key);
1579
+ if (found === void 0 && descriptions.entries.size > 0) {
1580
+ voice.write(noEntryMatches(descriptions.name, basename(options.file)));
1581
+ return { ok: false };
1582
+ }
1583
+ const namingNothing = [...descriptions.entries].filter(([other]) => other !== key).flatMap(([, other]) => other.keys);
1584
+ if (namingNothing.length > 0) voice.write(namingNoFile(namingNothing.sort()));
1585
+ if (found !== void 0) {
1586
+ const checked = checkEntry(found);
1587
+ if ("wrong" in checked) {
1588
+ voice.write(`${name2} \u2014 not sent: ${checked.wrong}`);
1589
+ return { ok: false };
1590
+ }
1591
+ described = checked.described;
1592
+ }
1593
+ }
1092
1594
  const auth2 = deps.auth ?? await resolveAuth(options);
1093
1595
  if (auth2 === null) {
1094
1596
  voice.write(CANNOT_REACH_DAZZER);
1095
1597
  return { ok: false };
1096
1598
  }
1097
- const settled = await settleSpace(options, auth2, deps, voice, name);
1599
+ const settled = await settleSpace(options, auth2, deps, voice, name2);
1098
1600
  if (settled === null) return { ok: false };
1099
1601
  const outcome = await sendOneSafely({
1100
1602
  path: options.file,
1101
1603
  handle,
1102
1604
  byteSize,
1103
1605
  scopeId: settled.scopeId,
1606
+ ...described === void 0 ? {} : { described },
1104
1607
  options,
1105
1608
  deps,
1106
1609
  auth: auth2,
1107
1610
  voice
1108
1611
  });
1109
- switch (outcome.kind) {
1110
- case "added":
1111
- voice.write(`${outcome.fileName} is in Dazzer, in ${named(outcome.space)}.`);
1112
- return { ok: true };
1113
- case "already-here":
1114
- voice.write(ALREADY_IN);
1115
- return { ok: true };
1116
- // ONE FILE, ONE QUESTION, IN THE SERVICE'S OWN WORDS. It is the only thing that knows what it
1117
- // already holds and what the space is called, and rewriting its question here would be a
1118
- // second wording of a question somebody approved. Nothing was added and nobody has answered,
1119
- // so this run did not do what it was asked to — the same answer a run that asked where things
1120
- // go gives.
1121
- case "held":
1122
- voice.write(outcome.question);
1123
- return { ok: false };
1124
- // ONE FILE, ONE REFUSAL, AND THE PLACES IT OFFERED. The sentence is the service's and is
1125
- // printed as sent; the places under it are the service's too, and without them a refusal
1126
- // that says to name one of them is a dead end for the person holding this one file.
1127
- case "refused":
1128
- voice.write(outcome.said ?? wentWrong(name));
1129
- offerThePlaces(voice.write, outcome);
1130
- return { ok: false };
1131
- case "did-not-finish":
1132
- voice.write(didNotFinish(name));
1133
- return { ok: false };
1134
- case "not-reached":
1135
- voice.write(CANNOT_REACH_DAZZER);
1136
- return { ok: false };
1137
- case "went-wrong":
1138
- voice.write(wentWrong(name));
1139
- return { ok: false };
1140
- // IT OPENED AND WOULD NOT READ — answered in the approved sentence for a file that cannot be
1141
- // opened, because that is the same thing a person can act on, and never as a crash.
1142
- case "cannot-read":
1143
- voice.write(cannotOpen(name));
1144
- return { ok: false };
1145
- case "stop":
1146
- say(voice.write, outcome.answer, name);
1147
- return { ok: false };
1612
+ const result = answerForOneFile(outcome, name2, voice);
1613
+ if (outcome.kind === "added" && descriptions !== void 0 && !("description" in (described ?? {}))) {
1614
+ voice.write(itHadNoDescription);
1148
1615
  }
1616
+ if (outcome.kind === "already-here" && outcome.description !== void 0) {
1617
+ voice.write(
1618
+ outcome.description === "filed" ? itsDescriptionFiledNow : outcome.description === "already-described" ? itAlreadyHasOne : itsDescriptionNotKept
1619
+ );
1620
+ }
1621
+ if (outcome.kind === "added" || outcome.kind === "already-here") {
1622
+ for (const line of outcome.notKept) voice.write(`${name2}: ${line}`);
1623
+ }
1624
+ return result;
1149
1625
  } finally {
1150
1626
  await handle.close();
1151
1627
  }
1152
1628
  }
1153
- async function addFolder(options, deps, voice) {
1154
- const name = asPrinted(basename(options.file));
1629
+ function answerForOneFile(outcome, name2, voice) {
1630
+ switch (outcome.kind) {
1631
+ case "added":
1632
+ voice.write(`${outcome.fileName} is in Dazzer, in ${named(outcome.space)}.`);
1633
+ return { ok: true };
1634
+ case "already-here":
1635
+ voice.write(ALREADY_IN);
1636
+ return { ok: true };
1637
+ // ONE FILE, ONE QUESTION, IN THE SERVICE'S OWN WORDS. It is the only thing that knows what it
1638
+ // already holds and what the space is called, and rewriting its question here would be a
1639
+ // second wording of a question somebody approved. Nothing was added and nobody has answered,
1640
+ // so this run did not do what it was asked to — the same answer a run that asked where things
1641
+ // go gives.
1642
+ case "held":
1643
+ voice.write(outcome.question);
1644
+ return { ok: false };
1645
+ // ONE FILE, ONE REFUSAL, AND THE PLACES IT OFFERED. The sentence is the service's and is
1646
+ // printed as sent; the places under it are the service's too, and without them a refusal
1647
+ // that says to name one of them is a dead end for the person holding this one file. A refusal
1648
+ // whose sentence is written for whoever makes the call is said in this command's own words.
1649
+ case "refused": {
1650
+ const because2 = notAddedBecause(outcome);
1651
+ voice.write(
1652
+ because2 === void 0 ? outcome.said ?? wentWrong(name2) : notAdded(name2, because2)
1653
+ );
1654
+ const next = whatToDoAbout(outcome);
1655
+ if (next !== void 0) voice.write(next);
1656
+ offerThePlaces(voice.write, outcome);
1657
+ return { ok: false };
1658
+ }
1659
+ case "did-not-finish":
1660
+ voice.write(didNotFinish(name2));
1661
+ return { ok: false };
1662
+ case "not-reached":
1663
+ voice.write(CANNOT_REACH_DAZZER);
1664
+ return { ok: false };
1665
+ case "went-wrong":
1666
+ voice.write(wentWrong(name2));
1667
+ return { ok: false };
1668
+ // IT OPENED AND WOULD NOT READ — answered in the approved sentence for a file that cannot be
1669
+ // opened, because that is the same thing a person can act on, and never as a crash.
1670
+ case "cannot-read":
1671
+ voice.write(cannotOpen(name2));
1672
+ return { ok: false };
1673
+ case "stop":
1674
+ say(voice.write, outcome.answer, name2);
1675
+ return { ok: false };
1676
+ }
1677
+ }
1678
+ async function addFolder(options, deps, voice, descriptions) {
1679
+ const name2 = asPrinted(basename(options.file));
1155
1680
  const walked = await (async () => {
1156
1681
  try {
1157
1682
  return await walkFolder(options.file);
@@ -1160,7 +1685,30 @@ async function addFolder(options, deps, voice) {
1160
1685
  }
1161
1686
  })();
1162
1687
  if (walked === null) {
1163
- voice.write(cannotOpen(name));
1688
+ voice.write(cannotOpen(name2));
1689
+ return { ok: false };
1690
+ }
1691
+ const found = new Set([...walked.files, ...walked.skipped].map((one) => asKey(one.relative)));
1692
+ const entries = [...descriptions?.entries ?? /* @__PURE__ */ new Map()];
1693
+ const namingNothing = entries.filter(([key]) => !found.has(key)).flatMap(([, entry]) => entry.keys).sort();
1694
+ if (descriptions !== void 0 && entries.length > 0 && entries.every(([key]) => !found.has(key))) {
1695
+ voice.write(
1696
+ noEntryMatches(
1697
+ descriptions.name,
1698
+ await aKeyItWouldMatch(walked.files, descriptions.identity)
1699
+ )
1700
+ );
1701
+ return { ok: false };
1702
+ }
1703
+ const worthAsking = walked.files.some((file) => {
1704
+ const entry = descriptions?.entries.get(asKey(file.relative));
1705
+ if (entry === void 0) return false;
1706
+ const checked = checkEntry(entry);
1707
+ return "wrong" in checked || "description" in checked.described;
1708
+ });
1709
+ if (walked.files.length > 0 && !worthAsking) {
1710
+ voice.write(NOTHING_DESCRIBED);
1711
+ voice.write(DESCRIBE_THEM);
1164
1712
  return { ok: false };
1165
1713
  }
1166
1714
  const auth2 = deps.auth ?? await resolveAuth(options);
@@ -1168,7 +1716,7 @@ async function addFolder(options, deps, voice) {
1168
1716
  voice.write(CANNOT_REACH_DAZZER);
1169
1717
  return { ok: false };
1170
1718
  }
1171
- const settled = await settleSpace(options, auth2, deps, voice, name);
1719
+ const settled = await settleSpace(options, auth2, deps, voice, name2);
1172
1720
  if (settled === null) return { ok: false };
1173
1721
  const skipped = walked.skipped.map((skip) => ({
1174
1722
  relative: skip.relative,
@@ -1177,7 +1725,33 @@ async function addFolder(options, deps, voice) {
1177
1725
  const held = [];
1178
1726
  let added = 0;
1179
1727
  let ran = true;
1728
+ const notKept = [];
1729
+ const alreadyThere = { filed: 0, alreadyDescribed: 0, notKept: 0 };
1180
1730
  for (const file of walked.files) {
1731
+ let described;
1732
+ const entry = descriptions?.entries.get(asKey(file.relative));
1733
+ if (entry !== void 0) {
1734
+ const checked = checkEntry(entry);
1735
+ if ("wrong" in checked) {
1736
+ skipped.push({
1737
+ relative: file.relative,
1738
+ reason: "entry-wrong-shape",
1739
+ said: `not sent: ${checked.wrong}`
1740
+ });
1741
+ continue;
1742
+ }
1743
+ described = checked.described;
1744
+ }
1745
+ if (described === void 0 || !("description" in described)) {
1746
+ skipped.push(
1747
+ descriptions !== void 0 && await isFile(file.path, descriptions.identity) ? { relative: file.relative, reason: "the-descriptions-file" } : {
1748
+ relative: file.relative,
1749
+ reason: "no-description",
1750
+ line: notSentNoDescription(asPrinted(file.relative))
1751
+ }
1752
+ );
1753
+ continue;
1754
+ }
1181
1755
  const handle = await openOne(file.path);
1182
1756
  if (typeof handle === "string") {
1183
1757
  skipped.push({ relative: file.relative, reason: "cannot-open" });
@@ -1185,11 +1759,13 @@ async function addFolder(options, deps, voice) {
1185
1759
  }
1186
1760
  let outcome;
1187
1761
  try {
1188
- outcome = await sendOneSafely({
1762
+ const info = await handle.stat({ bigint: true });
1763
+ outcome = descriptions !== void 0 && sameFile({ dev: info.dev, ino: info.ino }, descriptions.identity) ? "the-descriptions-file" : await sendOneSafely({
1189
1764
  path: file.path,
1190
1765
  handle,
1191
- byteSize: (await handle.stat()).size,
1766
+ byteSize: Number(info.size),
1192
1767
  scopeId: settled.scopeId,
1768
+ ...described === void 0 ? {} : { described },
1193
1769
  options,
1194
1770
  deps,
1195
1771
  auth: auth2,
@@ -1198,6 +1774,18 @@ async function addFolder(options, deps, voice) {
1198
1774
  } finally {
1199
1775
  await handle.close();
1200
1776
  }
1777
+ if (outcome === "the-descriptions-file") {
1778
+ skipped.push({ relative: file.relative, reason: "the-descriptions-file" });
1779
+ continue;
1780
+ }
1781
+ if (outcome.kind === "added" || outcome.kind === "already-here") {
1782
+ if (outcome.notKept.length > 0)
1783
+ notKept.push({ relative: file.relative, lines: outcome.notKept });
1784
+ }
1785
+ if (outcome.kind === "already-here" && outcome.description !== void 0) {
1786
+ const answer = outcome.description;
1787
+ alreadyThere[answer === "filed" ? "filed" : answer === "already-described" ? "alreadyDescribed" : "notKept"] += 1;
1788
+ }
1201
1789
  if (outcome.kind === "added") {
1202
1790
  added += 1;
1203
1791
  continue;
@@ -1215,12 +1803,19 @@ async function addFolder(options, deps, voice) {
1215
1803
  continue;
1216
1804
  }
1217
1805
  if (outcome.kind === "refused") {
1806
+ const because2 = notAddedBecause(outcome);
1807
+ const reason = (outcome.code === void 0 ? void 0 : FROM_THE_SERVICE[outcome.code]) ?? "refused";
1218
1808
  skipped.push({
1219
1809
  relative: file.relative,
1220
- // KEYED ON THE SERVICE'S CODE, never on the words it sent: the words are the person's and
1221
- // may be reworded, the code is the label the service publishes.
1222
- reason: (outcome.code === void 0 ? void 0 : FROM_THE_SERVICE[outcome.code]) ?? "refused",
1223
- ...outcome.said === void 0 ? {} : { said: outcome.said }
1810
+ reason,
1811
+ ...outcome.said === void 0 ? {} : { said: outcome.said },
1812
+ ...because2 === void 0 ? {} : { line: notAdded(asPrinted(file.relative), because2) },
1813
+ // REFUSED ALTHOUGH IT CARRIED AN ENTRY, which every file sent now does: most likely about
1814
+ // what the entry says, which only the person's assistant can put right, so it is named
1815
+ // without anybody having to run the whole folder again with `--why`. A refusal about the
1816
+ // file's kind or size is about the file, not its entry, so it stays a count that `--why`
1817
+ // names: a folder with thirty spreadsheets in it is one line, not thirty.
1818
+ ...reason === "refused" ? { named: true } : {}
1224
1819
  });
1225
1820
  continue;
1226
1821
  }
@@ -1233,15 +1828,30 @@ async function addFolder(options, deps, voice) {
1233
1828
  continue;
1234
1829
  }
1235
1830
  if (outcome.kind === "not-reached") voice.write(CANNOT_REACH_DAZZER);
1236
- else say(voice.write, outcome.answer, name);
1831
+ else say(voice.write, outcome.answer, name2);
1237
1832
  ran = false;
1238
1833
  break;
1239
1834
  }
1240
1835
  skipped.sort((a, b) => a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0);
1241
1836
  held.sort((a, b) => a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0);
1837
+ notKept.sort((a, b) => a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0);
1838
+ const undescribed = skipped.filter((skip) => skip.reason === "no-description");
1839
+ if (undescribed.length <= NAMED_WHEN_AT_MOST) {
1840
+ for (const skip of undescribed) skip.named = true;
1841
+ }
1242
1842
  voice.write(countLine(added, settled.space, skipped, options.why === true));
1843
+ if (skipped.some((skip) => skip.reason === "the-descriptions-file")) voice.write(keepItBeside);
1844
+ if (alreadyThere.filed + alreadyThere.alreadyDescribed + alreadyThere.notKept > 0) {
1845
+ voice.write(alreadyThereDescriptions(alreadyThere));
1846
+ }
1847
+ if (namingNothing.length > 0) voice.write(namingNoFile(namingNothing));
1243
1848
  if (held.length > 0) voice.write(waitingOnYou(held.length));
1244
- if (options.why === true) for (const skip of skipped) voice.write(whyLine(skip));
1849
+ for (const skip of skipped) {
1850
+ if (options.why === true || namedWithoutWhy(skip)) voice.write(whyLine(skip));
1851
+ }
1852
+ for (const one of notKept) {
1853
+ for (const line of one.lines) voice.write(`${asPrinted(one.relative)}: ${line}`);
1854
+ }
1245
1855
  const onlyOne = held.length === 1 ? held[0] : void 0;
1246
1856
  if (onlyOne !== void 0) {
1247
1857
  voice.write(onlyOne.question);
@@ -1253,7 +1863,25 @@ async function addFolder(options, deps, voice) {
1253
1863
  )
1254
1864
  );
1255
1865
  }
1256
- return { ok: ran };
1866
+ return { ok: ran && !skipped.some(LEAVES_A_FILE_OUT) };
1867
+ }
1868
+ async function isFile(path, identity) {
1869
+ try {
1870
+ const info = await statPath(path, { bigint: true });
1871
+ return sameFile({ dev: info.dev, ino: info.ino }, identity);
1872
+ } catch {
1873
+ return false;
1874
+ }
1875
+ }
1876
+ async function aKeyItWouldMatch(files, descriptionsFile) {
1877
+ for (const file of files) {
1878
+ try {
1879
+ const info = await statPath(file.path, { bigint: true });
1880
+ if (!sameFile({ dev: info.dev, ino: info.ino }, descriptionsFile)) return file.relative;
1881
+ } catch {
1882
+ }
1883
+ }
1884
+ return void 0;
1257
1885
  }
1258
1886
  function offerThePlaces(write, offer) {
1259
1887
  const places = offer.choices ?? [];
@@ -1262,10 +1890,10 @@ function offerThePlaces(write, offer) {
1262
1890
  for (const place of places) write(choiceLine(place.space, place.scopeId));
1263
1891
  write(answerWith(keepToYourself, places.length > 0));
1264
1892
  }
1265
- function say(write, answer, name) {
1893
+ function say(write, answer, name2) {
1266
1894
  switch (answer.status) {
1267
1895
  case "refused":
1268
- write(answer.detail ?? wentWrong(name));
1896
+ write(answer.detail ?? wentWrong(name2));
1269
1897
  offerThePlaces(write, answer);
1270
1898
  return;
1271
1899
  case "sign-in-invalid":
@@ -1978,23 +2606,40 @@ function signInServerDefaults() {
1978
2606
 
1979
2607
  // src/index.ts
1980
2608
  var require2 = createRequire(import.meta.url);
1981
- var { version } = require2("../package.json");
2609
+ var { name, version } = require2("../package.json");
2610
+ var client = `${name}/${version}`;
1982
2611
  var program = new Command();
1983
2612
  program.name("dazzer").description("Sign in to Dazzer from your terminal").version(version);
1984
- program.command("add").description("Put a file, or a whole folder, into Dazzer").argument("<path>", "The file or folder to add").option("--space <name>", "The space to put it in \u2014 the name you were shown, or its number").option("--keep-to-myself", "Keep it to yourself, rather than putting it in a shared space").option("--why", "Show each document that was skipped, and why").action(async (file, opts) => {
1985
- const env = validateEnv();
1986
- const result = await runAdd({
1987
- file,
1988
- space: opts.space,
1989
- keepToMyself: opts.keepToMyself === true,
1990
- why: opts.why === true,
1991
- baseUrl: env.DAZZER_API_BASE_URL,
1992
- issuer: env.DAZZER_AUTH_ISSUER,
1993
- clientId: env.DAZZER_AUTH_CLIENT_ID,
1994
- resource: env.DAZZER_AUTH_RESOURCE
1995
- });
1996
- if (!result.ok) process.exitCode = 1;
2613
+ var newerTool;
2614
+ program.hook("preAction", () => {
2615
+ newerTool = lookForNewerTool({ name, version, folder: getConfigDir() });
2616
+ });
2617
+ program.hook("postAction", async () => {
2618
+ const said = await newerTool?.finish();
2619
+ if (said) process.stderr.write(`${said}
2620
+ `);
1997
2621
  });
2622
+ program.command("add").description("Put a file, or a whole folder, into Dazzer").argument("<path>", "The file or folder to add").option("--space <name>", "The space to put it in \u2014 the name you were shown, or its number").option("--keep-to-myself", "Keep it to yourself, rather than putting it in a shared space").option("--why", "Show each document that was skipped, and why").option(
2623
+ "--described-in <file>",
2624
+ "A JSON file saying what each file is, keyed by its path inside the folder"
2625
+ ).action(
2626
+ async (file, opts) => {
2627
+ const env = validateEnv();
2628
+ const result = await runAdd({
2629
+ file,
2630
+ space: opts.space,
2631
+ keepToMyself: opts.keepToMyself === true,
2632
+ why: opts.why === true,
2633
+ ...opts.describedIn === void 0 ? {} : { describedIn: opts.describedIn },
2634
+ client,
2635
+ baseUrl: env.DAZZER_API_BASE_URL,
2636
+ issuer: env.DAZZER_AUTH_ISSUER,
2637
+ clientId: env.DAZZER_AUTH_CLIENT_ID,
2638
+ resource: env.DAZZER_AUTH_RESOURCE
2639
+ });
2640
+ if (!result.ok) process.exitCode = 1;
2641
+ }
2642
+ );
1998
2643
  var auth = program.command("auth").description("Manage sign-in");
1999
2644
  auth.command("login").description("Sign in through your browser; falls back to a device code when headless").option("--issuer <url>", "Authorization server URL").option("--client-id <id>", "OAuth client id").option("--resource <url>", "Resource indicator for access tokens").option("--no-browser", "Use the device-code flow instead of opening a browser").action(
2000
2645
  async (opts) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dazzer-connect",
3
3
  "private": false,
4
- "version": "0.2.0",
4
+ "version": "0.4.0",
5
5
  "description": "Sign in to Dazzer \u2014 the memory your AI tools share \u2014 from a terminal, for clients that cannot sign in themselves",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -34,16 +34,16 @@
34
34
  "dev": "tsx src/index.ts"
35
35
  },
36
36
  "dependencies": {
37
- "@napi-rs/keyring": "^2.0.0",
37
+ "@napi-rs/keyring": "^2.1.0",
38
38
  "commander": "^15.0.0",
39
39
  "env-paths": "^4.0.0",
40
- "zod": "^4.6.1"
40
+ "zod": "^4.6.5"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@dazzer/auth-ui": "workspace:*",
44
- "@types/node": "^26.5.1",
44
+ "@types/node": "^26.6.2",
45
45
  "esbuild": "^0.28.2",
46
- "tsx": "^4.23.13",
46
+ "tsx": "^4.23.15",
47
47
  "typescript": "^5.7.2",
48
48
  "vitest": "^4.1.11"
49
49
  },