dazzer-connect 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +20 -6
  2. package/dist/index.js +306 -53
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -69,12 +69,16 @@ Merge either one with any `mcpServers` entries you already have, save, and resta
69
69
 
70
70
  ## Put files into Dazzer
71
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
+
72
74
  ```bash
73
75
  dazzer add report.pdf
74
76
  dazzer add ./backlog
75
77
  ```
76
78
 
77
- 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.
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.
78
82
 
79
83
  ### With what your assistant wrote about each file
80
84
 
@@ -104,7 +108,7 @@ Your own AI assistant can read the files and say what each one is. It writes tha
104
108
  }
105
109
  ```
106
110
 
107
- Save it beside the folder, never inside it — for example as `backlog-descriptions.json` next to `backlog`. Inside the folder, a later `dazzer add ./backlog` without `--described-in` would send it like any other file. Then:
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:
108
112
 
109
113
  ```bash
110
114
  dazzer add ./backlog --described-in ./backlog-descriptions.json
@@ -113,14 +117,24 @@ dazzer add ./backlog --described-in ./backlog-descriptions.json
113
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.
114
118
 
115
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.
116
- - A file with no entry goes in as usual, and the count names it.
117
- - An entry that names no file being added is reported, and nothing is sent for it. If no entry names a file being added, nothing is sent at all.
118
- - A file Dazzer refuses although it had an entry is named, in Dazzer's own words.
119
- - Whatever Dazzer did not keep of an entry is printed file by file, with what to change.
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.
120
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.
121
125
 
122
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.
123
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
+
124
138
  ## Other commands
125
139
 
