memgineering 0.22.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,76 @@ language the reader wants. The bilingual rule the monorepo applies to
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.23.1] — 2026-09-11
15
+
16
+ ### Fixed
17
+
18
+ - **The rules a hook shows now include the sentence that says what to do.**
19
+ `memgineering guard` quoted each rule's `summary`, which is the note's text
20
+ cut at 200 characters — so a rule long enough to describe a problem before
21
+ prescribing an answer arrived with the answer removed. Measured across 24
22
+ sessions: of six rules planted as global rules, five were cut immediately
23
+ before their prescription, and every session holding one of them walked into
24
+ the trap its own rule described. The hook now quotes the rule as written, and
25
+ when it still has to shorten one it elides the MIDDLE, keeping the opening and
26
+ the closing instruction.
27
+ - **A short list of rules is now read further than a long one.** The width is a
28
+ budget shared across the whole block rather than a fixed cap per line, so two
29
+ standing decisions arrive in full where ten are abbreviated. Ten rules cost
30
+ exactly what ten rules cost before — nothing here prints more than it used to.
31
+
32
+ ### Added
33
+
34
+ - **A hosted brain sends the rule, not just the card.** `POST /rules` gained a
35
+ `text` field carrying the rule as written; a client older than this ignores it
36
+ and keeps rendering `summary`, and a server older than this makes the new
37
+ client fall back to `summary` as well. Deploy the server first to get the
38
+ benefit; neither order breaks anything.
39
+
40
+ ## [0.23.0] — 2026-09-09
41
+
42
+ ### Added
43
+
44
+ - **`revise <ref> --no-rule` on its own now takes a marking back and asks for
45
+ nothing else.** It used to require `--claim`, because `revise` is a verb
46
+ about conclusions — so unbinding a rule meant restating a conclusion that had
47
+ not changed, from text `open` hands back truncated, with `--summary`
48
+ defaulting to whatever you passed. Unbinding thirteen rules on a real hosted
49
+ brain was only possible by reading the note bodies out of the database. The
50
+ conclusion, the summary and the title now come back byte for byte, and
51
+ `undo` reverses it. Adding a claim, a summary, a title, a date, an explicit
52
+ `--action` or a boundary word still makes it an ordinary revise —
53
+ except `--action reinforce`, which means "the same conclusion" and is
54
+ therefore what unbinding already says.
55
+
56
+ ### Changed
57
+
58
+ - **`consolidate` no longer assumes which way an unclassified rule should
59
+ go.** A memory marked as binding with no kind was answered "raise this to a
60
+ decision". Measured on a real brain, 55 of 57 standing rules had no kind and
61
+ thirteen of them were not rules at all — every one was told to become a
62
+ decision, which would have made the mistake harder to see. Such a note is now
63
+ reported as `binding_unclassified` and names both moves: classify it, or stop
64
+ it binding. Only somebody who reads the note can say which.
65
+ - **`consolidate` shares its page between the signals it found.** One rule used
66
+ to take the whole list — 55 of 57 candidates — so a duplicate and a
67
+ contradiction underneath got one line each, and raising `--limit` returned
68
+ more of the same rule. Candidates now alternate across the rules that found
69
+ something, and the answer carries `by_rule` totals, which the page itself can
70
+ no longer tell you.
71
+ - **`rules` against a hosted brain now lists the rules scoped to other
72
+ projects**, under their own heading, as the local one already did. They are
73
+ still never shown to an agent working here. Without the list there was no
74
+ surface anywhere on which to notice a scope with a typo in it — such a rule
75
+ binds nothing and appears nowhere.
76
+
77
+ ### Agent guidance
78
+
79
+ `memgineering-rules` and `memgineering-writing` both changed: how to take a
80
+ marking back and how it differs from `retire`, the two budgets no longer
81
+ crowding out project rules, and the new consolidation signals. Restart your
82
+ agent session after upgrading so it reads them.
83
+
14
84
  ## [0.22.0] — 2026-09-09
15
85
 
16
86
  ### 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.1
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
@@ -497,6 +497,52 @@ var init_rule_budget = __esm({
497
497
  }
498
498
  });
