memgineering 0.7.4 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,93 @@ language the reader wants. The bilingual rule the monorepo applies to
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.7.5] — 2026-08-23
15
+
16
+ ### Fixed
17
+
18
+ - **The approval list now shows the claim — what the note would say — above the
19
+ reason it is being changed.** `proposals approve` has no confirmation step: it
20
+ applies the change and shows the diff afterwards, so the list is the only
21
+ place a decision is made, and it was carrying the argument for a change while
22
+ withholding the change itself. Cut to one line on screen; whole under
23
+ `--json`. Null for `retire`, `unretire` and `reclassify`, which assert no
24
+ conclusion. Showing it for a **hosted** brain needs the brain server deployed
25
+ as well; against an older server the row says the claim is unavailable rather
26
+ than rendering as though the action had none. Brains in a folder need nothing.
27
+ - **`consolidate` candidates name their notes.** A row gave an id and a path,
28
+ which say which FILE a candidate is about and nothing about what it says — so
29
+ you could not tell which notes a candidate was even about without opening both.
30
+ The row still does not carry their claims: deciding which of a pair is right
31
+ means reading them, and the row is what tells you which two to read.
32
+ Titles are cut at 72 characters with an ellipsis, so a short title and a
33
+ truncated one are never confusable. (Characters, not columns — a Korean title
34
+ is about twice as wide on screen.)
35
+ - **`reindex` reports what it left out.** It listed notes refused for their name
36
+ or their content and said nothing about ones a `.memgignore` rule or a symlink
37
+ kept out of the index, so "✓ rebuilt 3 note(s)" could not answer "where is my
38
+ note". It now prints the same four-rule block `push` prints. `--json` gains
39
+ `not_indexed`, `denied_explicitly`, `excluded_by_rule` and `skipped_symlinks`
40
+ — the count is `not_indexed` rather than push's `not_sent`, because nothing is
41
+ sent from here.
42
+ - **A title or claim cut mid-emoji no longer comes out as broken text.** The
43
+ truncation counted UTF-16 units, so a cut landing between the halves of a
44
+ surrogate pair emitted a lone surrogate. It counts characters now.
45
+ - **A rule that excludes a FOLDER now names the folder.** `push` and `reindex`
46
+ both stop walking at an excluded directory, so the notes inside are never
47
+ enumerated — and both reported a held-back count of zero and printed nothing
48
+ else, so a brain whose entire archive was excluded looked exactly like a brain
49
+ that lost nothing. The count is still an honest floor; `excluded_folders` in
50
+ `--json` and a named line on screen say what it is a floor of.
51
+ - **Note text is stripped of C1 controls and bidi format characters, on both
52
+ sides.** Only the `ESC`-introduced escape sequences were removed. A terminal in
53
+ 8-bit mode reads U+009B and U+009D as the same two introducers with no `ESC`
54
+ byte; U+0085 is a line break, which broke the one-line guarantee the new title
55
+ and claim rows depend on; and a bidi override can make a line display in an
56
+ order its bytes do not have. U+2028 and U+2029 go too — `quoteBlock` splits on
57
+ `\n` alone, so a note containing one could resume outside the quote that marks
58
+ it as the note's words rather than the tool's. Escape sequences are read by
59
+ ECMA-48's grammar now rather than run to the next plausible-looking byte, so a
60
+ stray marker costs a few characters instead of the rest of the line. Measured:
61
+ one character before pure Hangul, two before `launchctl 로 한다`, five before
62
+ `21 August`, six before `123 abc` — against the whole remainder before. U+0085,
63
+ U+2028 and U+2029 are translated to a newline rather than dropped, so a break
64
+ in a stored note stays a break instead of welding the words on either side.
65
+ - **Security: text pasted out of a terminal or a chat window can now be refused,
66
+ and a hidden character can no longer sneak a password past the check that looks
67
+ for one.** If a proposal or a `--reason` is rejected for carrying invisible
68
+ formatting, nothing was saved — retype the words as plain text and run it again.
69
+ What was wrong: the check read normalised text, and normalising could not
70
+ win: deleting the marker left its parameters between a label and its value,
71
+ and consuming the sequence deleted label characters instead — each fix opened
72
+ the other hole. The screen now refuses a proposal whose prose contains an
73
+ escape or control introducer at all, which has no such seam. The same rule now
74
+ guards `--reason` on every write verb: that text goes into the ledger, which is
75
+ permanent and travels with the brain. Note text itself is still stored rather
76
+ than refused — blocking a save is the worse trade on the main write path, where
77
+ the commonest source of a stray control byte is a mis-decoded file — and is
78
+ protected instead by the credential detector, which now reads the text with
79
+ control introducers removed as well as with them left in, so a label split by a
80
+ hidden byte is still seen as a label. That widening cuts both ways and the
81
+ second half is worth knowing: a note ALREADY on disk whose text hides a label
82
+ this way is now refused on READ, where 0.7.4 read it fine. Both codes involved
83
+ are weak ones, so `.memgallow` overrules them. The two copies of the normaliser are
84
+ hardened and a parity test compares them as source, because they had already
85
+ drifted once.
86
+
87
+ ### Changed
88
+
89
+ - `reindex` lists markdown only where it used to list every denied path, on
90
+ screen and in `denied_by_name` alike, matching `push`. A denied `.env` was
91
+ never a note anybody expected in a note index.
92
+ - `push`'s nothing-to-upload error no longer tells someone to check their path
93
+ when the path was right and their own folder rule emptied the brain.
94
+
95
+ ### Agent guidance
96
+
97
+ `memgineering-writing` and `memgineering-setup` describe the claim row and what
98
+ `reindex` now reports. Restart your agent session after upgrading so it picks
99
+ them up.
100
+
14
101
  ## [0.7.4] — 2026-08-19