126
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,9 @@ 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 RENEWED_SIGN_IN_NOT_SAVED = "Your sign-in was renewed, but this machine's credential store would not save it. Run: dazzer auth login";
27
+ var UPDATE_THE_TOOL = "npm install -g dazzer-connect@latest";
28
+ var newerToolIsOut = (version2) => `Version ${version2} of dazzer is out. Update it with: ${UPDATE_THE_TOOL}`;
22
29
  function printDeviceInstructions(deviceAuth) {
23
30
  writeOut("");
24
31
  if (deviceAuth.verification_uri_complete) {
@@ -89,6 +96,9 @@ function printAuthStatus(params) {
89
96
  writeOut(" Common on servers and containers, which often have none.");
90
97
  writeOut(" Run: dazzer auth login \u2014 it prints a pass you can paste in.");
91
98
  return;
99
+ case "save-refused":
100
+ writeOut(RENEWED_SIGN_IN_NOT_SAVED);
101
+ return;
92
102
  case "expired":
93
103
  writeOut("Your sign-in has expired or been revoked. Run: dazzer auth login");
94
104
  return;
@@ -193,6 +203,12 @@ var RefreshRejectedError = class extends Error {
193
203
  }
194
204
  oauthError;
195
205
  };
206
+ var RenewedSignInNotSavedError = class extends Error {
207
+ constructor() {
208
+ super("The credential store would not save the renewed sign-in.");
209
+ this.name = "RenewedSignInNotSavedError";
210
+ }
211
+ };
196
212
  async function refreshAccessToken(options) {
197
213
  const fetchFn = options.fetchFn ?? fetch;
198
214
  const params = new URLSearchParams({
@@ -220,7 +236,11 @@ async function refreshAccessToken(options) {
220
236
  }
221
237
  const newRefresh = typeof refreshToken === "string" ? refreshToken : options.refreshToken;
222
238
  const expiresIn = typeof body.expires_in === "number" ? body.expires_in : null;
223
- await options.tokenStore.save({ accessToken, refreshToken: newRefresh });
239
+ try {
240
+ await options.tokenStore.save({ accessToken, refreshToken: newRefresh });
241
+ } catch {
242
+ throw new RenewedSignInNotSavedError();
243
+ }
224
244
  return { accessToken, refreshToken: newRefresh, expiresIn };
225
245
  }
226
246
  async function getValidAccessToken(options) {
@@ -235,6 +255,9 @@ async function getValidAccessToken(options) {
235
255
  if (err instanceof RefreshRejectedError) {
236
256
  return { status: "rejected" };
237
257
  }
258
+ if (err instanceof RenewedSignInNotSavedError) {
259
+ return { status: "save-refused" };
260
+ }
238
261
  return { status: "unreachable" };
239
262
  }
240
263
  }
@@ -315,6 +338,31 @@ function refusalPlaces(body) {
315
338
  ...typeof keep === "boolean" ? { keepToYourself: keep } : {}
316
339
  };
317
340
  }
341
+ var wordsIn = (value) => typeof value === "string" && value.trim() !== "" ? value : void 0;
342
+ function refusalAbout(body) {
343
+ if (typeof body !== "object" || body === null) return {};
344
+ const flat = body;
345
+ const enveloped = typeof flat.error === "object" && flat.error !== null ? flat.error.details : void 0;
346
+ const fields = [
347
+ ...wordsIn(flat.field) === void 0 ? [] : [flat.field],
348
+ ...Array.isArray(enveloped) ? enveloped.flatMap((detail) => {
349
+ const named2 = typeof detail === "object" && detail !== null ? wordsIn(detail.field) : void 0;
350
+ return named2 === void 0 ? [] : [named2];
351
+ }) : []
352
+ ];
353
+ const dropped = Array.isArray(flat.dropped) ? flat.dropped.flatMap((entry) => {
354
+ if (typeof entry !== "object" || entry === null) return [];
355
+ const line = wordsIn(entry.line);
356
+ const reason = wordsIn(entry.reason);
357
+ return line === void 0 || reason === void 0 ? [] : [{ line, reason }];
358
+ }) : [];
359
+ const howMuch = typeof flat.how_much_was_read === "object" && flat.how_much_was_read !== null ? wordsIn(flat.how_much_was_read.reason) : void 0;
360
+ return {
361
+ ...fields.length > 0 ? { fields } : {},
362
+ ...dropped.length > 0 ? { dropped } : {},
363
+ ...howMuch === void 0 ? {} : { howMuchWasRead: howMuch }
364
+ };
365
+ }
318
366
  async function resolveCredential(auth2) {
319
367
  let held;
320
368
  try {
@@ -334,6 +382,8 @@ async function resolveCredential(auth2) {
334
382
  return { status: "signed-out" };
335
383
  case "rejected":
336
384
  return { status: "sign-in-invalid" };
385
+ case "save-refused":
386
+ return { status: "save-refused" };
337
387
  case "unreachable":
338
388
  return { held: held.accessToken };
339
389
  case "refreshed": {
@@ -353,6 +403,9 @@ async function callDazzer(request) {
353
403
  if (credential.status === "sign-in-invalid") {
354
404
  return { status: "sign-in-invalid", message: SIGN_IN_NO_LONGER_VALID };
355
405
  }
406
+ if (credential.status === "save-refused") {
407
+ return { status: "sign-in-invalid", message: RENEWED_SIGN_IN_NOT_SAVED };
408
+ }
356
409
  return { status: credential.status };
357
410
  }
358
411
  const fetchFn = request.fetchFn ?? fetch;
@@ -384,15 +437,99 @@ async function callDazzer(request) {
384
437
  httpStatus: res.status,
385
438
  ...detail === void 0 ? {} : { detail },
386
439
  ...code === void 0 ? {} : { code },
387
- ...refusalPlaces(body)
440
+ ...refusalPlaces(body),
441
+ ...refusalAbout(body)
388
442
  };
389
443
  }
390
444
  return { status: "ok", httpStatus: res.status, body };
391
445
  }
446
+ var REGISTRY = "https://registry.npmjs.org";
447
+ var LOOK_AT_MOST_MS = 3e3;
448
+ var A_DAY_MS = 24 * 60 * 60 * 1e3;
449
+ var NEWER_TOOL_FILE = "newer-version.json";
450
+ function releaseOf(version2) {
451
+ if (typeof version2 !== "string") return null;
452
+ const said = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})$/.exec(version2);
453
+ return said === null ? null : [Number(said[1]), Number(said[2]), Number(said[3])];
454
+ }
455
+ function isLater(published, mine) {
456
+ const theirs = releaseOf(published);
457
+ const own = /^(\d{1,6}\.\d{1,6}\.\d{1,6})(-[^+]+)?(\+.+)?$/.exec(mine);
458
+ const ours = own === null ? null : releaseOf(own[1]);
459
+ if (theirs === null || own === null || ours === null) return false;
460
+ for (let at = 0; at < 3; at += 1) {
461
+ if (theirs[at] !== ours[at]) return (theirs[at] ?? 0) > (ours[at] ?? 0);
462
+ }
463
+ return own[2] !== void 0;
464
+ }
465
+ async function recall(file) {
466
+ try {
467
+ const parsed = JSON.parse(await readFile(file, "utf8"));
468
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
469
+ } catch {
470
+ return {};
471
+ }
472
+ }
473
+ async function remember(folder, file, what) {
474
+ try {
475
+ await mkdir(folder, { recursive: true });
476
+ await writeFile(file, `${JSON.stringify(what)}
477
+ `, "utf8");
478
+ return true;
479
+ } catch {
480
+ return false;
481
+ }
482
+ }
483
+ var withinADay = (then, now) => typeof then === "number" && Number.isFinite(then) && then <= now && now - then < A_DAY_MS;
484
+ function lookForNewerTool(check) {
485
+ const now = check.now ?? Date.now;
486
+ const file = join(check.folder, NEWER_TOOL_FILE);
487
+ const stop = new AbortController();
488
+ const looked = (async () => {
489
+ try {
490
+ const before = await recall(file);
491
+ if (withinADay(before.looked_at, now()) || stop.signal.aborted) return;
492
+ const signal = AbortSignal.any([
493
+ stop.signal,
494
+ AbortSignal.timeout(check.lookAtMostMs ?? LOOK_AT_MOST_MS)
495
+ ]);
496
+ const res = await (check.fetchFn ?? fetch)(
497
+ `${REGISTRY}/${encodeURIComponent(check.name)}/latest`,
498
+ { headers: { accept: "application/json" }, signal }
499
+ );
500
+ const published = res.ok ? (await res.json()).version : void 0;
501
+ if (stop.signal.aborted) return;
502
+ const latest = releaseOf(published) === null ? void 0 : published;
503
+ await remember(check.folder, file, {
504
+ ...await recall(file),
505
+ looked_at: now(),
506
+ ...latest === void 0 ? {} : { latest }
507
+ });
508
+ } catch {
509
+ }
510
+ })();
511
+ return {
512
+ looked,
513
+ finish: async () => {
514
+ stop.abort();
515
+ try {
516
+ await looked;
517
+ const known = await recall(file);
518
+ const latest = known.latest;
519
+ if (typeof latest !== "string" || !isLater(latest, check.version)) return null;
520
+ if (withinADay(known.told_at, now())) return null;
521
+ const kept = await remember(check.folder, file, { ...known, told_at: now() });
522
+ return kept ? newerToolIsOut(latest) : null;
523
+ } catch {
524
+ return null;
525
+ }
526
+ }
527
+ };
528
+ }
392
529
 
393
530
  // src/lib/config-store.ts
394
- import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
395
- import { join } from "node:path";
531
+ import { mkdir as mkdir2, readFile as readFile2, unlink, writeFile as writeFile2 } from "node:fs/promises";
532
+ import { join as join2 } from "node:path";
396
533
  import envPaths from "env-paths";
397
534
 
398
535
  // src/lib/discovery.ts
@@ -456,9 +593,9 @@ async function discoverEndpoints(issuer, fetchFn = fetch) {
456
593
 
457
594
  // src/lib/config-store.ts
458
595
  var paths = envPaths("dazzer", { suffix: "" });
459
- var CONFIG_FILE = join(paths.config, "auth.json");
596
+ var CONFIG_FILE = join2(paths.config, "auth.json");
460
597
  async function ensureConfigDir() {
461
- await mkdir(paths.config, { recursive: true });
598
+ await mkdir2(paths.config, { recursive: true });
462
599
  }
463
600
  var MAX_CLIENT_ID_LENGTH = 512;
464
601
  async function saveAuthMetadata(meta) {
@@ -471,12 +608,12 @@ async function saveAuthMetadata(meta) {
471
608
  const savedToStore = typeof meta.savedToStore === "boolean" ? meta.savedToStore : void 0;
472
609
  await ensureConfigDir();
473
610
  const checked = { issuer, clientId, expiresAt, savedToStore };
474
- await writeFile(CONFIG_FILE, `${JSON.stringify(checked, null, 2)}
611
+ await writeFile2(CONFIG_FILE, `${JSON.stringify(checked, null, 2)}
475
612
  `, "utf8");
476
613
  }
477
614
  async function loadAuthMetadata() {
478
615
  try {
479
- const raw = await readFile(CONFIG_FILE, "utf8");
616
+ const raw = await readFile2(CONFIG_FILE, "utf8");
480
617
  const parsed = JSON.parse(raw);
481
618
  if (typeof parsed.issuer !== "string" || typeof parsed.clientId !== "string") {
482
619
  return null;
@@ -605,9 +742,17 @@ var KeychainTokenStore = class {
605
742
  this.openEntry = openEntry;
606
743
  }
607
744
  openEntry;
745
+ /**
746
+ * THE REFRESH TOKEN IS WRITTEN FIRST, AND IF IT WILL NOT BE, NOTHING ELSE IS TOUCHED. After a renewal
747
+ * the saved refresh token is already spent at the sign-in server. Writing the access token first and
748
+ * then failing on the refresh token left a new access token beside that spent one, which read as a
749
+ * fresh sign-in until it failed to renew. In this order a refused first write changes nothing, and
750
+ * a refused second write leaves the new refresh token, which can still renew. Either way the
751
+ * refusal reaches the caller, which says so rather than calling it anything else.
752
+ */
608
753
  async save(tokens) {
609
- this.openEntry(ACCESS_ACCOUNT).setPassword(tokens.accessToken);
610
754
  this.openEntry(REFRESH_ACCOUNT).setPassword(tokens.refreshToken);
755
+ this.openEntry(ACCESS_ACCOUNT).setPassword(tokens.accessToken);
611
756
  }
612
757
  async load() {
613
758
  const accessToken = this.openEntry(ACCESS_ACCOUNT).getPassword();
@@ -642,7 +787,7 @@ function createTokenStore() {
642
787
 
643
788
  // src/lib/walk.ts
644
789
  import { readdir, realpath, stat } from "node:fs/promises";
645
- import { join as join2, sep } from "node:path";
790
+ import { join as join3, sep } from "node:path";
646
791
  function inside(base, candidate) {
647
792
  return candidate === base || candidate.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
648
793
  }
@@ -667,7 +812,7 @@ async function walkFolder(root) {
667
812
  entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
668
813
  for (const entry of entries) {
669
814
  if (entry.name.startsWith(".")) continue;
670
- const full = join2(dir, entry.name);
815
+ const full = join3(dir, entry.name);
671
816
  const relative = `${prefix}${entry.name}`;
672
817
  if (entry.isSymbolicLink()) {
673
818
  await followShortcut(full, relative);
@@ -774,6 +919,10 @@ function namingNoFile(keys) {
774
919
  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))}.`);
775
920
  var keepItBeside = "The descriptions file is inside the folder. Keep it beside the folder instead, so no later run sends it as a document.";
776
921
  var itHadNoDescription = "It had no description.";
922
+ var NOTHING_DESCRIBED = "Nothing was sent. No file here has a description.";
923
+ var DESCRIBE_THEM = "Write what each file is in a descriptions file. Then run this again with --described-in.";
924
+ var notSentNoDescription = (name2) => `${name2} was not sent. It has no description.`;
925
+ var notAdded = (name2, because2) => [`${name2} was not added.`, ...because2].join(" ");
777
926
  function alreadyThereDescriptions(counted) {
778
927
  const parts = [
779
928
  ...counted.filed > 0 ? [`${counted.filed} took the description sent now`] : [],
@@ -933,6 +1082,7 @@ async function settleSpace(options, auth2, deps, voice, name2) {
933
1082
  var HELD_FOR_AN_ANSWER = "same_name_different_contents";
934
1083
  function fromAnswer(answer) {
935
1084
  if (answer.status === "refused") {
1085
+ if (aboutThisTool(answer)) return { kind: "stop", answer };
936
1086
  if (answer.code === HELD_FOR_AN_ANSWER && answer.detail !== void 0) {
937
1087
  return { kind: "held", question: answer.detail };
938
1088
  }
@@ -943,11 +1093,17 @@ function fromAnswer(answer) {
943
1093
  // Carried, not filled in: absent stays absent, so nothing downstream can print an offer the
944
1094
  // service did not make.
945
1095
  ...answer.choices === void 0 ? {} : { choices: answer.choices },
946
- ...answer.keepToYourself === void 0 ? {} : { keepToYourself: answer.keepToYourself }
1096
+ ...answer.keepToYourself === void 0 ? {} : { keepToYourself: answer.keepToYourself },
1097
+ ...answer.fields === void 0 ? {} : { fields: answer.fields },
1098
+ ...answer.dropped === void 0 ? {} : { dropped: answer.dropped },
1099
+ ...answer.howMuchWasRead === void 0 ? {} : { howMuchWasRead: answer.howMuchWasRead }
947
1100
  };
948
1101
  }
949
1102
  return { kind: "stop", answer };
950
1103
  }
1104
+ function aboutThisTool(answer) {
1105
+ return answer.status === "refused" && answer.fields?.includes(CLIENT_HEADER) === true;
1106
+ }
951
1107
  async function sendOne(input) {
952
1108
  const { deps, handle, options, voice } = input;
953
1109
  const described = await callPaced(
@@ -1208,8 +1364,22 @@ var LINE_DROPPED = {
1208
1364
  "line-verbatim-from-document": "its words are copied out of the document",
1209
1365
  "quote-empty": "it quotes nothing",
1210
1366
  "quote-too-long": "its quote is too long",
1367
+ "unstorable-characters": "it holds a character that cannot be stored",
1211
1368
  "quote-not-in-document": "the words it quotes are not in the document"
1212
1369
  };
1370
+ var A_LINE = {
1371
+ what_it_is: "The line saying what it is",
1372
+ what_it_covers: "The line saying what it covers",
1373
+ what_it_decides: "The line saying what it decides"
1374
+ };
1375
+ var HOW_MUCH_NOT_KEPT = {
1376
+ "not-said": "Its description does not say how much of the file was read.",
1377
+ "which-part-empty": "Its description says only part of the file was read, and not which part.",
1378
+ "which-part-unstorable-characters": "The part it says was read holds a character that cannot be stored.",
1379
+ "which-part-too-long": "The part it says was read is too long. Say it in one short sentence.",
1380
+ "which-part-line-break-or-marker": "The part it says was read is not one plain line."
1381
+ };
1382
+ var HOW_MUCH_NOT_KEPT_OTHERWISE = "Its description does not say how much of the file was read in a form Dazzer keeps.";
1213
1383
  var FACT_DROPPED = {
1214
1384
  "fact-empty": "it has no words",
1215
1385
  "fact-too-long": "it is too long",
@@ -1231,8 +1401,44 @@ var TYPING_NOT_USED = {
1231
1401
  not_used_file_has_its_own_text: "the file has words of its own",
1232
1402
  not_used_document_already_held: "the document was already here"
1233
1403
  };
1234
- var inPlainWords = (words, name2) => words[name2] ?? name2;
1235
- var SHORT_EXAMPLE = 120;
1404
+ var because = (words, name2) => {
1405
+ const said = words[name2];
1406
+ return said === void 0 ? ` (${name2}).` : `, because ${said}.`;
1407
+ };
1408
+ var lineNotKept = (dropped) => `${A_LINE[dropped.line] ?? "A line of its description"} was not kept${because(LINE_DROPPED, dropped.reason)}`;
1409
+ var howMuchNotKept = (reason) => HOW_MUCH_NOT_KEPT[reason] ?? HOW_MUCH_NOT_KEPT_OTHERWISE;
1410
+ function whatToDoAbout(refused) {
1411
+ return refused.code === "description_missing" ? DESCRIBE_THEM : void 0;
1412
+ }
1413
+ var ADD_THE_TEXT = "Add the text you read in it to its entry.";
1414
+ function notAddedBecause(refused) {
1415
+ const dropped = (refused.dropped ?? []).map(lineNotKept);
1416
+ const howMuch = refused.howMuchWasRead === void 0 ? [] : [howMuchNotKept(refused.howMuchWasRead)];
1417
+ switch (refused.code) {
1418
+ case "description_missing":
1419
+ return ["It has no description."];
1420
+ case "description_not_kept": {
1421
+ const noLineKept = refused.fields?.includes("description") === true;
1422
+ const said = [...noLineKept ? ["No line of its description was kept."] : [], ...dropped];
1423
+ const all = [...said, ...howMuch];
1424
+ return all.length > 0 ? all : ["Its description was not kept."];
1425
+ }
1426
+ case "nothing_to_check_it_against":
1427
+ return [
1428
+ "Dazzer holds no words of it to check its description against.",
1429
+ ADD_THE_TEXT,
1430
+ ...howMuch
1431
+ ];
1432
+ case "transcription_too_long":
1433
+ return ["The text typed out of it is longer than Dazzer keeps for one file."];
1434
+ case "try_again_shortly":
1435
+ return ["It could not be read just now. Try again shortly."];
1436
+ case "could_not_read_it":
1437
+ return ["Dazzer could not read its words.", ADD_THE_TEXT];
1438
+ default:
1439
+ return void 0;
1440
+ }
1441
+ }
1236
1442
  function whatBecameOfIt(stored, alreadyHere, descriptionSent) {
1237
1443
  const lines = [];
1238
1444
  let filing;
@@ -1240,34 +1446,25 @@ function whatBecameOfIt(stored, alreadyHere, descriptionSent) {
1240
1446
  if (description !== null) {
1241
1447
  const state = wordsOf(description.state);
1242
1448
  const what = wordsOf(description.what);
1243
- const how = wordsOf(description.how);
1244
1449
  if (descriptionSent && state !== void 0) {
1245
1450
  filing = state === "filed" ? "filed" : alreadyHere && state === "not_filed" && what === void 0 ? "already-described" : "not-kept";
1246
1451
  }
1247
1452
  if (state === "not_filed" && what !== void 0) {
1248
- lines.push(how === void 0 ? what : `${what} ${how}`);
1453
+ lines.push("Its description was not kept.");
1249
1454
  } else if (state !== void 0 && state !== "filed" && state !== "none_supplied" && !(alreadyHere && state === "not_filed")) {
1250
1455
  lines.push(
1251
- state === "not_filed" ? "description not filed." : `description not filed: ${inPlainWords(DESCRIPTION_NOT_FILED, state)}`
1456
+ state === "not_filed" ? "Its description was not filed." : `Its description was not filed${because(DESCRIPTION_NOT_FILED, state)}`
1252
1457
  );
1253
1458
  }
1254
1459
  for (const dropped of listOf(description.dropped)) {
1255
1460
  if (!isPlainObject(dropped)) continue;
1256
1461
  const line = wordsOf(dropped.line);
1257
1462
  const reason = wordsOf(dropped.reason);
1258
- const what2 = wordsOf(dropped.what);
1259
- const how2 = wordsOf(dropped.how);
1260
- const why = what2 === void 0 ? reason === void 0 ? void 0 : inPlainWords(LINE_DROPPED, reason) : how2 === void 0 ? what2 : `${what2} ${how2}`;
1261
- if (line !== void 0 && why !== void 0) lines.push(`${line} dropped: ${why}`);
1463
+ if (line !== void 0 && reason !== void 0) lines.push(lineNotKept({ line, reason }));
1262
1464
  }
1263
1465
  const howMuch = isPlainObject(description.how_much_was_read) ? description.how_much_was_read : null;
1264
- const howMuchWhat = howMuch === null ? void 0 : wordsOf(howMuch.what);
1265
- if (howMuch !== null && howMuchWhat !== void 0) {
1266
- const howMuchHow = wordsOf(howMuch.how);
1267
- const example = howMuch.example === void 0 ? "" : JSON.stringify(howMuch.example);
1268
- lines.push(
1269
- `how_much_was_read not kept: ${howMuchWhat}` + (howMuchHow === void 0 ? "" : ` ${howMuchHow}`) + (example !== "" && example.length <= SHORT_EXAMPLE ? ` Example: ${example}` : "")
1270
- );
1466
+ if (howMuch !== null && wordsOf(howMuch.what) !== void 0) {
1467
+ lines.push(howMuchNotKept(wordsOf(howMuch.reason) ?? ""));
1271
1468
  }
1272
1469
  }
1273
1470
  const facts = isPlainObject(stored.facts) ? stored.facts : null;
@@ -1275,16 +1472,16 @@ function whatBecameOfIt(stored, alreadyHere, descriptionSent) {
1275
1472
  if (!isPlainObject(dropped)) continue;
1276
1473
  const reason = wordsOf(dropped.reason);
1277
1474
  if (typeof dropped.fact === "number" && reason !== void 0) {
1278
- lines.push(`fact ${dropped.fact} dropped: ${inPlainWords(FACT_DROPPED, reason)}`);
1475
+ lines.push(`Fact ${dropped.fact} was not kept${because(FACT_DROPPED, reason)}`);
1279
1476
  }
1280
1477
  }
1281
1478
  const date = isPlainObject(stored.date_not_kept) ? wordsOf(stored.date_not_kept.reason) : void 0;
1282
1479
  if (date !== void 0 && !(alreadyHere && date === "document-already-held")) {
1283
- lines.push(`document_date not kept: ${inPlainWords(DATE_NOT_KEPT, date)}`);
1480
+ lines.push(`Its date was not kept${because(DATE_NOT_KEPT, date)}`);
1284
1481
  }
1285
1482
  const typed = wordsOf(stored.transcription);
1286
1483
  if (typed !== void 0 && typed !== "used" && !(alreadyHere && typed === "not_used_document_already_held")) {
1287
- lines.push(`transcription not used: ${inPlainWords(TYPING_NOT_USED, typed)}`);
1484
+ lines.push(`The text typed out of it was not used${because(TYPING_NOT_USED, typed)}`);
1288
1485
  }
1289
1486
  return {
1290
1487
  notKept: lines.map(asPrinted),
@@ -1305,6 +1502,7 @@ async function openOne(path) {
1305
1502
  return err?.code === "ENOENT" ? "missing" : "cannot-open";
1306
1503
  }
1307
1504
  }
1505
+ var LEAVES_A_FILE_OUT = (skip) => skip.reason !== "already-here" && skip.reason !== "the-descriptions-file";
1308
1506
  var REASON_ORDER = [
1309
1507
  "type-not-readable",
1310
1508
  "too-large",
@@ -1314,6 +1512,7 @@ var REASON_ORDER = [
1314
1512
  "did-not-finish",
1315
1513
  "leads-out",
1316
1514
  "entry-wrong-shape",
1515
+ "no-description",
1317
1516
  "the-descriptions-file"
1318
1517
  ];
1319
1518
  var NAMED_ALWAYS = /* @__PURE__ */ new Set(["entry-wrong-shape"]);
@@ -1331,6 +1530,7 @@ var COUNTED = {
1331
1530
  "did-not-finish": (many) => `${many} that didn't finish`,
1332
1531
  "leads-out": (many) => `${many} shortcut${many === 1 ? "" : "s"} out of the folder`,
1333
1532
  "entry-wrong-shape": (many) => `${many} with an entry in the wrong shape`,
1533
+ "no-description": (many) => `${many} with no description`,
1334
1534
  "the-descriptions-file": (many) => `${many} that ${many === 1 ? "is" : "are"} the descriptions file`
1335
1535
  };
1336
1536
  var IN_DETAIL = {
@@ -1342,10 +1542,11 @@ var IN_DETAIL = {
1342
1542
  "did-not-finish": "the send didn't finish",
1343
1543
  "leads-out": "a shortcut out of the folder",
1344
1544
  "entry-wrong-shape": "not sent: its entry is in the wrong shape.",
1545
+ "no-description": "not sent: it has no description.",
1345
1546
  "the-descriptions-file": "the descriptions file itself, never sent"
1346
1547
  };
1347
- function countLine(added, space, skipped, why, undescribed = []) {
1348
- const documents = `${added} document${added === 1 ? "" : "s"} in ${named(space)}.` + (undescribed.length === 0 ? "" : ` ${undescribed.length} had no description${undescribed.length <= NAMED_WHEN_AT_MOST ? ` \u2014 ${listed(undescribed.map(asPrinted))}` : ""}.`);
1548
+ function countLine(added, space, skipped, why) {
1549
+ const documents = `${added} document${added === 1 ? "" : "s"} in ${named(space)}.`;
1349
1550
  if (skipped.length === 0) return documents;
1350
1551
  const reasons = REASON_ORDER.flatMap((reason) => {
1351
1552
  const many = skipped.filter((skip) => skip.reason === reason).length;
@@ -1355,8 +1556,9 @@ function countLine(added, space, skipped, why, undescribed = []) {
1355
1556
  return `${documents} ${skipped.length} skipped \u2014 ${reasons}.${pointAtWhy ? " Run with --why to see each." : ""}`;
1356
1557
  }
1357
1558
  function whyLine(skip) {
1358
- const because = skip.said === void 0 ? IN_DETAIL[skip.reason] : skip.reason === "refused" ? `refused: ${skip.said}` : skip.said;
1359
- return `${asPrinted(skip.relative)} \u2014 ${because}`;
1559
+ if (skip.line !== void 0) return skip.line;
1560
+ const because2 = skip.said === void 0 ? IN_DETAIL[skip.reason] : skip.reason === "refused" ? `refused: ${skip.said}` : skip.said;
1561
+ return `${asPrinted(skip.relative)} \u2014 ${because2}`;
1360
1562
  }
1361
1563
  var FROM_THE_WALK = {
1362
1564
  "leads-out": "leads-out",
@@ -1447,7 +1649,7 @@ async function addOneFile(options, deps, voice, descriptions) {
1447
1649
  );
1448
1650
  }
1449
1651
  if (outcome.kind === "added" || outcome.kind === "already-here") {
1450
- for (const line of outcome.notKept) voice.write(`${name2} \u2014 ${line}`);
1652
+ for (const line of outcome.notKept) voice.write(`${name2}: ${line}`);
1451
1653
  }
1452
1654
  return result;
1453
1655
  } finally {
@@ -1472,11 +1674,18 @@ function answerForOneFile(outcome, name2, voice) {
1472
1674
  return { ok: false };
1473
1675
  // ONE FILE, ONE REFUSAL, AND THE PLACES IT OFFERED. The sentence is the service's and is
1474
1676
  // printed as sent; the places under it are the service's too, and without them a refusal
1475
- // that says to name one of them is a dead end for the person holding this one file.
1476
- case "refused":
1477
- voice.write(outcome.said ?? wentWrong(name2));
1677
+ // that says to name one of them is a dead end for the person holding this one file. A refusal
1678
+ // whose sentence is written for whoever makes the call is said in this command's own words.
1679
+ case "refused": {
1680
+ const because2 = notAddedBecause(outcome);
1681
+ voice.write(
1682
+ because2 === void 0 ? outcome.said ?? wentWrong(name2) : notAdded(name2, because2)
1683
+ );
1684
+ const next = whatToDoAbout(outcome);
1685
+ if (next !== void 0) voice.write(next);
1478
1686
  offerThePlaces(voice.write, outcome);
1479
1687
  return { ok: false };
1688
+ }
1480
1689
  case "did-not-finish":
1481
1690
  voice.write(didNotFinish(name2));
1482
1691
  return { ok: false };
@@ -1521,6 +1730,17 @@ async function addFolder(options, deps, voice, descriptions) {
1521
1730
  );
1522
1731
  return { ok: false };
1523
1732
  }
1733
+ const worthAsking = walked.files.some((file) => {
1734
+ const entry = descriptions?.entries.get(asKey(file.relative));
1735
+ if (entry === void 0) return false;
1736
+ const checked = checkEntry(entry);
1737
+ return "wrong" in checked || "description" in checked.described;
1738
+ });
1739
+ if (walked.files.length > 0 && !worthAsking) {
1740
+ voice.write(NOTHING_DESCRIBED);
1741
+ voice.write(DESCRIBE_THEM);
1742
+ return { ok: false };
1743
+ }
1524
1744
  const auth2 = deps.auth ?? await resolveAuth(options);
1525
1745
  if (auth2 === null) {
1526
1746
  voice.write(CANNOT_REACH_DAZZER);
@@ -1535,7 +1755,6 @@ async function addFolder(options, deps, voice, descriptions) {
1535
1755
  const held = [];
1536
1756
  let added = 0;
1537
1757
  let ran = true;
1538
- const undescribed = [];
1539
1758
  const notKept = [];
1540
1759
  const alreadyThere = { filed: 0, alreadyDescribed: 0, notKept: 0 };
1541
1760
  for (const file of walked.files) {
@@ -1553,6 +1772,16 @@ async function addFolder(options, deps, voice, descriptions) {
1553
1772
  }
1554
1773
  described = checked.described;
1555
1774
  }
1775
+ if (described === void 0 || !("description" in described)) {
1776
+ skipped.push(
1777
+ descriptions !== void 0 && await isFile(file.path, descriptions.identity) ? { relative: file.relative, reason: "the-descriptions-file" } : {
1778
+ relative: file.relative,
1779
+ reason: "no-description",
1780
+ line: notSentNoDescription(asPrinted(file.relative))
1781
+ }
1782
+ );
1783
+ continue;
1784
+ }
1556
1785
  const handle = await openOne(file.path);
1557
1786
  if (typeof handle === "string") {
1558
1787
  skipped.push({ relative: file.relative, reason: "cannot-open" });
@@ -1589,9 +1818,6 @@ async function addFolder(options, deps, voice, descriptions) {
1589
1818
  }
1590
1819
  if (outcome.kind === "added") {
1591
1820
  added += 1;
1592
- if (descriptions !== void 0 && !("description" in (described ?? {}))) {
1593
- undescribed.push(file.relative);
1594
- }
1595
1821
  continue;
1596
1822
  }
1597
1823
  if (outcome.kind === "already-here") {
@@ -1607,16 +1833,19 @@ async function addFolder(options, deps, voice, descriptions) {
1607
1833
  continue;
1608
1834
  }
1609
1835
  if (outcome.kind === "refused") {
1836
+ const because2 = notAddedBecause(outcome);
1837
+ const reason = (outcome.code === void 0 ? void 0 : FROM_THE_SERVICE[outcome.code]) ?? "refused";
1610
1838
  skipped.push({
1611
1839
  relative: file.relative,
1612
- // KEYED ON THE SERVICE'S CODE, never on the words it sent: the words are the person's and
1613
- // may be reworded, the code is the label the service publishes.
1614
- reason: (outcome.code === void 0 ? void 0 : FROM_THE_SERVICE[outcome.code]) ?? "refused",
1840
+ reason,
1615
1841
  ...outcome.said === void 0 ? {} : { said: outcome.said },
1616
- // REFUSED ALTHOUGH IT CARRIED AN ENTRY: most likely about what the entry says, which only
1617
- // the person's assistant can put right — so it is named, in the service's own sentence,
1618
- // without anybody having to run the whole folder again with `--why` to find out.
1619
- ...described === void 0 ? {} : { named: true }
1842
+ ...because2 === void 0 ? {} : { line: notAdded(asPrinted(file.relative), because2) },
1843
+ // REFUSED ALTHOUGH IT CARRIED AN ENTRY, which every file sent now does: most likely about
1844
+ // what the entry says, which only the person's assistant can put right, so it is named
1845
+ // without anybody having to run the whole folder again with `--why`. A refusal about the
1846
+ // file's kind or size is about the file, not its entry, so it stays a count that `--why`
1847
+ // names: a folder with thirty spreadsheets in it is one line, not thirty.
1848
+ ...reason === "refused" ? { named: true } : {}
1620
1849
  });
1621
1850
  continue;
1622
1851
  }
@@ -1636,7 +1865,11 @@ async function addFolder(options, deps, voice, descriptions) {
1636
1865
  skipped.sort((a, b) => a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0);
1637
1866
  held.sort((a, b) => a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0);
1638
1867
  notKept.sort((a, b) => a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0);
1639
- voice.write(countLine(added, settled.space, skipped, options.why === true, undescribed));
1868
+ const undescribed = skipped.filter((skip) => skip.reason === "no-description");
1869
+ if (undescribed.length <= NAMED_WHEN_AT_MOST) {
1870
+ for (const skip of undescribed) skip.named = true;
1871
+ }
1872
+ voice.write(countLine(added, settled.space, skipped, options.why === true));
1640
1873
  if (skipped.some((skip) => skip.reason === "the-descriptions-file")) voice.write(keepItBeside);
1641
1874
  if (alreadyThere.filed + alreadyThere.alreadyDescribed + alreadyThere.notKept > 0) {
1642
1875
  voice.write(alreadyThereDescriptions(alreadyThere));
@@ -1647,7 +1880,7 @@ async function addFolder(options, deps, voice, descriptions) {
1647
1880
  if (options.why === true || namedWithoutWhy(skip)) voice.write(whyLine(skip));
1648
1881
  }
1649
1882
  for (const one of notKept) {
1650
- for (const line of one.lines) voice.write(`${asPrinted(one.relative)} \u2014 ${line}`);
1883
+ for (const line of one.lines) voice.write(`${asPrinted(one.relative)}: ${line}`);
1651
1884
  }
1652
1885
  const onlyOne = held.length === 1 ? held[0] : void 0;
1653
1886
  if (onlyOne !== void 0) {
@@ -1660,7 +1893,15 @@ async function addFolder(options, deps, voice, descriptions) {
1660
1893
  )
1661
1894
  );
1662
1895
  }
1663
- return { ok: ran };
1896
+ return { ok: ran && !skipped.some(LEAVES_A_FILE_OUT) };
1897
+ }
1898
+ async function isFile(path, identity) {
1899
+ try {
1900
+ const info = await statPath(path, { bigint: true });
1901
+ return sameFile({ dev: info.dev, ino: info.ino }, identity);
1902
+ } catch {
1903
+ return false;
1904
+ }
1664
1905
  }
1665
1906
  async function aKeyItWouldMatch(files, descriptionsFile) {
1666
1907
  for (const file of files) {
@@ -2348,6 +2589,9 @@ async function runStatus(options) {
2348
2589
  case "rejected":
2349
2590
  printAuthStatus({ state: "expired" });
2350
2591
  return;
2592
+ case "save-refused":
2593
+ printAuthStatus({ state: "save-refused" });
2594
+ return;
2351
2595
  case "unreachable":
2352
2596
  printAuthStatus({
2353
2597
  state: "unverified",
@@ -2399,6 +2643,15 @@ var { name, version } = require2("../package.json");
2399
2643
  var client = `${name}/${version}`;
2400
2644
  var program = new Command();
2401
2645
  program.name("dazzer").description("Sign in to Dazzer from your terminal").version(version);
2646
+ var newerTool;
2647
+ program.hook("preAction", () => {
2648
+ newerTool = lookForNewerTool({ name, version, folder: getConfigDir() });
2649
+ });
2650
+ program.hook("postAction", async () => {
2651
+ const said = await newerTool?.finish();
2652
+ if (said) process.stderr.write(`${said}
2653
+ `);
2654
+ });
2402
2655
  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(
2403
2656
  "--described-in <file>",
2404
2657
  "A JSON file saying what each file is, keyed by its path inside the folder"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dazzer-connect",
3
3
  "private": false,
4
- "version": "0.3.0",
4
+ "version": "0.4.1",
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",
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "devDependencies": {
43
43
  "@dazzer/auth-ui": "workspace:*",
44
- "@types/node": "^26.6.2",
44
+ "@types/node": "^26.6.3",
45
45
  "esbuild": "^0.28.2",
46
46
  "tsx": "^4.23.15",
47
47
  "typescript": "^5.7.2",