memgineering 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,50 @@ language the reader wants. The bilingual rule the monorepo applies to
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.23.0] — 2026-09-09
15
+
16
+ ### Added
17
+
18
+ - **`revise <ref> --no-rule` on its own now takes a marking back and asks for
19
+ nothing else.** It used to require `--claim`, because `revise` is a verb
20
+ about conclusions — so unbinding a rule meant restating a conclusion that had
21
+ not changed, from text `open` hands back truncated, with `--summary`
22
+ defaulting to whatever you passed. Unbinding thirteen rules on a real hosted
23
+ brain was only possible by reading the note bodies out of the database. The
24
+ conclusion, the summary and the title now come back byte for byte, and
25
+ `undo` reverses it. Adding a claim, a summary, a title, a date, an explicit
26
+ `--action` or a boundary word still makes it an ordinary revise —
27
+ except `--action reinforce`, which means "the same conclusion" and is
28
+ therefore what unbinding already says.
29
+
30
+ ### Changed
31
+
32
+ - **`consolidate` no longer assumes which way an unclassified rule should
33
+ go.** A memory marked as binding with no kind was answered "raise this to a
34
+ decision". Measured on a real brain, 55 of 57 standing rules had no kind and
35
+ thirteen of them were not rules at all — every one was told to become a
36
+ decision, which would have made the mistake harder to see. Such a note is now
37
+ reported as `binding_unclassified` and names both moves: classify it, or stop
38
+ it binding. Only somebody who reads the note can say which.
39
+ - **`consolidate` shares its page between the signals it found.** One rule used
40
+ to take the whole list — 55 of 57 candidates — so a duplicate and a
41
+ contradiction underneath got one line each, and raising `--limit` returned
42
+ more of the same rule. Candidates now alternate across the rules that found
43
+ something, and the answer carries `by_rule` totals, which the page itself can
44
+ no longer tell you.
45
+ - **`rules` against a hosted brain now lists the rules scoped to other
46
+ projects**, under their own heading, as the local one already did. They are
47
+ still never shown to an agent working here. Without the list there was no
48
+ surface anywhere on which to notice a scope with a typo in it — such a rule
49
+ binds nothing and appears nowhere.
50
+
51
+ ### Agent guidance
52
+
53
+ `memgineering-rules` and `memgineering-writing` both changed: how to take a
54
+ marking back and how it differs from `retire`, the two budgets no longer
55
+ crowding out project rules, and the new consolidation signals. Restart your
56
+ agent session after upgrading so it reads them.
57
+
14
58
  ## [0.22.0] — 2026-09-09
15
59
 
16
60
  ### Added
@@ -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.22.0
5
+ version: 0.23.0
6
6
  ---
7
7
 
8
8
  # memgineering
@@ -146,11 +146,19 @@ a long list is fewer rules, not a longer one.
146
146
 
147
147
  Two consequences worth knowing before you mark things:
148
148
 
149
- - **Global habits crowd out project decisions.** A rule that has been recalled
150
- in other folders outranks a project rule written yesterday that nobody has
151
- reached for. If a user's own working preferences fill all eight slots, a new
152
- a rule written for the folder they are in may never be shown. Keep the
153
- standing set small.
154
- - **A retired rule stops being shown.** `memgineering retire <ref>` is the way
155
- out when a decision no longer holds it stays visible in recall, ranked last
156
- and labelled, but it stops binding.
149
+ - **Global habits used to crowd out project decisions**, and the two budgets
150
+ are why they no longer can: a rule scoped to the project has its own room and
151
+ cannot lose every slot to whatever the user has been reaching for lately. It
152
+ still pays to keep the standing set small ten is what a session reads.
153
+ - **Two ways out, and they are not the same.** `memgineering revise <ref>
154
+ --no-rule --reason "…"` says it was never a rule: it stops being shown before
155
+ an edit, and stays a perfectly ordinary current memory that recall returns.
156
+ `memgineering retire <ref>` says it no longer holds: it stops binding AND
157
+ drops to the bottom of recall, labelled. Reach for the first when the marking
158
+ was the mistake and the second when the decision has been overtaken.
159
+ - **`--no-rule` on its own asks for nothing else.** No claim, no summary — the
160
+ conclusion, the summary and the title come back byte for byte, which is the
161
+ whole reason it exists. It used to go through `revise`, which requires a
162
+ claim, so taking a marking back meant restating a conclusion nobody had
163
+ changed, from text this CLI hands back truncated. Do not restate anything;
164
+ `memgineering undo` reverses it.
@@ -235,19 +235,30 @@ it is about without opening them. It does not carry their claims, so deciding
235
235
  which of a pair is right still means reading them: two notes can share a title
