memgineering 0.7.3 → 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,116 @@ 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
+
101
+ ## [0.7.4] — 2026-08-19
102
+
103
+ ### Changed
104
+
105
+ - **Signing in now asks the person to agree, and an account is not created until
106
+ they do.** The approval screen carries three required consents — the terms,
107
+ the handling of personal data, and being 14 or older — as three separate
108
+ boxes, because they are three separate statements. An agent can run every
109
+ other step of a setup; it cannot answer these, and `login` now says so in both
110
+ its human output and its `next` hint so the agent stops and hands the question
111
+ over.
112
+
113
+ - **The terms and the privacy policy exist**, served by the brain server at
114
+ `/terms` and `/privacy` and linked from the approval screen. The privacy
115
+ policy was written against the database schema rather than from a template:
116
+ it names what is stored, what is not (IP addresses, request URLs, auth
117
+ headers), that the embedding model runs on the server so notes reach no third
118
+ party, and that recall queries are kept for 90 days. A test fails if a table
119
+ holding something a person wrote stops being described there.
120
+
121
+ This arrives with the server, not with this package — an older CLI reaches the
122
+ same screen and gets the same gate.
123
+
14
124
  ## [0.7.3] — 2026-08-18
15
125
 
16
126
  ### Fixed
@@ -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.3
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")) {
@@ -4864,7 +4895,7 @@ function emit(pending, ctx) {
4864
4895
  browser_opened: ctx.opened,
4865
4896
  api_url: ctx.base,
4866
4897
  waiting: ctx.blocking,
4867
- next: ctx.blocking ? "Waiting for approval in the browser." : "SHOW user_code to the user in your reply, then run any memgineering command \u2014 the token is claimed automatically once they approve."
4898
+ next: ctx.blocking ? "Waiting for approval in the browser." : "SHOW user_code to the user in your reply, then run any memgineering command \u2014 the token is claimed automatically once they approve. The approval screen asks THEM to agree to the terms and the privacy policy and to confirm they are 14 or older; you cannot answer that for them, and an account is not created until they do."
4868
4899
  },
4869
4900
  human: () => {
4870
4901
  printHuman("Show this code to whoever is signing in:\n");
@@ -4876,6 +4907,11 @@ function emit(pending, ctx) {
4876
4907
  printHuman("Open this to approve:\n");
4877
4908
  printHuman(` ${c.cyan(stripControl(pending.verification_url))}`);
4878
4909
  }
4910
+ printHuman(
4911
+ c.gray(
4912
+ "\nThey agree to the terms and the privacy policy there, and confirm they are 14 or older.\nNo account is created until they do."
4913
+ )
4914
+ );
4879
4915
  if (ctx.opened && ctx.showUrl) {
4880
4916
  printHuman(c.gray(` ${stripControl(pending.verification_url)}`));
4881
4917
  }
@@ -8270,6 +8306,120 @@ async function createCloudBrain(token, baseUrl, name) {
8270
8306
  // src/commands/brain-admin.ts
8271
8307
  init_config();
8272
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
+
8273
8423
  // src/lib/ledger.ts
8274
8424
  init_deny();
8275
8425
  init_brand();
@@ -8408,6 +8558,13 @@ var RULE_VERSION = 2;
8408
8558
  var RATIONALE_MAX = 500;
8409
8559
  function sanitizeRationale(raw) {
8410
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
+ }
8411
8568
  const clean = stripControl(raw).trim();
8412
8569
  if (clean === "") return null;
8413
8570
  const hits = secretContentHits(clean);
@@ -8598,14 +8755,17 @@ function reindexCommand() {
8598
8755
  protectedVault: true
8599
8756
  });
8600
8757
  const scan = await adapter.scan();
8601
- const nameDenied = (scan.denied ?? []).filter((d) => d.kind !== "explicit");
8758
+ const held = heldBack(scan);
8602
8759
  printDual({
8603
8760
  json: {
8604
8761
  brain: brain.root,
8605
8762
  indexed: built.entries.length,
8606
8763
  cache_dir: built.cacheDir,
8607
8764
  issues: built.issues,
8608
- 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"),
8609
8769
  denied_by_content: built.denied.filter((d) => d.reason === "content")
8610
8770
  },
8611
8771
  human: () => {
@@ -8618,14 +8778,21 @@ function reindexCommand() {
8618
8778
  )
8619
8779
  );
8620
8780
  }
8781
+ printHeldBack(held, { where: "the index", retry: "reindex" });
8621
8782
  const byContent = built.denied.filter((d) => d.reason === "content");
8622
- for (const [label, rows] of [
8623
- ["name looks private", nameDenied.map((d) => `${d.path} (matched "${d.term}")`)],
8624
- ["content looks like a credential", byContent.map((d) => `${d.path} (${d.detail})`)]
8625
- ]) {
8626
- if (rows.length === 0) continue;
8627
- printHuman(c.yellow(` ${rows.length} note(s) refused \u2014 ${label}:`));
8628
- 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
+ }
8629
8796
  }
8630
8797
  }