15
102
 
16
103
  ### Changed
@@ -2,7 +2,7 @@
2
2
  name: memgineering
3
3
  description: Use whenever the user refers to something they told you before, asks what was decided, tells you something worth keeping, or settles something that should hold next time. The memory lives in their own folder and outlives this session; check it before answering from guesswork, and write to it when you learn something durable.
4
4
  type: skill
5
- version: 0.7.4
5
+ version: 0.7.5
6
6
  ---
7
7
 
8
8
  # memgineering
@@ -282,7 +282,19 @@ and needs no model anywhere; that is a decision, not a gap.
282
282
  by hand in the ordinary case — writes keep the index in step on their own.
283
283
  `memgineering reindex` reaches either kind of brain: on a hosted one it backfills
284
284
  what is missing and clears stored rows for notes the brain has since been told
285
- not to read, reporting both; `--local` forces the folder brain. `--rebuild`
285
+ not to read, reporting both; `--local` forces the folder brain. On a brain in a
286
+ FOLDER it also names what it left out — notes a deny name, a `.memgignore` rule
287
+ or a symlink kept from the index, and any folder a rule excluded whole — so "3
288
+ notes rebuilt" is never the whole answer when a fourth was dropped. A hosted
289
+ reindex cannot say that: the rules are applied on the machine that pushed, so
290
+ the server only ever saw what arrived.
291
+
292
+ Under `--json` that is `not_indexed` with `denied_by_name`, `denied_explicitly`,
293
+ `excluded_by_rule`, `skipped_symlinks` and `excluded_folders`. **Read the count
294
+ together with `excluded_folders`**: a rule that names a folder stops the walk at
295
+ the folder, so nothing inside it is ever counted — `not_indexed: 0` beside a
296
+ non-empty `excluded_folders` means the count is a floor, not the answer. `push`
297
+ reports the same set with its total under `not_sent`. `--rebuild`
286
298
  exists for one situation only, where an embedding model changed the shape of its
287
299
  answers without changing its name and the index has to be laid down again.
288
300
 
@@ -140,8 +140,11 @@ only the last of them touches a note.
140
140
  memgineering consolidate # what looks worth merging or reclassifying — reads only
141
141
  ```
142
142
 
143
- It returns pairs with the evidence that caught them and the action it would
144
- suggest, under four rules: `similar_title`; `shared_context`, one scope naming
143
+ Each candidate names its notes id, title, path so you can tell WHICH notes
144
+ it is about without opening them. It does not carry their claims, so deciding
145
+ which of a pair is right still means reading them: two notes can share a title
146
+ and hold opposite conclusions, which is exactly the case a proposal exists for. It returns them with the evidence that caught them and the action it
147
+ would suggest, under four rules: `similar_title`; `shared_context`, one scope naming
145
148
  the same thing; `contradiction`, a disagreement nobody settled; and
146
149
  `kind_promotion`, a note marked as binding but filed as something other than a
147
150
  decision.
@@ -179,6 +182,14 @@ memgineering proposals reject <id>
179
182
  memgineering proposals rebase <id>
180
183
  ```
181
184
 
185
+ **Read the list before you approve.** A row carries the claim — what the note
186
+ would say — above the reason it is being changed. Three actions assert no
187
+ conclusion and so have none: `retire`, `unretire` and `reclassify`. A row with no
188
+ claim line is one of those, not a row that lost something. There is no confirmation step:
189
+ `approve` applies the change and shows you the diff afterwards, so the list is
190
+ where the decision is actually made. The claim is cut to one line on screen and
191
+ whole under `--json`.
192
+
182
193
  `approve` is the only command in this flow that writes to a note, and it goes
183
194
  through the ledger like every other write — one `undo` away from not having
184
195
  happened. `approve` and `reject` each take `--reason`, and it is kept.