499
499
 
500
+ // ../../packages/memory-engine/src/rule-text.ts
501
+ function ruleTextOf(entry) {
502
+ const intro = (entry.chunks ?? []).find((chunk) => chunk.label === null)?.text.trim();
503
+ if (intro !== void 0 && intro !== "") return intro;
504
+ return entry.memory.summary ?? entry.memory.title;
505
+ }
506
+ function ruleWidthFor(count) {
507
+ if (count <= 0) return RULE_WIDTH_MIN;
508
+ const share = Math.floor(RULE_BLOCK_BUDGET / count);
509
+ return Math.min(RULE_WIDTH_MAX, Math.max(RULE_WIDTH_MIN, share));
510
+ }
511
+ function rulePassage(text, max) {
512
+ const flat = text.replace(/\s+/g, " ").trim();
513
+ const chars = [...flat];
514
+ if (chars.length <= max) return flat;
515
+ if (max <= 1) return "\u2026";
516
+ const budget = max - 1;
517
+ if (budget < 2) return `${chars.slice(0, budget).join("")}\u2026`;
518
+ const tailBudget = Math.min(budget - 1, Math.max(1, Math.floor(budget * 0.6)));
519
+ const tail = [...trailingSentences(flat, tailBudget)];
520
+ const head = chars.slice(0, budget - tail.length).join("").trimEnd();
521
+ return `${head}\u2026${tail.join("")}`;
522
+ }
523
+ function trailingSentences(flat, budget) {
524
+ const starts = [...flat.matchAll(/[.!?。!?](?=\s)/g)].map(
525
+ (match) => match.index + match[0].length
526
+ );
527
+ let best = null;
528
+ for (let i = starts.length - 1; i >= 0; i -= 1) {
529
+ const candidate = flat.slice(starts[i]).trim();
530
+ if ([...candidate].length > budget) break;
531
+ if (candidate !== "") best = candidate;
532
+ }
533
+ if (best !== null) return best;
534
+ return [...flat].slice(-budget).join("");
535
+ }
536
+ var RULE_WIDTH_MIN, RULE_WIDTH_MAX, RULE_BLOCK_BUDGET;
537
+ var init_rule_text = __esm({
538
+ "../../packages/memory-engine/src/rule-text.ts"() {
539
+ "use strict";
540
+ RULE_WIDTH_MIN = 200;
541
+ RULE_WIDTH_MAX = 600;
542
+ RULE_BLOCK_BUDGET = RULE_WIDTH_MIN * 10;
543
+ }
544
+ });
545
+
500
546
  // ../../packages/memory-engine/src/boundary.ts