8631
8798
  });
@@ -10061,11 +10228,17 @@ function proposalsCommand() {
10061
10228
  opts.all === true ? { all: true } : {}
10062
10229
  ) : await listLocally(where.brain.root, opts.all === true);
10063
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
+ );
10064
10237
  printDual({
10065
10238
  json: {
10066
- proposals: shown,
10067
- count: shown.length,
10068
- 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)
10069
10242
  },
10070
10243
  human: () => {
10071
10244
  if (shown.length === 0) {
@@ -10106,6 +10279,7 @@ function summarize(p) {
10106
10279
  action: p.action,
10107
10280
  memory_id: p.memory_id,
10108
10281
  target: p.target.path,
10282
+ claim: p.claim?.text ?? null,
10109
10283
  reason: p.reason,
10110
10284
  proposed_kind: p.proposed_kind,
10111
10285
  supersedes: p.relations.supersedes,
@@ -10113,12 +10287,24 @@ function summarize(p) {
10113
10287
  expires_at: p.expiresAt
10114
10288
  };
10115
10289
  }
10290
+ var CLAIM_WIDTH = 160;
10116
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;
10117
10295
  const lines2 = [
10118
10296
  `- **${stripControl(p.action)}**${p.proposed_kind ? ` \u2192 ${p.proposed_kind}` : ""} \`${stripControl(p.target)}\``,
10119
- ` ${p.id} ${p.state}`,
10120
- quoteBlock(p.reason, " > ")
10297
+ ` ${p.id} ${p.state}`
10121
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, " > "));
10122
10308
  if (p.supersedes.length > 0) {
10123
10309
  lines2.push(c.gray(` replaces: ${p.supersedes.map(stripControl).join(", ")}`));
10124
10310
  }
@@ -10466,14 +10652,25 @@ function consolidateCommand() {
10466
10652
  });
10467
10653
  });