package/dist/index.js CHANGED
@@ -120,32 +120,46 @@ function stripControl(input) {
120
120
  let out = "";
121
121
  for (let i = 0; i < input.length; i++) {
122
122
  const code = input.charCodeAt(i);
123
- if (code === 27) {
124
- const next = input[i + 1];
125
- if (next === "]") {
126
- let j = i + 2;
127
- while (j < input.length && input.charCodeAt(j) !== 7 && !(input.charCodeAt(j) === 27 && input[j + 1] === "\\")) {
128
- j++;
129
- }
130
- if (input.charCodeAt(j) === 7) j++;
131
- else if (input.charCodeAt(j) === 27) j += 2;
132
- i = j - 1;
123
+ if (code === 27 && input[i + 1] === "]" || code === 157) {
124
+ const from = code === 157 ? i + 1 : i + 2;
125
+ let j = from;
126
+ while (j < input.length && input.charCodeAt(j) !== 7 && input.charCodeAt(j) !== 156 && !(input.charCodeAt(j) === 27 && input[j + 1] === "\\")) {
127
+ j++;
128
+ }
129
+ if (j >= input.length) {
130
+ i = from - 1;
133
131
  continue;
134
132
  }
135
- if (next === "[") {
136
- let j = i + 2;
137
- while (j < input.length && !(input.charCodeAt(j) >= 64 && input.charCodeAt(j) <= 126)) {
138
- j++;
139
- }
133
+ j += input.charCodeAt(j) === 27 ? 2 : 1;
134
+ i = j - 1;
135
+ continue;
136
+ }
137
+ if (code === 27 && input[i + 1] === "[" || code === 155) {
138
+ const from = code === 155 ? i + 1 : i + 2;
139
+ let j = from;
140
+ while (j < input.length && input.charCodeAt(j) >= 48 && input.charCodeAt(j) <= 63) j++;
141
+ while (j < input.length && input.charCodeAt(j) >= 32 && input.charCodeAt(j) <= 47) j++;
142
+ const final = j < input.length ? input.charCodeAt(j) : -1;
143
+ if (final >= 64 && final <= 126) {
140
144
  i = j;
141
145
  continue;
142
146
  }
147
+ i = from - 1;
148
+ continue;
149
+ }
150
+ if (code === 27) {
143
151
  i += 1;
144
152
  continue;
145
153
  }
146
- if (code <= 31 && code !== 10 && code !== 9 || code === 127) {
154
+ if (code === 133 || code === 8232 || code === 8233) {
155
+ out += "\n";
147
156
  continue;
148
157
  }
158
+ if (code <= 31 && code !== 10 && code !== 9 || code === 127) continue;
159
+ if (code >= 128 && code <= 159) continue;
160
+ if (code === 1564 || code === 8206 || code === 8207) continue;
161
+ if (code >= 8234 && code <= 8238) continue;
162
+ if (code >= 8294 && code <= 8297) continue;
149
163
  out += input[i];
150
164
  }
151
165
  return out;
@@ -153,6 +167,12 @@ function stripControl(input) {
153
167
  function quoteBlock(text, prefix = "> ") {
154
168
  return stripControl(text).split("\n").map((line) => prefix + line).join("\n");
155
169
  }
170
+ function oneLine(text, max) {
171
+ const flat = stripControl(text).replace(/\s+/g, " ").trim();
172
+ const chars = [...flat];
173
+ if (chars.length <= max) return flat;
174
+ return `${chars.slice(0, Math.max(1, max - 1)).join("").trimEnd()}\u2026`;
175
+ }
156
176
  function progress(text) {
157
177
  if (isInteractive()) process.stderr.write(kleur.gray(` ${text}
158
178
  `));
@@ -167,12 +187,13 @@ function undoHint(opId, vault) {
167
187
  function quoteArg(value) {
168
188
  return /^[A-Za-z0-9._\/-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
169
189
  }
170
- var jsonMode, c;
190
+ var jsonMode, CONTROL_INTRODUCER, c;
171
191
  var init_ui = __esm({
172
192
  "src/ui.ts"() {
173
193
  "use strict";
174
194
  init_brand();
175
195
  jsonMode = false;
196
+ CONTROL_INTRODUCER = /[\u001b\u009b\u009d]/;
176
197
  c = kleur;
177
198
  }
178
199
  });
@@ -969,10 +990,12 @@ function secretContentHits(text) {
969
990
  if (pattern.test(text)) hits.push({ code });
970
991
  }
971
992
  const plain = stripMarkdownEmphasis(text);
972
- if (hasLabelledCredential(text) || hasLabelledCredential(plain)) {
993
+ const bare = withoutIntroducers(text);
994
+ const bothWays = (check) => check(text) || check(plain) || bare !== text && (check(bare) || check(stripMarkdownEmphasis(bare)));
995
+ if (bothWays(hasLabelledCredential)) {
973
996
  hits.push({ code: "labelled_credential" });
974
997
  }
975
- if (hasSpacedCredential(text) || hasSpacedCredential(plain)) {
998
+ if (bothWays(hasSpacedCredential)) {
976
999
  hits.push({ code: "spaced_credential" });
977
1000
  }
978
1001
  if (hasCredentialNearPrivateIp(text)) hits.push({ code: "credential_near_private_ip" });
@@ -1003,7 +1026,7 @@ function isExplicitlyDenied(relPath, deny = EMPTY_DENY_LIST) {
1003
1026
  function isNameAllowlisted(relPath, allow = EMPTY_DENY_LIST) {
1004
1027
  return isExplicitlyDenied(relPath, allow);
1005
1028
  }
1006
- var SENSITIVE_NAME_TERMS, foldForMatch, FOLDED_TERMS, SECRET_CONTENT_PATTERNS, CREDENTIAL_LABEL, LABEL_TAIL, LABEL_QUALIFIER, SEPARATOR, HEADER_CELL_IS_LABEL, stripMarkdownEmphasis, LABELLED_CREDENTIAL_RE, SPACED_CREDENTIAL_RE, LOOKS_LIKE_A_CREDENTIAL, FINANCIAL_NUMBER_RE, SHORT_SECRET_RE, PRIVATE_IP_RE, ACCESS_WORD_RE, VALUE_IS_NOT_A_SECRET, DeniedReadError, WEAK_CODES, isWeakSecretCode, EMPTY_DENY_LIST;
1029
+ var SENSITIVE_NAME_TERMS, foldForMatch, FOLDED_TERMS, SECRET_CONTENT_PATTERNS, CREDENTIAL_LABEL, LABEL_TAIL, LABEL_QUALIFIER, SEPARATOR, HEADER_CELL_IS_LABEL, stripMarkdownEmphasis, LABELLED_CREDENTIAL_RE, SPACED_CREDENTIAL_RE, LOOKS_LIKE_A_CREDENTIAL, FINANCIAL_NUMBER_RE, SHORT_SECRET_RE, PRIVATE_IP_RE, ACCESS_WORD_RE, VALUE_IS_NOT_A_SECRET, DeniedReadError, WEAK_CODES, isWeakSecretCode, withoutIntroducers, EMPTY_DENY_LIST;
1007
1030
  var init_deny = __esm({
1008
1031
  "../../packages/storage-adapter/src/local-file/deny.ts"() {
1009
1032
  "use strict";
@@ -1123,6 +1146,12 @@ var init_deny = __esm({
1123
1146
  "credential_table"
1124
1147
  ]);
1125
1148
  isWeakSecretCode = (code) => WEAK_CODES.has(code);
1149
+ withoutIntroducers = (text) => (
1150
+ // `ESC [` and `ESC ]` are two characters, so removing the ESC alone would
1151
+ // leave a bracket where the hidden byte was and the label would still not
1152
+ // read as itself.
1153
+ text.replace(/\u001b[[\]]?|[\u009b\u009d]/g, "")
1154
+ );
1126
1155
  EMPTY_DENY_LIST = { entries: [] };
1127
1156
  }
1128
1157
  });
@@ -3129,6 +3158,7 @@ async function walkVault(root, opts = {}) {
3129
3158
  const files2 = [];
3130
3159
  const skippedSymlinks = [];
3131
3160
  const excluded = [];
3161
+ const excludedDirs = [];
3132
3162
  const visit = async (dirAbs, relDir, depth) => {
3133
3163
  if (depth > maxDepth) return;
3134
3164
  let entries;
@@ -3146,6 +3176,7 @@ async function walkVault(root, opts = {}) {
3146
3176
  const isDir = entry.isDirectory();
3147
3177
  if (isExcluded(rel, isDir, opts.ignore)) {
3148
3178
  excluded.push(rel);
3179
+ if (isDir) excludedDirs.push(rel);
3149
3180
  continue;
3150
3181
  }
3151
3182
  if (isDir) {
@@ -3157,7 +3188,7 @@ async function walkVault(root, opts = {}) {
3157
3188
  };
3158
3189
  await visit(rootAbs, "", 0);
3159
3190
  files2.sort();
3160
- return { files: files2, skippedSymlinks, excluded };
3191
+ return { files: files2, skippedSymlinks, excluded, excludedDirs };
3161
3192
  }
3162
3193
  function resolveInside(root, relPath) {
3163
3194
  if (relPath === "" || relPath.startsWith("/") || relPath.includes("\0")) {
@@ -8275,6 +8306,120 @@ async function createCloudBrain(token, baseUrl, name) {
8275
8306
  // src/commands/brain-admin.ts
8276
8307
  init_config();
8277
8308
 
8309
+ // src/lib/held-back.ts
8310
+ init_ui();
8311
+ var isNote = (p) => /\.(md|markdown)$/i.test(p);
8312
+ function heldBack(scan) {
8313
+ const denied = scan.denied ?? [];
8314
+ const byName = denied.filter((d) => d.kind === "name" && isNote(d.path)).map((d) => ({ path: d.path, term: d.term }));
8315
+ const explicitly = denied.filter((d) => d.kind === "explicit" && isNote(d.path)).map((d) => d.path);
8316
+ const byRule = scan.excluded.filter(isNote);
8317
+ const symlinked = scan.skippedSymlinks.filter(isNote);
8318
+ return {
8319
+ total: byName.length + explicitly.length + byRule.length + symlinked.length,
8320
+ folders: scan.excludedDirs ?? [],
8321
+ byName,
8322
+ explicitly,
8323
+ byRule,
8324
+ symlinked
8325
+ };
8326
+ }
8327
+ function heldBackJson(held, totalKey = "not_sent") {
8328
+ return {
8329
+ // The count's key is the caller's, because `not_sent` is a claim about
8330
+ // sending and `reindex` sends nothing. Naming its total `not_sent` would
8331
+ // have an agent reporting a failed upload from a command that never
8332
+ // contacts a server. `push` keeps the key it has always emitted.
8333
+ [totalKey]: held.total,
8334
+ denied_by_name: held.byName,
8335
+ denied_explicitly: held.explicitly,
8336
+ excluded_by_rule: held.byRule,
8337
+ skipped_symlinks: held.symlinked,
8338
+ // Present so a zero total can be read correctly. A non-empty list here means
8339
+ // the count above is a floor, not the answer — nothing inside these was
8340
+ // walked, so nobody knows how many notes they hold.
8341
+ excluded_folders: held.folders
8342
+ };
8343
+ }
8344
+ var NAME_GUESS_LABEL = "name looks private";
8345
+ function heldBackGroups(held) {
8346
+ return [
8347
+ {
8348
+ label: NAME_GUESS_LABEL,
8349
+ rows: held.byName.map((d) => ({ path: d.path, detail: `matched "${d.term ?? "?"}"` }))
8350
+ },
8351
+ { label: "listed in .memgdeny", rows: held.explicitly.map((p) => ({ path: p, detail: null })) },
8352
+ { label: "excluded by a rule", rows: held.byRule.map((p) => ({ path: p, detail: null })) },
8353
+ {
8354
+ label: "a symlink, which is never followed",
8355
+ rows: held.symlinked.map((p) => ({ path: p, detail: null }))
8356
+ }
8357
+ ];
8358
+ }
8359
+ function describeHeldBack(held) {
8360
+ return [
8361
+ ...heldBackGroups(held).flatMap(
8362
+ ({ label, rows }) => rows.map(
8363
+ (r) => `${stripControl(r.path)} \u2014 ${label}${r.detail === null ? "" : ` (${stripControl(r.detail)})`}`
8364
+ )
8365
+ ),
8366
+ ...held.folders.map(
8367
+ (dir) => `${stripControl(dir)}/ \u2014 an excluded folder, whose contents were never walked`
8368
+ )
8369
+ ];
8370
+ }
8371
+ function printHeldBack(held, wording) {
8372
+ if (held.total === 0 && held.folders.length === 0) return;
8373
+ const guessed = held.byName;
8374
+ const chosen = heldBackGroups(held).filter(({ label }) => label !== NAME_GUESS_LABEL);
8375
+ const chosenCount = chosen.reduce((n, g) => n + g.rows.length, 0);
8376
+ if (chosenCount > 0 || held.folders.length > 0) {
8377
+ printHuman(
8378
+ c.gray(
8379
+ chosenCount === 0 ? `
8380
+ Some notes are not in ${wording.where}, because your rules say so:` : `
8381
+ ${chosenCount} note(s) are not in ${wording.where}, because your rules say so:`
8382
+ )
8383
+ );
8384
+ for (const { label, rows } of chosen) {
8385
+ if (rows.length === 0) continue;
8386
+ printHuman(c.gray(` ${rows.length} \u2014 ${label}:`));
8387
+ for (const r of rows.slice(0, 10)) printHuman(c.gray(` ${stripControl(r.path)}`));
8388
+ if (rows.length > 10) printHuman(c.gray(` \u2026 and ${rows.length - 10} more`));
8389
+ }
8390
+ if (held.folders.length > 0) {
8391
+ printHuman(
8392
+ c.gray(
8393
+ ` ${held.folders.length} excluded folder(s) \u2014 their contents were not walked, so notes
8394
+ inside them are not counted above:`
8395
+ )
8396
+ );
8397
+ for (const dir of held.folders.slice(0, 10)) printHuman(c.gray(` ${stripControl(dir)}/`));
8398
+ if (held.folders.length > 10) {
8399
+ printHuman(c.gray(` \u2026 and ${held.folders.length - 10} more`));
8400
+ }
8401
+ }
8402
+ }
8403
+ if (guessed.length > 0) {
8404
+ printHuman(
8405
+ c.yellow(
8406
+ `
8407
+ \u26A0 ${guessed.length} note(s) are not in ${wording.where}, held back on this machine by a GUESS about the name:`
8408
+ )
8409
+ );
8410
+ for (const d of guessed.slice(0, 10)) {
8411
+ printHuman(c.gray(` ${stripControl(d.path)} (matched "${stripControl(d.term ?? "?")}")`));
8412
+ }
8413
+ if (guessed.length > 10) printHuman(c.gray(` \u2026 and ${guessed.length - 10} more`));
8414
+ printHuman(
8415
+ c.gray(
8416
+ ` This may be a false alarm \u2014 rename the note, or list it in .memgallow,
8417
+ and ${wording.retry}.`
8418
+ )
8419
+ );
8420
+ }
8421
+ }
8422
+
8278
8423
  // src/lib/ledger.ts
8279
8424
  init_deny();
8280
8425
  init_brand();
@@ -8413,6 +8558,13 @@ var RULE_VERSION = 2;
8413
8558
  var RATIONALE_MAX = 500;
8414
8559
  function sanitizeRationale(raw) {
8415
8560
  if (raw == null) return null;
8561
+ if (CONTROL_INTRODUCER.test(raw)) {
8562
+ throw memgError(
8563
+ "invalid_input",
8564
+ "the reason contains a terminal control sequence",
8565
+ "Nothing was saved. The text carries invisible formatting \u2014 usually from pasting out of a terminal, a chat window, or a file that was decoded wrongly \u2014 and stored prose that hides characters can read as something other than what it says. Type the same words in again as plain text and run it once more. Keep --reason: the reason is the part of a memory that still means anything later."
8566
+ );
8567
+ }
8416
8568
  const clean = stripControl(raw).trim();
8417
8569
  if (clean === "") return null;
8418
8570
  const hits = secretContentHits(clean);
@@ -8603,14 +8755,17 @@ function reindexCommand() {
8603
8755
  protectedVault: true
8604
8756
  });
8605
8757
  const scan = await adapter.scan();
8606
- const nameDenied = (scan.denied ?? []).filter((d) => d.kind !== "explicit");
8758
+ const held = heldBack(scan);
8607
8759
  printDual({
8608
8760
  json: {
8609
8761
  brain: brain.root,
8610
8762
  indexed: built.entries.length,
8611
8763
  cache_dir: built.cacheDir,
8612
8764
  issues: built.issues,
8613
- denied_by_name: nameDenied.map((d) => ({ path: d.path, term: d.term })),
8765
+ // Carries `denied_by_name` under the key it always had, plus the three
8766
+ // this command used to drop. The total is `not_indexed` rather than
8767
+ // push's `not_sent`: nothing is sent from here.
8768
+ ...heldBackJson(held, "not_indexed"),
8614
8769
  denied_by_content: built.denied.filter((d) => d.reason === "content")
8615
8770
  },
8616
8771
  human: () => {
@@ -8623,14 +8778,21 @@ function reindexCommand() {
8623
8778
  )
8624
8779
  );
8625
8780
  }
8781
+ printHeldBack(held, { where: "the index", retry: "reindex" });
8626
8782
  const byContent = built.denied.filter((d) => d.reason === "content");
8627
- for (const [label, rows] of [
8628
- ["name looks private", nameDenied.map((d) => `${d.path} (matched "${d.term}")`)],
8629
- ["content looks like a credential", byContent.map((d) => `${d.path} (${d.detail})`)]
8630
- ]) {
8631
- if (rows.length === 0) continue;
8632
- printHuman(c.yellow(` ${rows.length} note(s) refused \u2014 ${label}:`));
8633
- for (const row of rows.slice(0, 10)) printHuman(c.gray(` ${row}`));
8783
+ if (byContent.length > 0) {
8784
+ printHuman(
8785
+ c.yellow(
8786
+ `
8787
+ \u26A0 ${byContent.length} note(s) refused \u2014 content looks like a credential:`
8788
+ )
8789
+ );
8790
+ for (const d of byContent.slice(0, 10)) {
8791
+ printHuman(c.gray(` ${stripControl(d.path)} (${stripControl(d.detail)})`));
8792
+ }
8793
+ if (byContent.length > 10) {
8794
+ printHuman(c.gray(` \u2026 and ${byContent.length - 10} more`));
8795
+ }
8634
8796
  }
8635
8797
  }
8636
8798
  });
@@ -10066,11 +10228,17 @@ function proposalsCommand() {
10066
10228
  opts.all === true ? { all: true } : {}
10067
10229
  ) : await listLocally(where.brain.root, opts.all === true);
10068
10230
  const shown = listed.proposals;
10231
+ const unavailable = shown.filter(
10232
+ (p) => p.claim === void 0 && !carriesNoClaim(p.action)
10233
+ );
10234
+ const rows = shown.map(
10235
+ (p) => p.claim === void 0 && !carriesNoClaim(p.action) ? { ...p, claim: null, claim_unavailable: true } : p
10236
+ );
10069
10237
  printDual({
10070
10238
  json: {
10071
- proposals: shown,
10072
- count: shown.length,
10073
- hint: hintForList(shown.length, listed.total, opts.all === true)
10239
+ proposals: rows,
10240
+ count: rows.length,
10241
+ hint: unavailable.length > 0 ? `${unavailable.length} of these came back without a claim because this brain's server is older than the CLI \u2014 they are marked \`claim_unavailable\`, and \`memgineering open <id>\` shows what each note says now. ${hintForList(shown.length, listed.total, opts.all === true)}` : hintForList(shown.length, listed.total, opts.all === true)
10074
10242
  },
10075
10243
  human: () => {
10076
10244
  if (shown.length === 0) {
@@ -10111,6 +10279,7 @@ function summarize(p) {
10111
10279
  action: p.action,
10112
10280
  memory_id: p.memory_id,
10113
10281
  target: p.target.path,
10282
+ claim: p.claim?.text ?? null,
10114
10283
  reason: p.reason,
10115
10284
  proposed_kind: p.proposed_kind,
10116
10285
  supersedes: p.relations.supersedes,
@@ -10118,12 +10287,24 @@ function summarize(p) {
10118
10287
  expires_at: p.expiresAt
10119
10288
  };
10120
10289
  }
10290
+ var CLAIM_WIDTH = 160;
10121
10291
  function renderSummary(p) {
10292
+ const claimless = carriesNoClaim(p.action);
10293
+ const claim = p.claim === void 0 || p.claim === null ? null : oneLine(p.claim, CLAIM_WIDTH);
10294
+ const unavailable = p.claim === void 0 && !claimless;
10122
10295
  const lines2 = [
10123
10296
  `- **${stripControl(p.action)}**${p.proposed_kind ? ` \u2192 ${p.proposed_kind}` : ""} \`${stripControl(p.target)}\``,
10124
- ` ${p.id} ${p.state}`,
10125
- quoteBlock(p.reason, " > ")
10297
+ ` ${p.id} ${p.state}`
10126
10298
  ];
10299
+ if (claim !== null && claim !== "") lines2.push(` \u2192 ${claim}`);
10300
+ else if (unavailable) {
10301
+ lines2.push(
10302
+ c.gray(
10303
+ ` \u2192 (claim unavailable: this brain's server is older than the CLI. \`memgineering open ${stripControl(p.memory_id ?? p.target)}\` shows what the note says NOW; upgrading the server restores the claim.)`
10304
+ )
10305
+ );
10306
+ }
10307
+ lines2.push(quoteBlock(p.reason, " > "));
10127
10308
  if (p.supersedes.length > 0) {
10128
10309
  lines2.push(c.gray(` replaces: ${p.supersedes.map(stripControl).join(", ")}`));
10129
10310
  }
@@ -10471,14 +10652,25 @@ function consolidateCommand() {
10471
10652
  });
10472
10653
  });
10473
10654
  }
10655
+ var TITLE_WIDTH = 72;
10656
+ function noteLines(id, title, path) {
10657
+ const shown = oneLine(title, TITLE_WIDTH);
10658
+ return [
10659
+ shown === "" ? ` ${stripControl(id)}` : ` ${stripControl(id)} ${shown}`,
10660
+ // Backticked, as it was before the title moved onto the row above it. This
10661
+ // output is read by agents as markdown, and a path outside code formatting
10662
+ // is a path they have to guess the extent of.
10663
+ c.gray(` \`${stripControl(path)}\``)
10664
+ ];
10665
+ }
10474
10666
  function renderCandidate(candidate) {
10475
10667
  const lines2 = [
10476
10668
  `- **${stripControl(candidate.rule)}** \u2014 ${stripControl(candidate.evidence)}`,
10477
- ` ${stripControl(candidate.memory_id)} \`${stripControl(candidate.path)}\``
10669
+ ...noteLines(candidate.memory_id, candidate.title, candidate.path)
10478
10670
  ];
10479
10671
  if (candidate.other_id !== null) {
10480
10672
  lines2.push(
10481
- ` ${stripControl(candidate.other_id)} \`${stripControl(candidate.other_path ?? "")}\``
10673
+ ...noteLines(candidate.other_id, candidate.other_title ?? "", candidate.other_path ?? "")
10482
10674
  );
10483
10675
  }
10484
10676
  lines2.push(
@@ -12147,6 +12339,11 @@ init_api_client();
12147
12339
  import { Command as Command17 } from "commander";
12148
12340
  init_vault();
12149
12341
  init_ui();
12342
+ var HELD_BACK_WORDING = {
12343
+ where: "the hosted brain",
12344
+ retry: "push again"
12345
+ };
12346
+ var printHeldBack2 = (held) => printHeldBack(held, HELD_BACK_WORDING);
12150
12347
  function pushCommand() {
12151
12348
  return new Command17("push").description("upload the brain on this machine to your hosted one").option("--vault <path>", "which local brain to upload").option("--brain <name>", "the hosted brain to upload into (created if it does not exist)").option("--dry-run", "list what would be uploaded and send nothing").action(async (opts) => {
12152
12349
  const { brain: local } = await resolveBrain(opts.vault);
@@ -12155,11 +12352,16 @@ function pushCommand() {
12155
12352
  const paths = scan.files.filter(isNote);
12156
12353
  const held = heldBack(scan);
12157
12354
  if (paths.length === 0) {
12355
+ const sawFiles = scan.files.length > 0;
12356
+ const excluded = describeHeldBack(held).slice(0, 10).map((row) => ` ${row}`).join("\n");
12158
12357
  throw memgError(
12159
12358
  "invalid_input",
12160
12359
  `there is nothing to upload in ${local.root}`,
12161
- held.total === 0 ? "This brain has no markdown notes in it at all. Check the path \u2014 `--vault` takes the folder your notes live in." : `This brain has ${held.total} markdown note(s) and the rules on this machine hold back every one:
12162
- ` + describeHeldBack(held).slice(0, 10).map((row) => ` ${row}`).join("\n") + "\nRename one, or list it in .memgallow, if the name is a false alarm."
12360
+ held.total > 0 ? `This brain has ${held.total} markdown note(s) and the rules on this machine hold back every one:
12361
+ ${excluded}
12362
+ Rename one, or list it in .memgallow, if the name is a false alarm.` : held.folders.length > 0 ? `No markdown notes were found. ${held.folders.length} folder(s) were excluded by a rule and never looked inside:
12363
+ ${excluded}
12364
+ If your notes are in one of those, that rule is why \u2014 nothing here can see them. If they are somewhere else, check the path: \`--vault\` takes the folder your notes live in.` : sawFiles ? "There are files here but no markdown notes. Check the path \u2014 `--vault` takes the folder your notes live in." : "This folder is empty. Check the path \u2014 `--vault` takes the folder your notes live in."
12163
12365
  );
12164
12366
  }
12165
12367
  if (opts.dryRun) {
@@ -12179,7 +12381,7 @@ function pushCommand() {
12179
12381
  `);
12180
12382
  for (const path of paths.slice(0, 20)) printHuman(c.gray(` ${path}`));
12181
12383
  if (paths.length > 20) printHuman(c.gray(` \u2026 and ${paths.length - 20} more`));
12182
- printHeldBack(held);
12384
+ printHeldBack2(held);
12183
12385
  printHuman(
12184
12386
  c.gray(
12185
12387
  "\nNothing was sent. What a dry run cannot know is the other screen: a note\nwhose CONTENT looks like a credential is held back \u2014 by this machine before\nsending, and by the server on arrival \u2014 and deciding that needs the bytes,\nwhich this does not read. Those appear in the real run\u2019s summary."
@@ -12284,7 +12486,7 @@ function pushCommand() {
12284
12486
  printHuman(c.gray(` \u2026 and ${refused.length - 10} more`));
12285
12487
  }
12286
12488
  }
12287
- printHeldBack(held);
12489
+ printHeldBack2(held);
12288
12490
  printRepoint(repointed, destination);
12289
12491
  printHuman(
12290
12492
  c.gray(
@@ -12322,82 +12524,6 @@ brain before. \`memgineering recall --local\` still reads the folder.`
12322
12524
  )
12323
12525
  );
12324
12526
  }
12325
- var isNote = (p) => /\.(md|markdown)$/i.test(p);
12326
- function heldBack(scan) {
12327
- const denied = scan.denied ?? [];
12328
- const byName = denied.filter((d) => d.kind === "name" && isNote(d.path)).map((d) => ({ path: d.path, term: d.term }));
12329
- const explicitly = denied.filter((d) => d.kind === "explicit" && isNote(d.path)).map((d) => d.path);
12330
- const byRule = scan.excluded.filter(isNote);
12331
- const symlinked = scan.skippedSymlinks.filter(isNote);
12332
- return {
12333
- total: byName.length + explicitly.length + byRule.length + symlinked.length,
12334
- byName,
12335
- explicitly,
12336
- byRule,
12337
- symlinked
12338
- };
12339
- }
12340
- function heldBackJson(held) {
12341
- return {
12342
- not_sent: held.total,
12343
- denied_by_name: held.byName,
12344
- denied_explicitly: held.explicitly,
12345
- excluded_by_rule: held.byRule,
12346
- skipped_symlinks: held.symlinked
12347
- };
12348
- }
12349
- function heldBackGroups(held) {
12350
- return [
12351
- {
12352
- label: "name looks private",
12353
- rows: held.byName.map((d) => ({ path: d.path, detail: `matched "${d.term ?? "?"}"` }))
12354
- },
12355
- { label: "listed in .memgdeny", rows: held.explicitly.map((p) => ({ path: p, detail: null })) },
12356
- { label: "excluded by a rule", rows: held.byRule.map((p) => ({ path: p, detail: null })) },
12357
- {
12358
- label: "a symlink, which is never followed",
12359
- rows: held.symlinked.map((p) => ({ path: p, detail: null }))
12360
- }
12361
- ];
12362
- }
12363
- function describeHeldBack(held) {
12364
- return heldBackGroups(held).flatMap(
12365
- ({ label, rows }) => rows.map(
12366
- (r) => `${stripControl(r.path)} \u2014 ${label}${r.detail === null ? "" : ` (${stripControl(r.detail)})`}`
12367
- )
12368
- );
12369
- }
12370
- function printHeldBack(held) {
12371
- if (held.total === 0) return;
12372
- printHuman(
12373
- c.yellow(`
12374
- \u26A0 ${held.total} note(s) held back on this machine \u2014 they never reach the brain:`)
12375
- );
12376
- for (const { label, rows } of heldBackGroups(held)) {
12377
- if (rows.length === 0) continue;
12378
- printHuman(c.gray(` ${rows.length} \u2014 ${label}:`));
12379
- for (const r of rows.slice(0, 10)) {
12380
- printHuman(
12381
- c.gray(
12382
- ` ${stripControl(r.path)}${r.detail === null ? "" : ` (${stripControl(r.detail)})`}`
12383
- )
12384
- );
12385
- }
12386
- if (rows.length > 10) printHuman(c.gray(` \u2026 and ${rows.length - 10} more`));
12387
- }
12388
- if (held.byName.length > 0) {
12389
- printHuman(
12390
- c.gray(
12391
- " The name list is a GUESS and this may be a false alarm \u2014 rename the note,\n or list it in .memgallow, and push again."
12392
- )
12393
- );
12394
- }
12395
- printHuman(
12396
- c.gray(
12397
- " Counted per file. A rule that excludes a whole FOLDER stops the walk there,\n so notes inside one are not listed above."
12398
- )
12399
- );
12400
- }
12401
12527
  async function resolveDestination(named, root) {
12402
12528
  const base = apiUrl();
12403
12529
  const credentials = await requireCredentials(base);
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "memgineering",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "private": false,
5
- "description": "One memory for the AI you connect. Recall, remember, and revise a brain your agents share stored in your own folder.",
5
+ "description": "One memory for the AI you connect. Recall, remember, and revise a brain your agents share \u2014 stored in your own folder.",
6
6
  "license": "Apache-2.0",
7
7
  "homepage": "https://memgineering.com",
8
8
  "repository": {