501
547
  function normalizeForMatch(value) {
502
548
  return value.normalize("NFC").replace(ZERO_WIDTH, "").replace(WHITESPACE_RUN, " ").trim();
@@ -2235,8 +2281,11 @@ function isLifecycleAction(action) {
2235
2281
  function isClassifyAction(action) {
2236
2282
  return action === "reclassify";
2237
2283
  }
2284
+ function isBindingAction(action) {
2285
+ return action === "unbind";
2286
+ }
2238
2287
  function carriesNoClaim(action) {
2239
- return isLifecycleAction(action) || isClassifyAction(action);
2288
+ return isLifecycleAction(action) || isClassifyAction(action) || isBindingAction(action);
2240
2289
  }
2241
2290
  function isAccepted(value) {
2242
2291
  return typeof value === "object" && value !== null && value[ACCEPTED] === true;
@@ -2265,6 +2314,21 @@ var init_schema2 = __esm({
2265
2314
  "retire",
2266
2315
  "unretire",
2267
2316
  "reclassify",
2317
+ /**
2318
+ * A memory stops binding. Nothing else about it changes.
2319
+ *
2320
+ * It exists because there was no way to do this without saying something
2321
+ * else. Taking a marking back meant `revise --claim`, which is a verb about
2322
+ * conclusions — so unbinding thirteen rules on a real hosted brain required
2323
+ * restating thirteen conclusions the author had not changed, from text the
2324
+ * CLI hands back TRUNCATED. The person doing it read the note bodies out of
2325
+ * the database to avoid corrupting them, which is not a thing a user can do.
2326
+ *
2327
+ * One direction on purpose. `unbind` that could also bind would be a name
2328
+ * that lies half the time; marking something as a rule is already reachable
2329
+ * (`remember --rule`, or a `revise` that is genuinely saying something new).
2330
+ */
2331
+ "unbind",
2268
2332
  "ignore"
2269
2333
  ]);
2270
2334
  StorableActionSchema = ProposalActionSchema.exclude(["ignore"]);
@@ -2460,7 +2524,7 @@ var init_schema2 = __esm({
2460
2524
  path: ["source_excerpt"]
2461
2525
  }
2462
2526
  ).refine((p) => carriesNoClaim(p.action) || p.claim !== null, {
2463
- message: "only retire, unretire and reclassify may omit the claim",
2527
+ message: "only retire, unretire, reclassify and unbind may omit the claim",
2464
2528
  path: ["claim"]
2465
2529
  }).refine((p) => isClassifyAction(p.action) === (p.proposed_kind !== null), {
2466
2530
  message: "proposed_kind belongs to reclassify, and reclassify requires one",
@@ -2496,12 +2560,42 @@ function detectConsolidation(entries, opts = {}) {
2496
2560
  ...titles
2497
2561
  ];
2498
2562
  const out = rankCandidates(scored);
2563
+ const limit = opts.limit ?? DEFAULT_LIMIT;
2499
2564
  return {
2500
- candidates: out.slice(0, opts.limit ?? DEFAULT_LIMIT),
2565
+ candidates: shareTheList(out, limit),
2566
+ by_rule: countByRule(out),
2501
2567
  scanned: budget.scanned,
2502
2568
  truncated: budget.truncated
2503
2569
  };
2504
2570
  }
2571
+ function shareTheList(ranked, limit) {
2572
+ const lanes = /* @__PURE__ */ new Map();
2573
+ for (const candidate of ranked) {
2574
+ const lane = lanes.get(candidate.rule);
2575
+ if (lane) lane.push(candidate);
2576
+ else lanes.set(candidate.rule, [candidate]);
2577
+ }
2578
+ const out = [];
2579
+ const queues = [...lanes.values()];
2580
+ let round = 0;
2581
+ for (; ; ) {
2582
+ let added = false;
2583
+ for (const queue of queues) {
2584
+ if (out.length >= limit) return out;
2585
+ const next = queue[round];
2586
+ if (next === void 0) continue;
2587
+ out.push(next);
2588
+ added = true;
2589
+ }
2590
+ if (!added) return out;
2591
+ round += 1;
2592
+ }
2593
+ }
2594
+ function countByRule(ranked) {
2595
+ const out = {};
2596
+ for (const candidate of ranked) out[candidate.rule] = (out[candidate.rule] ?? 0) + 1;
2597
+ return out;
2598
+ }
2505
2599
  function titleTerms(title) {
2506
2600
  const out = /* @__PURE__ */ new Set();
2507
2601
  for (const term of terms(title)) {
@@ -2854,16 +2948,16 @@ function kindPromotions(entries) {
2854
2948
  if (kind === void 0) {
2855
2949
  out.push({
2856
2950
  candidate: {
2857
- rule: "kind_promotion",
2951
+ rule: "binding_unclassified",
2858
2952
  memory_id: entry.memory.id,
2859
2953
  path: entry.path,
2860
2954
  title: entry.memory.title,
2861
2955
  other_id: null,
2862
2956
  other_path: null,
2863
2957
  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"
2958
+ evidence: "marked as binding and left unclassified \u2014 either a decision nobody classified, or something that was never a rule",
2959
+ suggested_action: "decide",
2960
+ suggested_kind: null
2867
2961
  },
2868
2962
  confidence: 4
2869
2963
  });
@@ -2999,7 +3093,8 @@ var init_detect = __esm({
2999
3093
  "shared_context",
3000
3094
  "similar_title",
3001
3095
  "kind_promotion",
3002
- "rule_demotion"
3096
+ "rule_demotion",
3097
+ "binding_unclassified"
3003
3098
  ];
3004
3099
  DEFAULT_LIMIT = 50;
3005
3100
  DEFAULT_PAIR_BUDGET = 2e5;
@@ -3252,12 +3347,12 @@ function commit(prepared, input, opts = {}) {
3252
3347
  if (carriesNoClaim(parsed.action)) {
3253
3348
  if (parsed.claim !== null) {
3254
3349
  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.`
3350
+ 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
3351
  });
3257
3352
  }
3258
3353
  } else if (parsed.claim === null) {
3259
3354
  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."
3355
+ 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
3356
  });
3262
3357
  } else if ((parsed.action === "supersede" || parsed.action === "create") && parsed.claim.summary === null) {
3263
3358
  throw Object.assign(new Error(`${parsed.action} needs a summary as well as a claim`), {
@@ -3313,10 +3408,10 @@ var init_commit = __esm({
3313
3408
  */
3314
3409
  memory_id: z5.string().min(1).nullable().default(null),
3315
3410
  /**
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.
3411
+ * Omitted by `retire` / `unretire` / `reclassify` / `unbind`, which change a
3412
+ * memory's standing, its classification or whether it binds rather than
3413
+ * what it says. Checked below with the other action rules so the error can
3414
+ * name the action instead of pointing at a field.
3320
3415
  */
3321
3416
  claim: ProposedClaimSchema.nullable().default(null),
3322
3417
  /** What `reclassify` is reclassifying this memory AS. Only that action takes one. */
@@ -3421,6 +3516,16 @@ function reclassifyInput(entry, kind, opts = {}) {
3421
3516
  target_path: entry.path
3422
3517
  };
3423
3518
  }
3519
+ function unbindInput(entry, opts = {}) {
3520
+ return {
3521
+ action: "unbind",
3522
+ memory_id: entry.memory.id,
3523
+ claim: null,
3524
+ reason: opts.reason ?? "no longer binding",
3525
+ source_excerpt: opts.because ?? entry.memory.summary ?? entry.memory.title,
3526
+ target_path: entry.path
3527
+ };
3528
+ }
3424
3529
  function saysNothingNew(entry, input, normalize) {
3425
3530
  const norm = (v) => normalize(v ?? "").trim();
3426
3531
  const claim = input.claim;
@@ -3510,6 +3615,10 @@ function desiredFields(proposal, existing) {
3510
3615
  out["kind"] = proposal.proposed_kind;
3511
3616
  return out;
3512
3617
  }
3618
+ if (isBindingAction(proposal.action)) {
3619
+ delete out["binding"];
3620
+ return out;
3621
+ }
3513
3622
  const claim = proposal.claim;
3514
3623
  if (claim === null) {
3515
3624
  throw new Error(`${proposal.action} reached the patcher with no claim`);
@@ -3807,6 +3916,46 @@ function verifyPatch(patched, proposal, before) {
3807
3916
  }
3808
3917
  return;
3809
3918
  }
3919
+ if (isBindingAction(proposal.action)) {
3920
+ if (mapped.memory.binding === true) {
3921
+ throw new PatchError(
3922
+ "the patched note still reads as binding",
3923
+ "This is a defect in the patcher, not in your note. Nothing was written."
3924
+ );
3925
+ }
3926
+ if (before === void 0) {
3927
+ throw new PatchError(
3928
+ `${proposal.action} cannot be verified without the note it started from`,
3929
+ "This is a defect in the caller, not in your note. Nothing was written."
3930
+ );
3931
+ }
3932
+ const was = mapNoteToMemory({ path: proposal.target.path, content: before, revision: null });
3933
+ if (was.claims[0]?.text !== mapped.claims[0]?.text) {
3934
+ throw new PatchError(
3935
+ `${proposal.action} changed the conclusion, which it must never do`,
3936
+ "Taking a marking back leaves what the note says exactly as it was. Nothing was written."
3937
+ );
3938
+ }
3939
+ if (was.memory.summary !== mapped.memory.summary) {
3940
+ throw new PatchError(
3941
+ `${proposal.action} changed the summary, which it must never do`,
3942
+ "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."
3943
+ );
3944
+ }
3945
+ if (was.memory.title !== mapped.memory.title) {
3946
+ throw new PatchError(
3947
+ `${proposal.action} changed the title, which it must never do`,
3948
+ "Taking a marking back leaves what the note says exactly as it was. Nothing was written."
3949
+ );
3950
+ }
3951
+ if (was.memory.status !== mapped.memory.status) {
3952
+ throw new PatchError(
3953
+ `${proposal.action} changed the status, which it must never do`,
3954
+ "A memory that no longer binds is still current. To take it out of the answers as well, use retire. Nothing was written."
3955
+ );
3956
+ }
3957
+ return;
3958
+ }
3810
3959
  const supersedes = new Set(
3811
3960
  mapped.relations.filter((r) => r.type === "supersedes").map((r) => r.to)
3812
3961
  );
@@ -4127,6 +4276,7 @@ var init_src = __esm({
4127
4276
  init_schema();
4128
4277
  init_layout();
4129
4278
  init_rule_budget();
4279
+ init_rule_text();
4130
4280
  init_boundary();
4131
4281
  init_markdown_mapping();
4132
4282
  init_build();
@@ -4709,7 +4859,7 @@ async function createCloudBrain(token, baseUrl, name) {
4709
4859
  });
4710
4860
  return res.body;
4711
4861
  }
4712
- var call, askedFromField, cloudRecall, cloudOpen, cloudEvidence, cloudRemember, cloudUndo, cloudLog, cloudPutNote, cloudListNotes, cloudRevise, cloudBaseWrite, cloudLifecycle, cloudExclude, RANKED_BY_PROJECT, rankedOnProject, cloudResurface, cloudRules, cloudReindex;
4862
+ var call, askedFromField, cloudRecall, cloudOpen, cloudEvidence, cloudRemember, cloudUndo, cloudLog, cloudPutNote, cloudListNotes, cloudRevise, cloudBaseWrite, cloudLifecycle, cloudUnbind, cloudExclude, RANKED_BY_PROJECT, rankedOnProject, cloudResurface, cloudRules, cloudReindex;
4713
4863
  var init_cloud_brain = __esm({
4714
4864
  "src/lib/cloud-brain.ts"() {
4715
4865
  "use strict";
@@ -4799,6 +4949,10 @@ var init_cloud_brain = __esm({
4799
4949
  ref: args.ref,
4800
4950
  ...args.reason === void 0 ? {} : { reason: args.reason }
4801
4951
  });
4952
+ cloudUnbind = (brain, args) => call(brain, "POST", "/unbind", {
4953
+ ref: args.ref,
4954
+ ...args.reason === void 0 ? {} : { reason: args.reason }
4955
+ });
4802
4956
  cloudExclude = (brain, mode, args) => call(brain, "POST", `/${mode}`, {
4803
4957
  path: args.path,
4804
4958
  ...args.reason === void 0 ? {} : { reason: args.reason }
@@ -12946,10 +13100,13 @@ function yamlString(value) {
12946
13100
 
12947
13101
  // src/commands/revise.ts
12948
13102
  function reviseCommand() {
13103
+ const said = { action: false };
12949
13104
  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
13105
  "--valid-from <iso>",
12951
13106
  "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(
13107
+ ).option("--action <kind>", "reinforce | supersede | conflict", "supersede").on("option:action", () => {
13108
+ said.action = true;
13109
+ }).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
13110
  "--never-store-add <words>",
12954
13111
  "add words to this note\u2019s boundary, keeping the ones already there"
12955
13112
  ).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 +13126,24 @@ function reviseCommand() {
12969
13126
  ].join("\n")
12970
13127
  ).action(async (ref, opts) => {
12971
13128
  const added = boundaryAddition(opts);
13129
+ const bareUnbind = opts.rule === false && // `reinforce` is the one action that AGREES with unbinding: it means
13130
+ // "the same conclusion", which is exactly what is being said. A fresh
13131
+ // agent reaches for it unprompted — measured in the Round, the first
13132
+ // session typed `--no-rule --action reinforce`, was refused for
13133
+ // having no claim, and then supplied one, writing a `claim:` key the
13134
+ // note never had. That is the failure this release exists to remove,
13135
+ // reproduced by the guard meant to be careful. `supersede` and
13136
+ // `conflict` still disqualify: both assert something new.
13137
+ (!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
13138
  const where = await resolveTarget({
12973
13139
  ...opts.local === void 0 ? {} : { local: opts.local },
12974
13140
  ...opts.vault === void 0 ? {} : { vault: opts.vault }
12975
13141
  });
12976
13142
  if (where.kind === "cloud") {
13143
+ if (bareUnbind) {
13144
+ await unbindHosted(where, ref, opts);
13145
+ return;
13146
+ }
12977
13147
  await reviseHosted(where, ref, opts, added);
12978
13148
  return;
12979
13149
  }
@@ -12985,7 +13155,10 @@ function reviseCommand() {
12985
13155
  const built = await openIndex(adapter, brain.root);
12986
13156
  const entry = resolveRef(built.entries, ref);
12987
13157
  const widened = widenedBoundary(entry.memory.never_store, added, ref);
12988
- const input = await buildCommitInput(entry, opts, widened);
13158
+ const input = bareUnbind ? unbindInput(entry, {
13159
+ ...opts.reason === void 0 ? {} : { reason: opts.reason },
13160
+ ...opts.because === void 0 ? {} : { because: opts.because }
13161
+ }) : await buildCommitInput(entry, opts, widened);
12989
13162
  const rationale = sanitizeRationale(
12990
13163
  input.reason === NO_REASON_GIVEN ? null : input.reason ?? null
12991
13164
  );
@@ -13177,6 +13350,30 @@ Undo: \`${undoHint(applied.record.op_id, opts)}\``));
13177
13350
  });
13178
13351
  });
13179
13352
  }
13353
+ async function unbindHosted(where, ref, opts) {
13354
+ if (opts.dryRun === true) {
13355
+ throw memgError(
13356
+ "invalid_input",
13357
+ "--dry-run is not supported for `--no-rule` on a hosted brain",
13358
+ "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."
13359
+ );
13360
+ }
13361
+ const brain = await openCloudBrain(where);
13362
+ const result = await cloudUnbind(brain, {
13363
+ ref,
13364
+ ...opts.reason === void 0 ? {} : { reason: opts.reason }
13365
+ });
13366
+ printDual({
13367
+ json: result,
13368
+ human: () => {
13369
+ printHuman(`Unbound \`${oneLine(result.target, WIDTH.path)}\`.
13370
+ `);
13371
+ printHuman(c.gray(oneLine(result.note, WIDTH.text)));
13372
+ printHuman(c.gray(`
13373
+ \`memgineering undo ${oneLine(result.op_id, WIDTH.handle)}\``));
13374
+ }
13375
+ });
13376
+ }
13180
13377
  async function reviseHosted(where, ref, opts, added) {
13181
13378
  if (opts.input) {
13182
13379
  throw memgError(
@@ -13875,6 +14072,10 @@ function consolidateCommand() {
13875
14072
  " rule_demotion a note marked as binding but classified as a fact, a",
13876
14073
  " procedure, an episode or a goal \u2014 the author said it is",
13877
14074
  " not a decision, so the MARKING is the field to doubt",
14075
+ " binding_unclassified a note marked as binding with no kind at all. Two",
14076
+ " fields, agreeing on nothing: it is either a decision",
14077
+ " nobody classified or something that was never a rule,",
14078
+ " and only somebody who reads it can say which",
13878
14079
  "",
13879
14080
  "Nothing here becomes a proposal on its own. Read a candidate, decide what it",
13880
14081
  "means, then say so:",
@@ -13888,11 +14089,13 @@ function consolidateCommand() {
13888
14089
  limit === void 0 ? {} : { limit }
13889
14090
  ) : await detectLocally(where.brain.root, limit);
13890
14091
  const { candidates, truncated } = report3;
14092
+ const byRule = Object.entries(report3.by_rule ?? {}).sort((a, b) => b[1] - a[1]);
13891
14093
  printDual({
13892
14094
  json: {
13893
14095
  candidates,
13894
14096
  count: candidates.length,
13895
14097
  scanned: report3.scanned,
14098
+ by_rule: report3.by_rule ?? {},
13896
14099
  // Reported rather than left to be inferred from a short list. A run
13897
14100
  // that stopped early and said nothing would be indistinguishable from
13898
14101
  // a brain with little to find, which is the one thing the reader
@@ -13916,6 +14119,19 @@ function consolidateCommand() {
13916
14119
  printHuman(`## ${candidates.length} worth a look
13917
14120
  `);
13918
14121
  for (const candidate of candidates) printHuman(renderCandidate(candidate));
14122
+ const held = byRule.filter(
14123
+ ([rule, total]) => total > candidates.filter((c2) => c2.rule === rule).length
14124
+ );
14125
+ if (held.length > 0) {
14126
+ printHuman(
14127
+ c.gray(
14128
+ `
14129
+ ${held.map(([rule, total]) => `${rule}: ${total}`).join(" \xB7 ")}
14130
+ Found in total \u2014 the list above shows a few of each, so one noisy signal
14131
+ cannot bury the rest. \`--limit\` raises the page.`
14132
+ )
14133
+ );
14134
+ }
13919
14135
  if (truncated) {
13920
14136
  printHuman(
13921
14137
  c.yellow(`
@@ -13962,7 +14178,16 @@ function renderCandidate(candidate) {
13962
14178
  // a second door to the same field would give a brain two ways to record one
13963
14179
  // change. So the suggestion names the command rather than a verb this CLI
13964
14180
  // 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}`)
14181
+ candidate.suggested_action === "unmark_rule" ? c.gray(` suggested: memgineering revise ${candidate.memory_id} --no-rule --reason "\u2026"`) : (
14182
+ // BOTH moves, because the tool genuinely cannot tell. It used to print
14183
+ // one — "promote this to a decision" — for every unclassified rule,
14184
+ // including the thirteen on a real brain that were not rules at all.
14185
+ candidate.suggested_action === "decide" ? c.gray(
14186
+ ` suggested: read it, then one of
14187
+ memgineering propose reclassify --memory ${candidate.memory_id} --kind decision --reason "\u2026" --excerpt "\u2026"
14188
+ memgineering revise ${candidate.memory_id} --no-rule --reason "\u2026"`
14189
+ ) : candidate.suggested_kind === null ? c.gray(` suggested: ${candidate.suggested_action}`) : c.gray(` suggested: ${candidate.suggested_action} --kind ${candidate.suggested_kind}`)
14190
+ )
13966
14191
  );
13967
14192
  return lines2.join("\n");
13968
14193
  }
@@ -15619,26 +15844,31 @@ async function collectRules(vault, contextDir, now = /* @__PURE__ */ new Date())
15619
15844
  ...unreachableDefault ? { unreachableDefault } : {}
15620
15845
  };
15621
15846
  }
15622
- var RULE_WIDTH = 200;
15847
+ var RULE_WIDTH = RULE_WIDTH_MIN;
15623
15848
  function renderRuleLine(text, width = RULE_WIDTH) {
15624
- return quoteBlock(oneLine(text, width));
15849
+ return quoteBlock(rulePassage(oneLineWhole(text), width));
15625
15850
  }
15626
- function renderRuleLines(rules) {
15627
- return rules.map(({ entry }) => renderRuleLine(entry.memory.summary ?? entry.memory.title));
15851
+ function renderRuleLines(rules, width = ruleWidthFor(rules.length)) {
15852
+ return rules.map(({ entry }) => renderRuleLine(ruleTextOf(entry), width));
15628
15853
  }
15629
15854
  function renderRuleGroups(rules, project) {
15630
15855
  const globals = rules.filter((rule) => rule.lane === "global");
15631
15856
  const scoped = rules.filter((rule) => rule.lane === "project");
15857
+ const width = ruleWidthFor(rules.length);
15632
15858
  const out = [];
15633
15859
  if (globals.length > 0 && scoped.length > 0) {
15634
- out.push("Everywhere:", ...renderRuleLines(globals), "");
15635
- out.push(`In ${oneLine(project ?? "this project", WIDTH.title)}:`, ...renderRuleLines(scoped));
15860
+ out.push("Everywhere:", ...renderRuleLines(globals, width), "");
15861
+ out.push(
15862
+ `In ${oneLine(project ?? "this project", WIDTH.title)}:`,
15863
+ ...renderRuleLines(scoped, width)
15864
+ );
15636
15865
  return out;
15637
15866
  }
15638
- return renderRuleLines(rules);
15867
+ return renderRuleLines(rules, width);
15639
15868
  }
15640
15869
 
15641
15870
  // src/commands/guard.ts
15871
+ init_src();
15642
15872
  init_deadline();
15643
15873
  init_cloud_brain();
15644
15874
  function guardCommand() {
@@ -15837,7 +16067,8 @@ async function hostedRuleLines(where, contextDir) {
15837
16067
  return { lines: grouped, total };
15838
16068
  }
15839
16069
  function groupHostedLines(rules, project) {
15840
- const line = (r) => renderRuleLine(r.summary ?? r.title);
16070
+ const width = ruleWidthFor(rules.length);
16071
+ const line = (r) => renderRuleLine(r.text ?? r.summary ?? r.title, width);
15841
16072
  const globals = rules.filter((r) => r.lane === "global");
15842
16073
  const scoped = rules.filter((r) => r.lane === "project");
15843
16074
  if (globals.length === 0 || scoped.length === 0) return rules.map(line);
@@ -21268,16 +21499,27 @@ function rulesCommand() {
21268
21499
  });
21269
21500
  if (where.kind === "cloud") {
21270
21501
  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
- });
21502
+ const {
21503
+ rules: rules2,
21504
+ total: total2,
21505
+ eligible,
21506
+ elsewhere: elsewhere2 = []
21507
+ } = await cloudRules(await openCloudBrain(where), { project: project2 });
21274
21508
  const shownOf = eligible ?? rules2.length;
21275
21509
  printDual({
21276
- json: { brain: where.name, hosted: true, rules: rules2, total: total2, project: project2, eligible: shownOf },
21510
+ json: {
21511
+ brain: where.name,
21512
+ hosted: true,
21513
+ rules: rules2,
21514
+ elsewhere: elsewhere2,
21515
+ total: total2,
21516
+ project: project2,
21517
+ eligible: shownOf
21518
+ },
21277
21519
  human: () => {
21278
21520
  printHuman(`## rules \u2014 ${oneLine(where.name, WIDTH.title)} (${total2})
21279
21521
  `);
21280
- if (rules2.length === 0) {
21522
+ if (rules2.length === 0 && elsewhere2.length === 0) {
21281
21523
  printHuman("_Nothing is marked as a rule in this brain._\n");
21282
21524
  printHuman(
21283
21525
  c.gray(
@@ -21299,12 +21541,21 @@ function rulesCommand() {
21299
21541
  printHuman(c.gray(`
21300
21542
  ${shownOf - rules2.length} more not shown.`));
21301
21543
  }
21302
- if (total2 > shownOf) {
21544
+ if (elsewhere2.length > 0) {
21545
+ printHuman(`
21546
+ ### scoped to other projects (${elsewhere2.length})
21547
+ `);
21303
21548
  printHuman(
21304
- c.gray(
21305
- `${total2 - shownOf} more belong to other projects and are not shown here \u2014 they bind there.`
21306
- )
21549
+ c.gray("Not shown to an agent working here. They bind where they say.\n")
21307
21550
  );
21551
+ for (const rule of elsewhere2) {
21552
+ printHuman(`- ${oneLine(rule.summary ?? rule.title, WIDTH.text)}`);
21553
+ printHuman(
21554
+ c.gray(
21555
+ ` \`open: ${oneLine(rule.handle, WIDTH.handle)}\` \xB7 ${oneLine(rule.scope ?? "", WIDTH.title)}`
21556
+ )
21557
+ );
21558
+ }
21308
21559
  }
21309
21560
  printHuman(
21310
21561
  "\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.1",
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",