10468
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
+ }
10469
10666
  function renderCandidate(candidate) {
10470
10667
  const lines2 = [
10471
10668
  `- **${stripControl(candidate.rule)}** \u2014 ${stripControl(candidate.evidence)}`,
10472
- ` ${stripControl(candidate.memory_id)} \`${stripControl(candidate.path)}\``
10669
+ ...noteLines(candidate.memory_id, candidate.title, candidate.path)
10473
10670
  ];
10474
10671
  if (candidate.other_id !== null) {
10475
10672
  lines2.push(
10476
- ` ${stripControl(candidate.other_id)} \`${stripControl(candidate.other_path ?? "")}\``
10673
+ ...noteLines(candidate.other_id, candidate.other_title ?? "", candidate.other_path ?? "")
10477
10674
  );
10478
10675
  }
10479
10676
  lines2.push(
@@ -12142,6 +12339,11 @@ init_api_client();
12142
12339
  import { Command as Command17 } from "commander";
12143
12340
  init_vault();
12144
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);
12145
12347
  function pushCommand() {
12146
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) => {
12147
12349
  const { brain: local } = await resolveBrain(opts.vault);
@@ -12150,11 +12352,16 @@ function pushCommand() {
12150
12352
  const paths = scan.files.filter(isNote);
12151
12353
  const held = heldBack(scan);
12152
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");
12153
12357
  throw memgError(
12154
12358
  "invalid_input",
12155
12359
  `there is nothing to upload in ${local.root}`,
12156
- 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:
12157
- ` + 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."
12158
12365
  );
12159
12366
  }
12160
12367
  if (opts.dryRun) {
@@ -12174,7 +12381,7 @@ function pushCommand() {
12174
12381
  `);
12175
12382
  for (const path of paths.slice(0, 20)) printHuman(c.gray(` ${path}`));
12176
12383
  if (paths.length > 20) printHuman(c.gray(` \u2026 and ${paths.length - 20} more`));
12177
- printHeldBack(held);
12384
+ printHeldBack2(held);
12178
12385
  printHuman(
12179
12386
  c.gray(
12180
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."
@@ -12279,7 +12486,7 @@ function pushCommand() {
12279
12486
  printHuman(c.gray(` \u2026 and ${refused.length - 10} more`));
12280
12487
  }
12281
12488
  }
12282
- printHeldBack(held);
12489
+ printHeldBack2(held);
12283
12490
  printRepoint(repointed, destination);
12284
12491
  printHuman(
12285
12492
  c.gray(
@@ -12317,82 +12524,6 @@ brain before. \`memgineering recall --local\` still reads the folder.`
12317
12524
  )
12318
12525
  );
12319
12526
  }
12320
- var isNote = (p) => /\.(md|markdown)$/i.test(p);
12321
- function heldBack(scan) {
12322
- const denied = scan.denied ?? [];
12323
- const byName = denied.filter((d) => d.kind === "name" && isNote(d.path)).map((d) => ({ path: d.path, term: d.term }));
12324
- const explicitly = denied.filter((d) => d.kind === "explicit" && isNote(d.path)).map((d) => d.path);
12325
- const byRule = scan.excluded.filter(isNote);
12326
- const symlinked = scan.skippedSymlinks.filter(isNote);
12327
- return {
12328
- total: byName.length + explicitly.length + byRule.length + symlinked.length,
12329
- byName,
12330
- explicitly,
12331
- byRule,
12332
- symlinked
12333
- };
12334
- }
12335
- function heldBackJson(held) {
12336
- return {
12337
- not_sent: held.total,
12338
- denied_by_name: held.byName,
12339
- denied_explicitly: held.explicitly,
12340
- excluded_by_rule: held.byRule,
12341
- skipped_symlinks: held.symlinked
12342
- };
12343
- }
12344
- function heldBackGroups(held) {
12345
- return [
12346
- {
12347
- label: "name looks private",
12348
- rows: held.byName.map((d) => ({ path: d.path, detail: `matched "${d.term ?? "?"}"` }))
12349
- },
12350
- { label: "listed in .memgdeny", rows: held.explicitly.map((p) => ({ path: p, detail: null })) },
12351
- { label: "excluded by a rule", rows: held.byRule.map((p) => ({ path: p, detail: null })) },
12352
- {
12353
- label: "a symlink, which is never followed",
12354
- rows: held.symlinked.map((p) => ({ path: p, detail: null }))
12355
- }
12356
- ];
12357
- }
12358
- function describeHeldBack(held) {
12359
- return heldBackGroups(held).flatMap(
12360
- ({ label, rows }) => rows.map(
12361
- (r) => `${stripControl(r.path)} \u2014 ${label}${r.detail === null ? "" : ` (${stripControl(r.detail)})`}`
12362
- )
12363
- );
12364
- }
12365
- function printHeldBack(held) {
12366
- if (held.total === 0) return;
12367
- printHuman(
12368
- c.yellow(`
12369
- \u26A0 ${held.total} note(s) held back on this machine \u2014 they never reach the brain:`)
12370
- );
12371
- for (const { label, rows } of heldBackGroups(held)) {
12372
- if (rows.length === 0) continue;
12373
- printHuman(c.gray(` ${rows.length} \u2014 ${label}:`));
12374
- for (const r of rows.slice(0, 10)) {
12375
- printHuman(
12376
- c.gray(
12377
- ` ${stripControl(r.path)}${r.detail === null ? "" : ` (${stripControl(r.detail)})`}`
12378
- )
12379
- );
12380
- }
12381
- if (rows.length > 10) printHuman(c.gray(` \u2026 and ${rows.length - 10} more`));
12382
- }
12383
- if (held.byName.length > 0) {
12384
- printHuman(
12385
- c.gray(
12386
- " 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."
12387
- )
12388
- );
12389
- }
12390
- printHuman(
12391
- c.gray(
12392
- " Counted per file. A rule that excludes a whole FOLDER stops the walk there,\n so notes inside one are not listed above."
12393
- )
12394
- );
12395
- }
12396
12527
  async function resolveDestination(named, root) {
12397
12528
  const base = apiUrl();
12398
12529
  const credentials = await requireCredentials(base);
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "memgineering",
3
- "version": "0.7.3",
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": {