236
236
  and hold opposite conclusions, which is exactly the case a proposal exists for.
237
237
  It returns them with the evidence that caught them and the action it would
238
- suggest, under seven rules: `declared_supersession`, a replaced note still
238
+ suggest, under eight rules: `declared_supersession`, a replaced note still
239
239
  active; `contradiction`, a disagreement nobody settled; `semantic_similarity`,
240
240
  indexed meaning agreeing reciprocally; `shared_context`, one scope naming the
241
- same thing; `similar_title`; and the two directions of one disagreement between
242
- a note's `binding` flag and its `kind` `kind_promotion`, a binding note with
243
- no kind at all, where the marking was deliberate and the classification never
244
- happened; and `rule_demotion`, a binding note the author classified as a fact,
245
- a procedure, an episode or a goal. That second one is two deliberate fields
246
- contradicting each other, and the marking is the one to doubt: measured on a
247
- real brain, 13 of 54 binding notes were a fact, a piece of state or a procedure,
248
- each standing over every session as a decision nobody made. Its suggestion is
249
- `memgineering revise <id> --no-rule --reason "…"`, which already exists — there
250
- is no new verb for taking a marking back. Semantic detection reuses a complete, calibrated hosted
241
+ same thing; `similar_title`; and three that read a note against itself, where
242
+ its `binding` flag and its `kind` disagree. `rule_demotion` is a binding note
243
+ the author classified as a fact, a procedure, an episode or a goal two
244
+ deliberate fields contradicting each other, and the marking is the one to
245
+ doubt. `binding_unclassified` is a binding note with no kind at all, and it
246
+ names BOTH moves rather than picking one, because the tool reads fields and not
247
+ prose: it is either a decision nobody classified or something that was never a
248
+ rule, and only somebody who reads it can say which. (`kind_promotion` remains
249
+ in the vocabulary and no longer fires.)
250
+
251
+ That neutrality was measured rather than chosen for balance. On a real brain,
252
+ 55 of 57 binding notes had no kind and every one was answered "promote this to
253
+ a decision" — including the thirteen that turned out not to be rules, where
254
+ approving the suggestion would have hardened the mistake.
255
+
256
+ **The page is shared between the signals.** One rule used to take the whole
257
+ list — 55 of 57 candidates — so the duplicate and the contradiction underneath
258
+ got a line each. Candidates now round-robin across the rules that found
259
+ something, and `by_rule` reports how many each found in total, which the page
260
+ can no longer tell you. Raising `--limit` widens the page rather than returning
261
+ more of one rule. Semantic detection reuses a complete, calibrated hosted
251
262
  vector generation and never calls a model during consolidation. Authored
252
263
  scope/entity context permits reciprocal neighbours; without it, evidence must
253
264
  form a stricter three-note semantic triangle.
package/dist/index.js CHANGED
@@ -2235,8 +2235,11 @@ function isLifecycleAction(action) {
2235
2235
  function isClassifyAction(action) {
2236
2236
  return action === "reclassify";
2237
2237
  }
2238
+ function isBindingAction(action) {
2239
+ return action === "unbind";
2240
+ }
2238
2241
  function carriesNoClaim(action) {
2239
- return isLifecycleAction(action) || isClassifyAction(action);
2242
+ return isLifecycleAction(action) || isClassifyAction(action) || isBindingAction(action);
2240
2243
  }
2241
2244
  function isAccepted(value) {
2242
2245
  return typeof value === "object" && value !== null && value[ACCEPTED] === true;
@@ -2265,6 +2268,21 @@ var init_schema2 = __esm({
2265
2268
  "retire",
2266
2269
  "unretire",
2267
2270
  "reclassify",
2271
+ /**
2272
+ * A memory stops binding. Nothing else about it changes.
2273
+ *
2274
+ * It exists because there was no way to do this without saying something
2275
+ * else. Taking a marking back meant `revise --claim`, which is a verb about
2276
+ * conclusions — so unbinding thirteen rules on a real hosted brain required
2277
+ * restating thirteen conclusions the author had not changed, from text the
2278
+ * CLI hands back TRUNCATED. The person doing it read the note bodies out of
2279
+ * the database to avoid corrupting them, which is not a thing a user can do.
2280
+ *
2281
+ * One direction on purpose. `unbind` that could also bind would be a name
2282
+ * that lies half the time; marking something as a rule is already reachable
2283
+ * (`remember --rule`, or a `revise` that is genuinely saying something new).
2284
+ */
2285
+ "unbind",
2268
2286
  "ignore"
2269
2287
  ]);
2270
2288
  StorableActionSchema = ProposalActionSchema.exclude(["ignore"]);
@@ -2460,7 +2478,7 @@ var init_schema2 = __esm({
2460
2478
  path: ["source_excerpt"]
2461
2479
  }
2462
2480
  ).refine((p) => carriesNoClaim(p.action) || p.claim !== null, {
2463
- message: "only retire, unretire and reclassify may omit the claim",
2481
+ message: "only retire, unretire, reclassify and unbind may omit the claim",
2464
2482
  path: ["claim"]
2465
2483
  }).refine((p) => isClassifyAction(p.action) === (p.proposed_kind !== null), {
2466
2484
  message: "proposed_kind belongs to reclassify, and reclassify requires one",
@@ -2496,12 +2514,42 @@ function detectConsolidation(entries, opts = {}) {
2496
2514
  ...titles
2497
2515
  ];
2498
2516
  const out = rankCandidates(scored);
2517
+ const limit = opts.limit ?? DEFAULT_LIMIT;
2499
2518
  return {
2500
- candidates: out.slice(0, opts.limit ?? DEFAULT_LIMIT),
2519
+ candidates: shareTheList(out, limit),
2520
+ by_rule: countByRule(out),
2501
2521
  scanned: budget.scanned,
2502
2522
  truncated: budget.truncated
2503
2523
  };
2504
2524
  }
2525
+ function shareTheList(ranked, limit) {
2526
+ const lanes = /* @__PURE__ */ new Map();
2527
+ for (const candidate of ranked) {
2528
+ const lane = lanes.get(candidate.rule);
2529
+ if (lane) lane.push(candidate);
2530
+ else lanes.set(candidate.rule, [candidate]);
2531
+ }
2532
+ const out = [];
2533
+ const queues = [...lanes.values()];
2534
+ let round = 0;
2535
+ for (; ; ) {
2536
+ let added = false;
2537
+ for (const queue of queues) {
2538
+ if (out.length >= limit) return out;
2539
+ const next = queue[round];
2540
+ if (next === void 0) continue;
2541
+ out.push(next);
2542
+ added = true;
2543
+ }
2544
+ if (!added) return out;
2545
+ round += 1;
2546
+ }
2547
+ }
2548
+ function countByRule(ranked) {
2549
+ const out = {};
2550
+ for (const candidate of ranked) out[candidate.rule] = (out[candidate.rule] ?? 0) + 1;
2551
+ return out;
2552
+ }
2505
2553
  function titleTerms(title) {
2506
2554
  const out = /* @__PURE__ */ new Set();
2507
2555
  for (const term of terms(title)) {
@@ -2854,16 +2902,16 @@ function kindPromotions(entries) {
2854
2902
  if (kind === void 0) {
2855
2903
  out.push({
2856
2904
  candidate: {
2857
- rule: "kind_promotion",
2905
+ rule: "binding_unclassified",
2858
2906
  memory_id: entry.memory.id,
2859
2907
  path: entry.path,
2860
2908
  title: entry.memory.title,
2861
2909
  other_id: null,
2862
2910
  other_path: null,
2863
2911
  other_title: null,
2864
- evidence: "marked as binding but left unclassified \u2014 a memory that binds is a decision",
2865
- suggested_action: "reclassify",
2866
- suggested_kind: "decision"
2912
+ evidence: "marked as binding and left unclassified \u2014 either a decision nobody classified, or something that was never a rule",
2913
+ suggested_action: "decide",
2914
+ suggested_kind: null
2867
2915
  },
2868
2916
  confidence: 4
2869
2917
  });
@@ -2999,7 +3047,8 @@ var init_detect = __esm({
2999
3047
  "shared_context",
3000
3048
  "similar_title",
3001
3049
  "kind_promotion",
3002
- "rule_demotion"
3050
+ "rule_demotion",
3051
+ "binding_unclassified"
3003
3052
  ];
3004
3053
  DEFAULT_LIMIT = 50;
3005
3054
  DEFAULT_PAIR_BUDGET = 2e5;
@@ -3252,12 +3301,12 @@ function commit(prepared, input, opts = {}) {
3252
3301
  if (carriesNoClaim(parsed.action)) {
3253
3302
  if (parsed.claim !== null) {
3254
3303
  throw Object.assign(new Error(`${parsed.action} does not take a claim`), {
3255
- hint: isClassifyAction(parsed.action) ? `${parsed.action} says what sort of memory this is, not what it says. To change the conclusion too, use supersede.` : `${parsed.action} changes whether a memory is current, not what it says. To change the conclusion too, use supersede.`
3304
+ hint: isClassifyAction(parsed.action) ? `${parsed.action} says what sort of memory this is, not what it says. To change the conclusion too, use supersede.` : isBindingAction(parsed.action) ? `${parsed.action} takes a marking back and leaves the conclusion exactly as it is \u2014 that is the whole reason it exists. To change what the note says as well, use supersede.` : `${parsed.action} changes whether a memory is current, not what it says. To change the conclusion too, use supersede.`
3256
3305
  });
3257
3306
  }
3258
3307
  } else if (parsed.claim === null) {
3259
3308
  throw Object.assign(new Error(`${parsed.action} needs a claim`), {
3260
- hint: "Only retire, unretire and reclassify may omit it. They change a memory's standing or its classification rather than its content."
3309
+ hint: "Only retire, unretire, reclassify and unbind may omit it. They change a memory's standing, its classification or whether it binds, rather than its content."
3261
3310
  });
3262
3311
  } else if ((parsed.action === "supersede" || parsed.action === "create") && parsed.claim.summary === null) {
3263
3312
  throw Object.assign(new Error(`${parsed.action} needs a summary as well as a claim`), {
@@ -3313,10 +3362,10 @@ var init_commit = __esm({
3313
3362
  */
3314
3363
  memory_id: z5.string().min(1).nullable().default(null),
3315
3364
  /**
3316
- * Omitted by `retire` / `unretire` / `reclassify`, which change a memory's
3317
- * standing or its classification rather than what it says. Checked below with
3318
- * the other action rules so the error can name the action instead of pointing
3319
- * at a field.
3365
+ * Omitted by `retire` / `unretire` / `reclassify` / `unbind`, which change a
3366
+ * memory's standing, its classification or whether it binds rather than
3367
+ * what it says. Checked below with the other action rules so the error can
3368
+ * name the action instead of pointing at a field.
3320
3369
  */
3321
3370
  claim: ProposedClaimSchema.nullable().default(null),
3322
3371
  /** What `reclassify` is reclassifying this memory AS. Only that action takes one. */
@@ -3421,6 +3470,16 @@ function reclassifyInput(entry, kind, opts = {}) {
3421
3470
  target_path: entry.path
3422
3471
  };
3423
3472
  }
3473
+ function unbindInput(entry, opts = {}) {
3474
+ return {
3475
+ action: "unbind",
3476
+ memory_id: entry.memory.id,
3477
+ claim: null,
3478
+ reason: opts.reason ?? "no longer binding",
3479
+ source_excerpt: opts.because ?? entry.memory.summary ?? entry.memory.title,
3480
+ target_path: entry.path
3481
+ };
3482
+ }
3424
3483
  function saysNothingNew(entry, input, normalize) {
3425
3484
  const norm = (v) => normalize(v ?? "").trim();
3426
3485
  const claim = input.claim;
@@ -3510,6 +3569,10 @@ function desiredFields(proposal, existing) {
3510
3569
  out["kind"] = proposal.proposed_kind;
3511
3570
  return out;
3512
3571
  }
3572
+ if (isBindingAction(proposal.action)) {
3573
+ delete out["binding"];
3574
+ return out;
3575
+ }
3513
3576
  const claim = proposal.claim;
3514
3577
  if (claim === null) {
3515
3578
  throw new Error(`${proposal.action} reached the patcher with no claim`);
@@ -3807,6 +3870,46 @@ function verifyPatch(patched, proposal, before) {
3807
3870
  }
3808
3871
  return;
3809
3872
  }
3873
+ if (isBindingAction(proposal.action)) {
3874
+ if (mapped.memory.binding === true) {
3875
+ throw new PatchError(
3876
+ "the patched note still reads as binding",
3877
+ "This is a defect in the patcher, not in your note. Nothing was written."
3878
+ );
3879
+ }
3880
+ if (before === void 0) {
3881
+ throw new PatchError(
3882
+ `${proposal.action} cannot be verified without the note it started from`,
3883
+ "This is a defect in the caller, not in your note. Nothing was written."
3884
+ );
3885
+ }
3886
+ const was = mapNoteToMemory({ path: proposal.target.path, content: before, revision: null });
3887
+ if (was.claims[0]?.text !== mapped.claims[0]?.text) {
3888
+ throw new PatchError(
3889
+ `${proposal.action} changed the conclusion, which it must never do`,
3890
+ "Taking a marking back leaves what the note says exactly as it was. Nothing was written."
3891
+ );
3892
+ }
3893
+ if (was.memory.summary !== mapped.memory.summary) {
3894
+ throw new PatchError(
3895
+ `${proposal.action} changed the summary, which it must never do`,
3896
+ "Taking a marking back leaves what the note says exactly as it was \u2014 and the summary is what recall matches on. Nothing was written."
3897
+ );
3898
+ }
3899
+ if (was.memory.title !== mapped.memory.title) {
3900
+ throw new PatchError(
3901
+ `${proposal.action} changed the title, which it must never do`,
3902
+ "Taking a marking back leaves what the note says exactly as it was. Nothing was written."
3903
+ );
3904
+ }
3905
+ if (was.memory.status !== mapped.memory.status) {
3906
+ throw new PatchError(
3907
+ `${proposal.action} changed the status, which it must never do`,
3908
+ "A memory that no longer binds is still current. To take it out of the answers as well, use retire. Nothing was written."
3909
+ );
3910
+ }
3911
+ return;
3912
+ }
3810
3913
  const supersedes = new Set(
3811
3914
  mapped.relations.filter((r) => r.type === "supersedes").map((r) => r.to)
3812
3915
  );
@@ -4709,7 +4812,7 @@ async function createCloudBrain(token, baseUrl, name) {
4709
4812
  });
4710
4813
  return res.body;
4711
4814
  }
4712
- var call, askedFromField, cloudRecall, cloudOpen, cloudEvidence, cloudRemember, cloudUndo, cloudLog, cloudPutNote, cloudListNotes, cloudRevise, cloudBaseWrite, cloudLifecycle, cloudExclude, RANKED_BY_PROJECT, rankedOnProject, cloudResurface, cloudRules, cloudReindex;
4815
+ var call, askedFromField, cloudRecall, cloudOpen, cloudEvidence, cloudRemember, cloudUndo, cloudLog, cloudPutNote, cloudListNotes, cloudRevise, cloudBaseWrite, cloudLifecycle, cloudUnbind, cloudExclude, RANKED_BY_PROJECT, rankedOnProject, cloudResurface, cloudRules, cloudReindex;
4713
4816
  var init_cloud_brain = __esm({
4714
4817
  "src/lib/cloud-brain.ts"() {
4715
4818
  "use strict";
@@ -4799,6 +4902,10 @@ var init_cloud_brain = __esm({
4799
4902
  ref: args.ref,
4800
4903
  ...args.reason === void 0 ? {} : { reason: args.reason }
4801
4904
  });
4905
+ cloudUnbind = (brain, args) => call(brain, "POST", "/unbind", {
4906
+ ref: args.ref,
4907
+ ...args.reason === void 0 ? {} : { reason: args.reason }
4908
+ });
4802
4909
  cloudExclude = (brain, mode, args) => call(brain, "POST", `/${mode}`, {
4803
4910
  path: args.path,
4804
4911
  ...args.reason === void 0 ? {} : { reason: args.reason }
@@ -12946,10 +13053,13 @@ function yamlString(value) {
12946
13053
 
12947
13054
  // src/commands/revise.ts
12948
13055
  function reviseCommand() {
13056
+ const said = { action: false };
12949
13057
  return new Command7("revise").description("change a memory's conclusion, and record how to undo it").argument("<ref>", "handle, id, path, or exact title").option("--claim <text>", "what is now true").option("--summary <text>", "the one line recall will show (defaults to the claim)").option("--title <text>", "the heading recall shows above it (unchanged when omitted)").option(
12950
13058
  "--valid-from <iso>",
12951
13059
  "when this became true (defaults: supersede = now, otherwise unchanged)"
12952
- ).option("--action <kind>", "reinforce | supersede | conflict", "supersede").option("--rule", "this memory is now a decision that binds, not a fact to look up").option("--no-rule", "it no longer binds \u2014 an ordinary memory again").option("--never-store <words>", "replace this note\u2019s boundary words (comma-separated)").option("--no-never-store", "this note no longer declares a boundary").option(
13060
+ ).option("--action <kind>", "reinforce | supersede | conflict", "supersede").on("option:action", () => {
13061
+ said.action = true;
13062
+ }).option("--rule", "this memory is now a decision that binds, not a fact to look up").option("--no-rule", "it no longer binds \u2014 an ordinary memory again").option("--never-store <words>", "replace this note\u2019s boundary words (comma-separated)").option("--no-never-store", "this note no longer declares a boundary").option(
12953
13063
  "--never-store-add <words>",
12954
13064
  "add words to this note\u2019s boundary, keeping the ones already there"
12955
13065
  ).option("--contradicts <ids...>", "memories this disagrees with").option("--reason <text>", "why this change is right").option("--because <text>", "the passage from the note that justifies it").option("--input <json>", "the whole change as JSON (file path, `-`, or inline)").option("--dry-run", "show the diff and write nothing").option("--vault <path>", "which brain to write to").option("--local", "use the brain on this machine, even when signed in to a hosted one").addHelpText(
@@ -12969,11 +13079,24 @@ function reviseCommand() {
12969
13079
  ].join("\n")
12970
13080
  ).action(async (ref, opts) => {
12971
13081
  const added = boundaryAddition(opts);
13082
+ const bareUnbind = opts.rule === false && // `reinforce` is the one action that AGREES with unbinding: it means
13083
+ // "the same conclusion", which is exactly what is being said. A fresh
13084
+ // agent reaches for it unprompted — measured in the Round, the first
13085
+ // session typed `--no-rule --action reinforce`, was refused for
13086
+ // having no claim, and then supplied one, writing a `claim:` key the
13087
+ // note never had. That is the failure this release exists to remove,
13088
+ // reproduced by the guard meant to be careful. `supersede` and
13089
+ // `conflict` still disqualify: both assert something new.
13090
+ (!said.action || opts.action === "reinforce") && opts.claim === void 0 && opts.summary === void 0 && opts.title === void 0 && opts.validFrom === void 0 && opts.contradicts === void 0 && opts.input === void 0 && opts.neverStore === void 0 && opts.neverStoreAdd === void 0;
12972
13091
  const where = await resolveTarget({
12973
13092
  ...opts.local === void 0 ? {} : { local: opts.local },
12974
13093
  ...opts.vault === void 0 ? {} : { vault: opts.vault }
12975
13094
  });
12976
13095
  if (where.kind === "cloud") {
13096
+ if (bareUnbind) {
13097
+ await unbindHosted(where, ref, opts);
13098
+ return;
13099
+ }
12977
13100
  await reviseHosted(where, ref, opts, added);
12978
13101
  return;
12979
13102
  }
@@ -12985,7 +13108,10 @@ function reviseCommand() {
12985
13108
  const built = await openIndex(adapter, brain.root);
12986
13109
  const entry = resolveRef(built.entries, ref);
12987
13110
  const widened = widenedBoundary(entry.memory.never_store, added, ref);
12988
- const input = await buildCommitInput(entry, opts, widened);
13111
+ const input = bareUnbind ? unbindInput(entry, {
13112
+ ...opts.reason === void 0 ? {} : { reason: opts.reason },
13113
+ ...opts.because === void 0 ? {} : { because: opts.because }
13114
+ }) : await buildCommitInput(entry, opts, widened);
12989
13115
  const rationale = sanitizeRationale(
12990
13116
  input.reason === NO_REASON_GIVEN ? null : input.reason ?? null
12991
13117
  );
@@ -13177,6 +13303,30 @@ Undo: \`${undoHint(applied.record.op_id, opts)}\``));
13177
13303
  });
13178
13304
  });
13179
13305
  }
13306
+ async function unbindHosted(where, ref, opts) {
13307
+ if (opts.dryRun === true) {
13308
+ throw memgError(
13309
+ "invalid_input",
13310
+ "--dry-run is not supported for `--no-rule` on a hosted brain",
13311
+ "Taking a marking back writes one key and leaves everything else byte for byte, so there is no diff worth previewing. Run it without `--dry-run`, and `memgineering undo` reverses it. Nothing was sent."
13312
+ );
13313
+ }
13314
+ const brain = await openCloudBrain(where);
13315
+ const result = await cloudUnbind(brain, {
13316
+ ref,
13317
+ ...opts.reason === void 0 ? {} : { reason: opts.reason }
13318
+ });
13319
+ printDual({
13320
+ json: result,
13321
+ human: () => {
13322
+ printHuman(`Unbound \`${oneLine(result.target, WIDTH.path)}\`.
13323
+ `);
13324
+ printHuman(c.gray(oneLine(result.note, WIDTH.text)));
13325
+ printHuman(c.gray(`
13326
+ \`memgineering undo ${oneLine(result.op_id, WIDTH.handle)}\``));
13327
+ }
13328
+ });
13329
+ }
13180
13330
  async function reviseHosted(where, ref, opts, added) {
13181
13331
  if (opts.input) {
13182
13332
  throw memgError(
@@ -13875,6 +14025,10 @@ function consolidateCommand() {
13875
14025
  " rule_demotion a note marked as binding but classified as a fact, a",
13876
14026
  " procedure, an episode or a goal \u2014 the author said it is",
13877
14027
  " not a decision, so the MARKING is the field to doubt",
14028
+ " binding_unclassified a note marked as binding with no kind at all. Two",
14029
+ " fields, agreeing on nothing: it is either a decision",
14030
+ " nobody classified or something that was never a rule,",
14031
+ " and only somebody who reads it can say which",
13878
14032
  "",
13879
14033
  "Nothing here becomes a proposal on its own. Read a candidate, decide what it",
13880
14034
  "means, then say so:",
@@ -13888,11 +14042,13 @@ function consolidateCommand() {
13888
14042
  limit === void 0 ? {} : { limit }
13889
14043
  ) : await detectLocally(where.brain.root, limit);
13890
14044
  const { candidates, truncated } = report3;
14045
+ const byRule = Object.entries(report3.by_rule ?? {}).sort((a, b) => b[1] - a[1]);
13891
14046
  printDual({
13892
14047
  json: {
13893
14048
  candidates,
13894
14049
  count: candidates.length,
13895
14050
  scanned: report3.scanned,
14051
+ by_rule: report3.by_rule ?? {},
13896
14052
  // Reported rather than left to be inferred from a short list. A run
13897
14053
  // that stopped early and said nothing would be indistinguishable from
13898
14054
  // a brain with little to find, which is the one thing the reader
@@ -13916,6 +14072,19 @@ function consolidateCommand() {
13916
14072
  printHuman(`## ${candidates.length} worth a look
13917
14073
  `);
13918
14074
  for (const candidate of candidates) printHuman(renderCandidate(candidate));
14075
+ const held = byRule.filter(
14076
+ ([rule, total]) => total > candidates.filter((c2) => c2.rule === rule).length
14077
+ );
14078
+ if (held.length > 0) {
14079
+ printHuman(
14080
+ c.gray(
14081
+ `
14082
+ ${held.map(([rule, total]) => `${rule}: ${total}`).join(" \xB7 ")}
14083
+ Found in total \u2014 the list above shows a few of each, so one noisy signal
14084
+ cannot bury the rest. \`--limit\` raises the page.`
14085
+ )
14086
+ );
14087
+ }
13919
14088
  if (truncated) {
13920
14089
  printHuman(
13921
14090
  c.yellow(`
@@ -13962,7 +14131,16 @@ function renderCandidate(candidate) {
13962
14131
  // a second door to the same field would give a brain two ways to record one
13963
14132
  // change. So the suggestion names the command rather than a verb this CLI
13964
14133
  // does not have.
13965
- candidate.suggested_action === "unmark_rule" ? c.gray(` suggested: memgineering revise ${candidate.memory_id} --no-rule --reason "\u2026"`) : candidate.suggested_kind === null ? c.gray(` suggested: ${candidate.suggested_action}`) : c.gray(` suggested: ${candidate.suggested_action} --kind ${candidate.suggested_kind}`)
14134
+ candidate.suggested_action === "unmark_rule" ? c.gray(` suggested: memgineering revise ${candidate.memory_id} --no-rule --reason "\u2026"`) : (
14135
+ // BOTH moves, because the tool genuinely cannot tell. It used to print
14136
+ // one — "promote this to a decision" — for every unclassified rule,
14137
+ // including the thirteen on a real brain that were not rules at all.
14138
+ candidate.suggested_action === "decide" ? c.gray(
14139
+ ` suggested: read it, then one of
14140
+ memgineering propose reclassify --memory ${candidate.memory_id} --kind decision --reason "\u2026" --excerpt "\u2026"
14141
+ memgineering revise ${candidate.memory_id} --no-rule --reason "\u2026"`
14142
+ ) : candidate.suggested_kind === null ? c.gray(` suggested: ${candidate.suggested_action}`) : c.gray(` suggested: ${candidate.suggested_action} --kind ${candidate.suggested_kind}`)
14143
+ )
13966
14144
  );
13967
14145
  return lines2.join("\n");
13968
14146
  }
@@ -21268,16 +21446,27 @@ function rulesCommand() {
21268
21446
  });
21269
21447
  if (where.kind === "cloud") {
21270
21448
  const project2 = await projectOf().then((p) => p.name).catch(() => null);
21271
- const { rules: rules2, total: total2, eligible } = await cloudRules(await openCloudBrain(where), {
21272
- project: project2
21273
- });
21449
+ const {
21450
+ rules: rules2,
21451
+ total: total2,
21452
+ eligible,
21453
+ elsewhere: elsewhere2 = []
21454
+ } = await cloudRules(await openCloudBrain(where), { project: project2 });
21274
21455
  const shownOf = eligible ?? rules2.length;
21275
21456
  printDual({
21276
- json: { brain: where.name, hosted: true, rules: rules2, total: total2, project: project2, eligible: shownOf },
21457
+ json: {
21458
+ brain: where.name,
21459
+ hosted: true,
21460
+ rules: rules2,
21461
+ elsewhere: elsewhere2,
21462
+ total: total2,
21463
+ project: project2,
21464
+ eligible: shownOf
21465
+ },
21277
21466
  human: () => {
21278
21467
  printHuman(`## rules \u2014 ${oneLine(where.name, WIDTH.title)} (${total2})
21279
21468
  `);
21280
- if (rules2.length === 0) {
21469
+ if (rules2.length === 0 && elsewhere2.length === 0) {
21281
21470
  printHuman("_Nothing is marked as a rule in this brain._\n");
21282
21471
  printHuman(
21283
21472
  c.gray(
@@ -21299,12 +21488,21 @@ function rulesCommand() {
21299
21488
  printHuman(c.gray(`
21300
21489
  ${shownOf - rules2.length} more not shown.`));
21301
21490
  }
21302
- if (total2 > shownOf) {
21491
+ if (elsewhere2.length > 0) {
21492
+ printHuman(`
21493
+ ### scoped to other projects (${elsewhere2.length})
21494
+ `);
21303
21495
  printHuman(
21304
- c.gray(
21305
- `${total2 - shownOf} more belong to other projects and are not shown here \u2014 they bind there.`
21306
- )
21496
+ c.gray("Not shown to an agent working here. They bind where they say.\n")
21307
21497
  );
21498
+ for (const rule of elsewhere2) {
21499
+ printHuman(`- ${oneLine(rule.summary ?? rule.title, WIDTH.text)}`);
21500
+ printHuman(
21501
+ c.gray(
21502
+ ` \`open: ${oneLine(rule.handle, WIDTH.handle)}\` \xB7 ${oneLine(rule.scope ?? "", WIDTH.title)}`
21503
+ )
21504
+ );
21505
+ }
21308
21506
  }
21309
21507
  printHuman(
21310
21508
  "\nThese are decisions, not suggestions. Follow them without being asked, and say\nso if what you are about to do goes against one."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memgineering",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "private": false,
5
5
  "description": "One memory for the AI you connect. Recall, remember, and revise a brain your agents share — stored in your own folder.",
6
6
  "license": "Apache-2.0",