memgineering 0.21.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,95 @@ 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
+
58
+ ## [0.22.0] — 2026-09-09
59
+
60
+ ### Added
61
+
62
+ - **`consolidate` now asks whether a rule is a rule.** It already proposed
63
+ raising a binding memory to a decision; it never proposed the other
64
+ direction. A memory marked as binding but classified as a fact or a
65
+ procedure is now surfaced as a candidate to stop binding — audited against a
66
+ real brain, 13 of 54 standing rules were a fact, a status, or a
67
+ troubleshooting procedure rather than anything an agent should obey.
68
+
69
+ ### Changed
70
+
71
+ - **`rules` and the before-edit hook now fill two budgets, not one list.**
72
+ Rules that hold everywhere and rules that hold only in this project are drawn
73
+ separately and labelled, so a project's own rules can no longer be pushed out
74
+ by more-used general ones. Measured before the change on a brain with 54
75
+ rules: not one project-scoped rule reached the hook in its own project.
76
+ - **A rule write has to say what it binds.** `remember --rule` now records a
77
+ scope. The CLI fills it from the project you are in, so a rule written at a
78
+ terminal is unaffected; a caller that sends neither a scope nor a project —
79
+ an MCP tool call, a direct API write — is refused and told to send the
80
+ project name, or `global` for a rule that must hold everywhere. `global` is
81
+ stored as no scope at all, which is what such a rule already looked like.
82
+ Ordinary `remember` is unchanged: an observation with no scope is a normal
83
+ observation.
84
+
85
+ ### Fixed
86
+
87
+ - **The before-edit hook no longer pays for the network on every tool call.**
88
+ It claims the session's turn before asking the server rather than after, so
89
+ the second and later calls in a session return without a round-trip. It
90
+ spoke once per session already; it now costs once per session too.
91
+ - **`init` scaffolds the folder `remember` actually writes to.** New brains
92
+ were laid out with `20_TIMELINE/observations/` while writes went to
93
+ `_observations/`, leaving the scaffolded folder empty and a second one beside
94
+ it. Existing notes are not moved.
95
+
96
+ ### Agent guidance
97
+
98
+ The bundled hub and sub-skills changed: the hub now hands the install job to
99
+ `memgineering registry pull` outright instead of describing it, and the rules
100
+ skill covers the two budgets and the scope a rule now carries. Restart your
101
+ agent session after upgrading so it re-reads them.
102
+
14
103
  ## [0.21.0] — 2026-09-08
15
104
 
16
105
  ### 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.21.0
5
+ version: 0.23.0
6
6
  ---
7
7
 
8
8
  # memgineering
@@ -85,12 +85,23 @@ alone earned — not that the top card is a rule, and not that it never could be
85
85
  since the engine noticing a note is decision-shaped is weaker evidence than its
86
86
  author saying it binds.
87
87
 
88
- One difference is worth knowing rather than discovering. The before-edit hook
89
- reads a brain **on this machine**, so on a machine pointed at a hosted brain it
90
- shows the local folder's rules, not the hosted brain's a network call on the
91
- path of every file write is not a trade this makes. `memgineering rules` shows
92
- whichever brain the machine is pointed at; add `--local` to see what the hook
93
- will actually deliver.
88
+ **Rules arrive in two budgets, not one list.** What a session is shown is the
89
+ strongest few that hold everywhere, plus the strongest few scoped to the project
90
+ it is standing in — ten in total, and each half gives its unused room to the
91
+ other, so a brain with nothing scoped still fills the list. Rules scoped to a
92
+ DIFFERENT project are not shown to an agent working here at all; they bind
93
+ there. `memgineering rules` lists them separately under "scoped to other
94
+ projects", which is the one place to notice a scope with a typo in it — such a
95
+ rule binds nowhere and would otherwise appear nowhere.
96
+
97
+ Before the split there was one list ranked by use, and it did not work: measured
98
+ in a real repository, every slot went to rules about other things and a rule
99
+ written that morning about that project's own layout ranked 49th of 54.
100
+
101
+ The before-edit hook and `memgineering rules` read the same brain — the hosted
102
+ one when this machine is pointed at it, the folder on disk otherwise — so what
103
+ the hook will deliver is what the command shows. Add `--local` to read the
104
+ folder on this disk specifically.
94
105
 
95
106
  ## What the user's agent actually sees
96
107
 
@@ -135,11 +146,19 @@ a long list is fewer rules, not a longer one.
135
146
 
136
147
  Two consequences worth knowing before you mark things:
137
148
 
138
- - **Global habits crowd out project decisions.** A rule that has been recalled
139
- in other folders outranks a project rule written yesterday that nobody has
140
- reached for. If a user's own working preferences fill all eight slots, a new
141
- a rule written for the folder they are in may never be shown. Keep the
142
- standing set small.
143
- - **A retired rule stops being shown.** `memgineering retire <ref>` is the way
144
- out when a decision no longer holds it stays visible in recall, ranked last
145
- 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.
@@ -33,14 +33,34 @@ memgineering remember "we always squash before merging" --rule --no-scope \
33
33
  --reason "they said from now on, about every repo — not this one"
34
34
  ```
35
35
 
36
- - `--no-scope` for anything meant to hold machine-wide, rules especially: a
37
- rule stamped with one project's scope drops out of another project's
38
- `recall --scope` answers.
36
+ - `--no-scope` for a decision that genuinely holds in every project — a
37
+ security rule, a way of writing commits, something about the person rather
38
+ than the work. It is a real answer, not the safe one: use it when it is true.
39
39
  - `--scope <name>` when the note is ABOUT a project you are not standing in.
40
40
 
41
+ **Prefer the project scope for a rule that is about one project.** `--no-scope`
42
+ used to be the advice for rules in general, on the reasoning that a scoped rule
43
+ drops out of other projects' answers — it does, and that is now the point. Rules
44
+ reach a session in two budgets, one for what holds everywhere and one for the
45
+ project in front of it, so a scoped rule is no longer competing with every other
46
+ rule in the brain for the same few slots. Measured before the split: in a real
47
+ repository all the slots went to rules about other things, and a rule written
48
+ that morning about that project's own layout ranked 49th of 54 — present,
49
+ enforced by nothing, invisible to the session it existed to stop.
50
+
41
51
  `--scope` and `--no-scope` together are refused — they answer the same
42
52
  question both ways.
43
53
 
54
+ **A rule has to say what it binds, and one of the two is required.** A `--rule`
55
+ write is refused when nothing names a scope and nothing can be derived — a
56
+ folder that is not a repository and has no bound project. Add `--scope "<the
57
+ project>"` or `--no-scope`, and retry; nothing was written. On the hosted API
58
+ (`brain_remember`, or `POST /remember` directly) the same refusal applies with
59
+ no derivation at all: send `scope`, or `"scope": "global"` for one that really
60
+ does hold everywhere. `global` is reserved and is stored as no scope, which is
61
+ what a machine-wide rule already looks like — so there is only ever one
62
+ spelling of it to search for.
63
+
44
64
  **`--reason` on every write.** It goes in the ledger and is the only part of the
45
65
  record that still means anything six months later. Every write verb takes it:
46
66
  `remember`, `revise`, `retire`, `exclude`, `undo`. The one on `undo` matters
@@ -215,11 +235,30 @@ it is about without opening them. It does not carry their claims, so deciding
215
235
  which of a pair is right still means reading them: two notes can share a title
216
236
  and hold opposite conclusions, which is exactly the case a proposal exists for.
217
237
  It returns them with the evidence that caught them and the action it would
218
- suggest, under six rules: `declared_supersession`, a replaced note still active;
219
- `contradiction`, a disagreement nobody settled; `semantic_similarity`, indexed
220
- meaning agreeing reciprocally; `shared_context`, one scope naming the same
221
- thing; `similar_title`; and `kind_promotion`, a binding note filed as something
222
- other than a decision. Semantic detection reuses a complete, calibrated hosted
238
+ suggest, under eight rules: `declared_supersession`, a replaced note still
239
+ active; `contradiction`, a disagreement nobody settled; `semantic_similarity`,
240
+ indexed meaning agreeing reciprocally; `shared_context`, one scope naming the
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
223
262
  vector generation and never calls a model during consolidation. Authored
224
263
  scope/entity context permits reciprocal neighbours; without it, evidence must
225
264
  form a stricter three-note semantic triangle.
package/dist/index.js CHANGED
@@ -277,13 +277,17 @@ var init_errors = __esm({
277
277
 
278
278
  // ../../packages/memory-engine/src/schema.ts
279
279
  import { z } from "zod";
280
- var MappingModeSchema, ScopeSourceSchema, SCOPE_LIMIT, TELEMETRY_ERROR_KINDS, TELEMETRY_MESSAGE_LIMIT, TelemetryErrorPayloadSchema, ProvenanceSchema, MemoryStatusSchema, ClaimConfidenceSchema, MemoryKindSchema, ClaimSchema, MemorySchema, RelationTypeSchema, RelationSourceSchema, RelationSchema;
280
+ function isGlobalScope(scope) {
281
+ return scope != null && scope.trim().toLowerCase() === GLOBAL_SCOPE;
282
+ }
283
+ var MappingModeSchema, ScopeSourceSchema, SCOPE_LIMIT, GLOBAL_SCOPE, TELEMETRY_ERROR_KINDS, TELEMETRY_MESSAGE_LIMIT, TelemetryErrorPayloadSchema, ProvenanceSchema, MemoryStatusSchema, ClaimConfidenceSchema, MemoryKindSchema, ClaimSchema, MemorySchema, RelationTypeSchema, RelationSourceSchema, RelationSchema;
281
284
  var init_schema = __esm({
282
285
  "../../packages/memory-engine/src/schema.ts"() {
283
286
  "use strict";
284
287
  MappingModeSchema = z.enum(["explicit", "heuristic"]);
285
288
  ScopeSourceSchema = z.enum(["explicit", "path", "none"]);
286
289
  SCOPE_LIMIT = 128;
290
+ GLOBAL_SCOPE = "global";
287
291
  TELEMETRY_ERROR_KINDS = [
288
292
  "network_unreachable",
289
293
  "server_error",
@@ -444,6 +448,55 @@ var init_schema = __esm({
444
448
  }
445
449
  });
446
450
 
451
+ // ../../packages/memory-engine/src/layout.ts
452
+ var OBSERVATIONS_DIR;
453
+ var init_layout = __esm({
454
+ "../../packages/memory-engine/src/layout.ts"() {
455
+ "use strict";
456
+ OBSERVATIONS_DIR = "_observations";
457
+ }
458
+ });
459
+
460
+ // ../../packages/memory-engine/src/rule-budget.ts
461
+ function ruleLane(scope, project) {
462
+ if (scope == null || scope.trim() === "" || isGlobalScope(scope)) return "global";
463
+ if (project === null) return "elsewhere";
464
+ return scope.trim().toLowerCase() === project.trim().toLowerCase() ? "project" : "elsewhere";
465
+ }
466
+ function declaredScopeOf(memory) {
467
+ return memory.provenance.scopeSource === "explicit" ? memory.scope : null;
468
+ }
469
+ function splitRuleBudget(ranked, scopeOf, project, budget = DEFAULT_RULE_BUDGET) {
470
+ const globalLane = [];
471
+ const projectLane = [];
472
+ const elsewhere = [];
473
+ for (const item of ranked) {
474
+ const lane = ruleLane(scopeOf(item), project);
475
+ if (lane === "global") globalLane.push(item);
476
+ else if (lane === "project") projectLane.push(item);
477
+ else elsewhere.push(item);
478
+ }
479
+ const total = budget.global + budget.project;
480
+ const globalRoom = Math.max(budget.global, total - Math.min(projectLane.length, budget.project));
481
+ const projectRoom = Math.max(budget.project, total - Math.min(globalLane.length, budget.global));
482
+ return {
483
+ global: globalLane.slice(0, globalRoom),
484
+ project: projectLane.slice(0, projectRoom),
485
+ elsewhere,
486
+ // Everything either budget COULD have shown, so "5 of 12" is honest about
487
+ // the cap without counting another project's rules as things withheld.
488
+ eligible: globalLane.length + projectLane.length
489
+ };
490
+ }
491
+ var DEFAULT_RULE_BUDGET;
492
+ var init_rule_budget = __esm({
493
+ "../../packages/memory-engine/src/rule-budget.ts"() {
494
+ "use strict";
495
+ init_schema();
496
+ DEFAULT_RULE_BUDGET = { global: 5, project: 5 };
497
+ }
498
+ });
499
+
447
500
  // ../../packages/memory-engine/src/boundary.ts
448
501
  function normalizeForMatch(value) {
449
502
  return value.normalize("NFC").replace(ZERO_WIDTH, "").replace(WHITESPACE_RUN, " ").trim();
@@ -2182,8 +2235,11 @@ function isLifecycleAction(action) {
2182
2235
  function isClassifyAction(action) {
2183
2236
  return action === "reclassify";
2184
2237
  }
2238
+ function isBindingAction(action) {
2239
+ return action === "unbind";
2240
+ }
2185
2241
  function carriesNoClaim(action) {
2186
- return isLifecycleAction(action) || isClassifyAction(action);
2242
+ return isLifecycleAction(action) || isClassifyAction(action) || isBindingAction(action);
2187
2243
  }
2188
2244
  function isAccepted(value) {
2189
2245
  return typeof value === "object" && value !== null && value[ACCEPTED] === true;
@@ -2212,6 +2268,21 @@ var init_schema2 = __esm({
2212
2268
  "retire",
2213
2269
  "unretire",
2214
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",
2215
2286
  "ignore"
2216
2287
  ]);
2217
2288
  StorableActionSchema = ProposalActionSchema.exclude(["ignore"]);
@@ -2407,7 +2478,7 @@ var init_schema2 = __esm({
2407
2478
  path: ["source_excerpt"]
2408
2479
  }
2409
2480
  ).refine((p) => carriesNoClaim(p.action) || p.claim !== null, {
2410
- message: "only retire, unretire and reclassify may omit the claim",
2481
+ message: "only retire, unretire, reclassify and unbind may omit the claim",
2411
2482
  path: ["claim"]
2412
2483
  }).refine((p) => isClassifyAction(p.action) === (p.proposed_kind !== null), {
2413
2484
  message: "proposed_kind belongs to reclassify, and reclassify requires one",
@@ -2443,12 +2514,42 @@ function detectConsolidation(entries, opts = {}) {
2443
2514
  ...titles
2444
2515
  ];
2445
2516
  const out = rankCandidates(scored);
2517
+ const limit = opts.limit ?? DEFAULT_LIMIT;
2446
2518
  return {
2447
- candidates: out.slice(0, opts.limit ?? DEFAULT_LIMIT),
2519
+ candidates: shareTheList(out, limit),
2520
+ by_rule: countByRule(out),
2448
2521
  scanned: budget.scanned,
2449
2522
  truncated: budget.truncated
2450
2523
  };
2451
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
+ }
2452
2553
  function titleTerms(title) {
2453
2554
  const out = /* @__PURE__ */ new Set();
2454
2555
  for (const term of terms(title)) {
@@ -2796,19 +2897,38 @@ function kindPromotions(entries) {
2796
2897
  const out = [];
2797
2898
  for (const entry of entries) {
2798
2899
  if (entry.memory.binding !== true) continue;
2799
- if (entry.memory.kind === "decision") continue;
2900
+ const kind = entry.memory.kind;
2901
+ if (kind !== void 0 && RULE_SHAPED_KINDS.has(kind)) continue;
2902
+ if (kind === void 0) {
2903
+ out.push({
2904
+ candidate: {
2905
+ rule: "binding_unclassified",
2906
+ memory_id: entry.memory.id,
2907
+ path: entry.path,
2908
+ title: entry.memory.title,
2909
+ other_id: null,
2910
+ other_path: null,
2911
+ other_title: null,
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
2915
+ },
2916
+ confidence: 4
2917
+ });
2918
+ continue;
2919
+ }
2800
2920
  out.push({
2801
2921
  candidate: {
2802
- rule: "kind_promotion",
2922
+ rule: "rule_demotion",
2803
2923
  memory_id: entry.memory.id,
2804
2924
  path: entry.path,
2805
2925
  title: entry.memory.title,
2806
2926
  other_id: null,
2807
2927
  other_path: null,
2808
2928
  other_title: null,
2809
- evidence: entry.memory.kind === void 0 ? "marked as binding but left unclassified \u2014 a memory that binds is a decision" : `marked as binding but classified as ${entry.memory.kind} \u2014 a memory that binds is a decision`,
2810
- suggested_action: "reclassify",
2811
- suggested_kind: "decision"
2929
+ evidence: `marked as binding but classified as ${kind} \u2014 the author said it is not a decision, so it may not be a rule`,
2930
+ suggested_action: "unmark_rule",
2931
+ suggested_kind: null
2812
2932
  },
2813
2933
  confidence: 4
2814
2934
  });
@@ -2915,7 +3035,7 @@ function* pairsFrom(buckets, budget) {
2915
3035
  }
2916
3036
  }
2917
3037
  }
2918
- var CONSOLIDATION_RULES, DEFAULT_LIMIT, DEFAULT_PAIR_BUDGET, SEMANTIC_PAIR_BUDGET, TITLE_OVERLAP, SAME_SCOPE_SEMANTIC_SIMILARITY, SHARED_ENTITY_SEMANTIC_SIMILARITY, GLOBAL_SEMANTIC_SIMILARITY, RECIPROCAL_NEIGHBOURS, CandidateComponents;
3038
+ var CONSOLIDATION_RULES, DEFAULT_LIMIT, DEFAULT_PAIR_BUDGET, SEMANTIC_PAIR_BUDGET, TITLE_OVERLAP, SAME_SCOPE_SEMANTIC_SIMILARITY, SHARED_ENTITY_SEMANTIC_SIMILARITY, GLOBAL_SEMANTIC_SIMILARITY, RECIPROCAL_NEIGHBOURS, RULE_SHAPED_KINDS, CandidateComponents;
2919
3039
  var init_detect = __esm({
2920
3040
  "../../packages/memory-engine/src/proposal/detect.ts"() {
2921
3041
  "use strict";
@@ -2926,7 +3046,9 @@ var init_detect = __esm({
2926
3046
  "semantic_similarity",
2927
3047
  "shared_context",
2928
3048
  "similar_title",
2929
- "kind_promotion"
3049
+ "kind_promotion",
3050
+ "rule_demotion",
3051
+ "binding_unclassified"
2930
3052
  ];
2931
3053
  DEFAULT_LIMIT = 50;
2932
3054
  DEFAULT_PAIR_BUDGET = 2e5;
@@ -2936,6 +3058,7 @@ var init_detect = __esm({
2936
3058
  SHARED_ENTITY_SEMANTIC_SIMILARITY = 0.75;
2937
3059
  GLOBAL_SEMANTIC_SIMILARITY = 0.72;
2938
3060
  RECIPROCAL_NEIGHBOURS = 2;
3061
+ RULE_SHAPED_KINDS = /* @__PURE__ */ new Set(["decision", "preference"]);
2939
3062
  CandidateComponents = class {
2940
3063
  parent = /* @__PURE__ */ new Map();
2941
3064
  root(id) {
@@ -3178,12 +3301,12 @@ function commit(prepared, input, opts = {}) {
3178
3301
  if (carriesNoClaim(parsed.action)) {
3179
3302
  if (parsed.claim !== null) {
3180
3303
  throw Object.assign(new Error(`${parsed.action} does not take a claim`), {
3181
- 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.`
3182
3305
  });
3183
3306
  }
3184
3307
  } else if (parsed.claim === null) {
3185
3308
  throw Object.assign(new Error(`${parsed.action} needs a claim`), {
3186
- 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."
3187
3310
  });
3188
3311
  } else if ((parsed.action === "supersede" || parsed.action === "create") && parsed.claim.summary === null) {
3189
3312
  throw Object.assign(new Error(`${parsed.action} needs a summary as well as a claim`), {
@@ -3239,10 +3362,10 @@ var init_commit = __esm({
3239
3362
  */
3240
3363
  memory_id: z5.string().min(1).nullable().default(null),
3241
3364
  /**
3242
- * Omitted by `retire` / `unretire` / `reclassify`, which change a memory's
3243
- * standing or its classification rather than what it says. Checked below with
3244
- * the other action rules so the error can name the action instead of pointing
3245
- * 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.
3246
3369
  */
3247
3370
  claim: ProposedClaimSchema.nullable().default(null),
3248
3371
  /** What `reclassify` is reclassifying this memory AS. Only that action takes one. */
@@ -3347,6 +3470,16 @@ function reclassifyInput(entry, kind, opts = {}) {
3347
3470
  target_path: entry.path
3348
3471
  };
3349
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
+ }
3350
3483
  function saysNothingNew(entry, input, normalize) {
3351
3484
  const norm = (v) => normalize(v ?? "").trim();
3352
3485
  const claim = input.claim;
@@ -3436,6 +3569,10 @@ function desiredFields(proposal, existing) {
3436
3569
  out["kind"] = proposal.proposed_kind;
3437
3570
  return out;
3438
3571
  }
3572
+ if (isBindingAction(proposal.action)) {
3573
+ delete out["binding"];
3574
+ return out;
3575
+ }
3439
3576
  const claim = proposal.claim;
3440
3577
  if (claim === null) {
3441
3578
  throw new Error(`${proposal.action} reached the patcher with no claim`);
@@ -3733,6 +3870,46 @@ function verifyPatch(patched, proposal, before) {
3733
3870
  }
3734
3871
  return;
3735
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
+ }
3736
3913
  const supersedes = new Set(
3737
3914
  mapped.relations.filter((r) => r.type === "supersedes").map((r) => r.to)
3738
3915
  );
@@ -4051,6 +4228,8 @@ var init_src = __esm({
4051
4228
  "../../packages/memory-engine/src/index.ts"() {
4052
4229
  "use strict";
4053
4230
  init_schema();
4231
+ init_layout();
4232
+ init_rule_budget();
4054
4233
  init_boundary();
4055
4234
  init_markdown_mapping();
4056
4235
  init_build();
@@ -4482,6 +4661,7 @@ var BrainSchema, ConfigSchema, EMPTY_CONFIG;
4482
4661
  var init_config = __esm({
4483
4662
  "src/lib/config.ts"() {
4484
4663
  "use strict";
4664
+ init_src();
4485
4665
  init_atomic();
4486
4666
  init_brand();
4487
4667
  BrainSchema = z8.object({
@@ -4495,8 +4675,11 @@ var init_config = __esm({
4495
4675
  * place for observations in its layout, while a folder someone has kept
4496
4676
  * notes in for years does not — and picking one for them is how a tool
4497
4677
  * starts rearranging a vault it promised only to read.
4678
+ *
4679
+ * The DEFAULT is shared with everything else that writes an observation, and
4680
+ * is not a second opinion about where they go — see `OBSERVATIONS_DIR`.
4498
4681
  */
4499
- observationsDir: z8.string().min(1).default("_observations")
4682
+ observationsDir: z8.string().min(1).default(OBSERVATIONS_DIR)
4500
4683
  });
4501
4684
  ConfigSchema = z8.object({
4502
4685
  brain: z8.object({
@@ -4629,7 +4812,7 @@ async function createCloudBrain(token, baseUrl, name) {
4629
4812
  });
4630
4813
  return res.body;
4631
4814
  }
4632
- 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;
4633
4816
  var init_cloud_brain = __esm({
4634
4817
  "src/lib/cloud-brain.ts"() {
4635
4818
  "use strict";
@@ -4719,6 +4902,10 @@ var init_cloud_brain = __esm({
4719
4902
  ref: args.ref,
4720
4903
  ...args.reason === void 0 ? {} : { reason: args.reason }
4721
4904
  });
4905
+ cloudUnbind = (brain, args) => call(brain, "POST", "/unbind", {
4906
+ ref: args.ref,
4907
+ ...args.reason === void 0 ? {} : { reason: args.reason }
4908
+ });
4722
4909
  cloudExclude = (brain, mode, args) => call(brain, "POST", `/${mode}`, {
4723
4910
  path: args.path,
4724
4911
  ...args.reason === void 0 ? {} : { reason: args.reason }
@@ -4730,7 +4917,12 @@ var init_cloud_brain = __esm({
4730
4917
  ...askedFromField(args.askedFrom)
4731
4918
  });
4732
4919
  cloudRules = (brain, args = {}) => call(brain, "POST", "/rules", {
4733
- ...args.limit === void 0 ? {} : { limit: args.limit }
4920
+ ...args.limit === void 0 ? {} : { limit: args.limit },
4921
+ // The server cannot see this machine's folders, so the project it is
4922
+ // standing in has to be told. Omitted rather than sent as null when there
4923
+ // is none: absent means "fill the global budget only", which is what a
4924
+ // folder that names no project should get.
4925
+ ...args.project == null ? {} : { project: args.project }
4734
4926
  });
4735
4927
  cloudReindex = (brain, args = {}) => call(brain, "POST", "/reindex", {
4736
4928
  ...args.rebuild === true ? { rebuild: true } : {}
@@ -5542,14 +5734,14 @@ function initCommand() {
5542
5734
  brain: canonicalRoot2,
5543
5735
  folders: FOLDERS,
5544
5736
  files: Object.keys(files(opts.name)),
5545
- observations_dir: OBSERVATIONS_DIR,
5737
+ observations_dir: OBSERVATIONS_DIR2,
5546
5738
  indexed
5547
5739
  },
5548
5740
  human: () => {
5549
5741
  printHuman(`Created a brain at \`${canonicalRoot2}\`.
5550
5742
  `);
5551
5743
  printHuman(`- \`01_BASE/\` \u2014 five files your agent reads first`);
5552
- printHuman(`- \`${OBSERVATIONS_DIR}/\` \u2014 where \`remember\` writes`);
5744
+ printHuman(`- \`${OBSERVATIONS_DIR2}/\` \u2014 where \`remember\` writes`);
5553
5745
  printHuman(`- nine more folders, ready when you need them
5554
5746
  `);
5555
5747
  printHuman(c.gray("The five base files are empty templates.\n"));
@@ -5589,7 +5781,7 @@ async function createBrain(path, name) {
5589
5781
  brains.push({
5590
5782
  root: canonicalRoot2,
5591
5783
  linkedAt: (/* @__PURE__ */ new Date()).toISOString(),
5592
- observationsDir: OBSERVATIONS_DIR
5784
+ observationsDir: OBSERVATIONS_DIR2
5593
5785
  });
5594
5786
  await saveConfig({ ...cfg, brain: withDefaultBrain({ ...cfg.brain, brains }) });
5595
5787
  return { root: canonicalRoot2, indexed: built.entries.length };
@@ -5634,7 +5826,7 @@ function files(name) {
5634
5826
  "| Folder | What lives here |",
5635
5827
  "| --- | --- |",
5636
5828
  "| `10_ENTITIES/` | People, projects, systems \u2014 one note each |",
5637
- "| `20_TIMELINE/` | What happened, when. `observations/` is written by the agent |",
5829
+ "| `20_TIMELINE/` | What happened, when |",
5638
5830
  "| `30_ACTIONS/` | Open work and decisions waiting on something |",
5639
5831
  "| `40_KNOWLEDGE/` | Conclusions worth keeping: how things work, why they were decided |",
5640
5832
  "| `50_SKILLS/` | Procedures \u2014 the steps for doing a thing again |",
@@ -5642,6 +5834,10 @@ function files(name) {
5642
5834
  "| `70_SOURCES/` | Where something came from: links, papers, transcripts |",
5643
5835
  "| `80_SHAREABLE/` | Notes you would be willing to publish |",
5644
5836
  "| `90_ARCHIVE/` | Kept, but no longer current |",
5837
+ `| \`${OBSERVATIONS_DIR2}/\` | Written by the agent \u2014 what \`remember\` records |`,
5838
+ "",
5839
+ "An underscore in front means the folder is the agent's: it is written",
5840
+ "and rewritten without asking. A number in front means it is yours.",
5645
5841
  ""
5646
5842
  ].join("\n"),
5647
5843
  "01_BASE/CRITICAL_FACTS.md": base(
@@ -5701,30 +5897,33 @@ function files(name) {
5701
5897
  )
5702
5898
  };
5703
5899
  }
5704
- var OBSERVATIONS_DIR, FOLDERS;
5900
+ var OBSERVATIONS_DIR2, FOLDERS;
5705
5901
  var init_init = __esm({
5706
5902
  "src/commands/init.ts"() {
5707
5903
  "use strict";
5904
+ init_src();
5708
5905
  init_adapter();
5709
5906
  init_errors();
5710
5907
  init_config();
5711
5908
  init_index_build();
5712
5909
  init_vault();
5713
5910
  init_ui();
5714
- OBSERVATIONS_DIR = "20_TIMELINE/observations";
5911
+ OBSERVATIONS_DIR2 = OBSERVATIONS_DIR;
5715
5912
  FOLDERS = [
5716
5913
  "00_HUB",
5717
5914
  "01_BASE",
5718
5915
  "10_ENTITIES",
5719
5916
  "20_TIMELINE",
5720
- "20_TIMELINE/observations",
5721
5917
  "30_ACTIONS",
5722
5918
  "40_KNOWLEDGE",
5723
5919
  "50_SKILLS",
5724
5920
  "60_TEMPLATES",
5725
5921
  "70_SOURCES",
5726
5922
  "80_SHAREABLE",
5727
- "90_ARCHIVE"
5923
+ "90_ARCHIVE",
5924
+ // Last rather than in numeric order, because it is not in that order: the
5925
+ // numbered folders are the person's and this one is the agent's.
5926
+ OBSERVATIONS_DIR2
5728
5927
  ];
5729
5928
  }
5730
5929
  });
@@ -6163,7 +6362,7 @@ var init_link = __esm({
6163
6362
  init_index_build();
6164
6363
  init_vault();
6165
6364
  init_ui();
6166
- DEFAULT_OBSERVATIONS_DIR = "_observations";
6365
+ DEFAULT_OBSERVATIONS_DIR = OBSERVATIONS_DIR;
6167
6366
  }
6168
6367
  });
6169
6368
 
@@ -12539,6 +12738,14 @@ function rememberCommand() {
12539
12738
  const neverStore = parseNeverStore(opts.neverStore);
12540
12739
  const binding = opts.rule === true || neverStore.length > 0;
12541
12740
  const scope = await scopeToWrite(opts.scope);
12741
+ if (binding && scope === void 0 && !said.noScope) {
12742
+ throw memgError(
12743
+ "invalid_input",
12744
+ "a rule needs a scope, and this folder does not suggest one",
12745
+ 'Nothing was written. This folder is not a repository and has no bound project name, so there is nothing to stamp. Add `--scope "<the project>"` for a decision about one thing, or `--no-scope` for one that has to hold everywhere.'
12746
+ );
12747
+ }
12748
+ const wireScope = scope ?? (binding ? GLOBAL_SCOPE : void 0);
12542
12749
  const target = await resolveTarget({
12543
12750
  ...opts.local === void 0 ? {} : { local: opts.local },
12544
12751
  ...opts.vault === void 0 ? {} : { vault: opts.vault }
@@ -12547,7 +12754,7 @@ function rememberCommand() {
12547
12754
  const cloud = await openCloudBrain(target);
12548
12755
  const written = await cloudRemember(cloud, {
12549
12756
  text: body,
12550
- ...scope ? { scope } : {},
12757
+ ...wireScope ? { scope: wireScope } : {},
12551
12758
  ...rationale ? { reason: rationale } : {},
12552
12759
  ...binding ? { rule: true } : {},
12553
12760
  ...neverStore.length > 0 ? { neverStore } : {},
@@ -12846,10 +13053,13 @@ function yamlString(value) {
12846
13053
 
12847
13054
  // src/commands/revise.ts
12848
13055
  function reviseCommand() {
13056
+ const said = { action: false };
12849
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(
12850
13058
  "--valid-from <iso>",
12851
13059
  "when this became true (defaults: supersede = now, otherwise unchanged)"
12852
- ).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(
12853
13063
  "--never-store-add <words>",
12854
13064
  "add words to this note\u2019s boundary, keeping the ones already there"
12855
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(
@@ -12869,11 +13079,24 @@ function reviseCommand() {
12869
13079
  ].join("\n")
12870
13080
  ).action(async (ref, opts) => {
12871
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;
12872
13091
  const where = await resolveTarget({
12873
13092
  ...opts.local === void 0 ? {} : { local: opts.local },
12874
13093
  ...opts.vault === void 0 ? {} : { vault: opts.vault }
12875
13094
  });
12876
13095
  if (where.kind === "cloud") {
13096
+ if (bareUnbind) {
13097
+ await unbindHosted(where, ref, opts);
13098
+ return;
13099
+ }
12877
13100
  await reviseHosted(where, ref, opts, added);
12878
13101
  return;
12879
13102
  }
@@ -12885,7 +13108,10 @@ function reviseCommand() {
12885
13108
  const built = await openIndex(adapter, brain.root);
12886
13109
  const entry = resolveRef(built.entries, ref);
12887
13110
  const widened = widenedBoundary(entry.memory.never_store, added, ref);
12888
- 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);
12889
13115
  const rationale = sanitizeRationale(
12890
13116
  input.reason === NO_REASON_GIVEN ? null : input.reason ?? null
12891
13117
  );
@@ -13077,6 +13303,30 @@ Undo: \`${undoHint(applied.record.op_id, opts)}\``));
13077
13303
  });
13078
13304
  });
13079
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
+ }
13080
13330
  async function reviseHosted(where, ref, opts, added) {
13081
13331
  if (opts.input) {
13082
13332
  throw memgError(
@@ -13770,8 +14020,15 @@ function consolidateCommand() {
13770
14020
  " semantic_similarity calibrated indexed meaning agrees reciprocally",
13771
14021
  " shared_context two notes in one scope naming the same thing",
13772
14022
  " similar_title two notes titled with mostly the same words",
13773
- " kind_promotion a note marked as binding but filed as something other",
13774
- " than a decision",
14023
+ " kind_promotion a note marked as binding with no kind at all \u2014 the",
14024
+ " marking is deliberate, the classification never happened",
14025
+ " rule_demotion a note marked as binding but classified as a fact, a",
14026
+ " procedure, an episode or a goal \u2014 the author said it is",
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",
13775
14032
  "",
13776
14033
  "Nothing here becomes a proposal on its own. Read a candidate, decide what it",
13777
14034
  "means, then say so:",
@@ -13785,11 +14042,13 @@ function consolidateCommand() {
13785
14042
  limit === void 0 ? {} : { limit }
13786
14043
  ) : await detectLocally(where.brain.root, limit);
13787
14044
  const { candidates, truncated } = report3;
14045
+ const byRule = Object.entries(report3.by_rule ?? {}).sort((a, b) => b[1] - a[1]);
13788
14046
  printDual({
13789
14047
  json: {
13790
14048
  candidates,
13791
14049
  count: candidates.length,
13792
14050
  scanned: report3.scanned,
14051
+ by_rule: report3.by_rule ?? {},
13793
14052
  // Reported rather than left to be inferred from a short list. A run
13794
14053
  // that stopped early and said nothing would be indistinguishable from
13795
14054
  // a brain with little to find, which is the one thing the reader
@@ -13813,6 +14072,19 @@ function consolidateCommand() {
13813
14072
  printHuman(`## ${candidates.length} worth a look
13814
14073
  `);
13815
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
+ }
13816
14088
  if (truncated) {
13817
14089
  printHuman(
13818
14090
  c.yellow(`
@@ -13854,7 +14126,21 @@ function renderCandidate(candidate) {
13854
14126
  );
13855
14127
  }
13856
14128
  lines2.push(
13857
- candidate.suggested_kind === null ? c.gray(` suggested: ${candidate.suggested_action}`) : c.gray(` suggested: ${candidate.suggested_action} --kind ${candidate.suggested_kind}`)
14129
+ // `unmark_rule` is not a proposal action and deliberately is not one: taking
14130
+ // a marking BACK is `revise --no-rule`, which already exists, and inventing
14131
+ // a second door to the same field would give a brain two ways to record one
14132
+ // change. So the suggestion names the command rather than a verb this CLI
14133
+ // does not have.
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
+ )
13858
14144
  );
13859
14145
  return lines2.join("\n");
13860
14146
  }
@@ -15475,7 +15761,7 @@ init_src();
15475
15761
  init_index_build();
15476
15762
  init_ui();
15477
15763
  init_vault();
15478
- var RULE_LIMIT = 8;
15764
+ var RULE_LIMIT = DEFAULT_RULE_BUDGET.global + DEFAULT_RULE_BUDGET.project;
15479
15765
  async function collectRules(vault, contextDir, now = /* @__PURE__ */ new Date()) {
15480
15766
  const { brain, unreachableDefault } = await resolveBrain(vault, contextDir);
15481
15767
  const adapter = await openBrainOrExplain(brain.root);
@@ -15493,11 +15779,21 @@ async function collectRules(vault, contextDir, now = /* @__PURE__ */ new Date())
15493
15779
  here: (event) => isInside(event.context_dir, contextDir)
15494
15780
  }).map((r) => [r.entry.memory.id, r.score])
15495
15781
  );
15496
- const rules = declared.map((entry) => ({ entry, score: order.get(entry.memory.id) ?? 0 })).sort((a, b) => b.score - a.score || a.entry.memory.id.localeCompare(b.entry.memory.id));
15782
+ const ranked = declared.map((entry) => ({ entry, score: order.get(entry.memory.id) ?? 0 })).sort((a, b) => b.score - a.score || a.entry.memory.id.localeCompare(b.entry.memory.id));
15783
+ const project = await projectOf(contextDir).then((p) => p.name).catch(() => null);
15784
+ const split = splitRuleBudget(ranked, (r) => declaredScopeOf(r.entry.memory), project);
15497
15785
  return {
15498
15786
  brainRoot: brain.root,
15499
- rules: rules.slice(0, RULE_LIMIT),
15500
- total: rules.length,
15787
+ // Global first: they hold here too, and they are the ones a reader is most
15788
+ // likely to already know, so the project's own decisions land last and
15789
+ // closest to the work.
15790
+ rules: [
15791
+ ...split.global.map((rule) => ({ ...rule, lane: "global" })),
15792
+ ...split.project.map((rule) => ({ ...rule, lane: "project" }))
15793
+ ],
15794
+ elsewhere: split.elsewhere.map((rule) => ({ ...rule, lane: "elsewhere" })),
15795
+ total: ranked.length,
15796
+ project,
15501
15797
  ...unreachableDefault ? { unreachableDefault } : {}
15502
15798
  };
15503
15799
  }
@@ -15508,6 +15804,17 @@ function renderRuleLine(text, width = RULE_WIDTH) {
15508
15804
  function renderRuleLines(rules) {
15509
15805
  return rules.map(({ entry }) => renderRuleLine(entry.memory.summary ?? entry.memory.title));
15510
15806
  }
15807
+ function renderRuleGroups(rules, project) {
15808
+ const globals = rules.filter((rule) => rule.lane === "global");
15809
+ const scoped = rules.filter((rule) => rule.lane === "project");
15810
+ const out = [];
15811
+ if (globals.length > 0 && scoped.length > 0) {
15812
+ out.push("Everywhere:", ...renderRuleLines(globals), "");
15813
+ out.push(`In ${oneLine(project ?? "this project", WIDTH.title)}:`, ...renderRuleLines(scoped));
15814
+ return out;
15815
+ }
15816
+ return renderRuleLines(rules);
15817
+ }
15511
15818
 
15512
15819
  // src/commands/guard.ts
15513
15820
  init_deadline();
@@ -15576,11 +15883,16 @@ async function runStopGuard(payload, contextDirFlag) {
15576
15883
  });
15577
15884
  }
15578
15885
  async function claimRules(sessionId, contextDir) {
15579
- const hosted = await hostedRuleLines().catch(() => null);
15580
- if (hosted !== null) {
15581
- if (hosted.lines.length === 0) return null;
15582
- if (!await claimSession(sessionId, hosted.brainId)) return null;
15583
- return { lines: hosted.lines, total: hosted.total };
15886
+ const where = await resolveTarget({}).catch(() => null);
15887
+ if (where?.kind === "cloud") {
15888
+ if (!await claimSession(sessionId, where.brainId)) return null;
15889
+ const hosted = await hostedRuleLines(where, contextDir).catch(() => null);
15890
+ if (hosted !== null) {
15891
+ if (hosted.lines.length > 0) return { lines: hosted.lines, total: hosted.total };
15892
+ await releaseSession(sessionId, where.brainId);
15893
+ return null;
15894
+ }
15895
+ await releaseSession(sessionId, where.brainId);
15584
15896
  }
15585
15897
  const collected = await collectRules(void 0, contextDir).catch(() => null);
15586
15898
  if (collected === null) return null;
@@ -15599,7 +15911,7 @@ async function claimRules(sessionId, contextDir) {
15599
15911
  limit: RULE_LIMIT,
15600
15912
  returnedIds: rules.map((r) => r.entry.memory.id)
15601
15913
  });
15602
- return { lines: renderRuleLines(rules), total };
15914
+ return { lines: renderRuleGroups(rules, collected.project), total };
15603
15915
  }
15604
15916
  function contextFor(lines2, total) {
15605
15917
  return `${rulesQuoted(lines2, total)}
@@ -15662,11 +15974,14 @@ async function misdeliveryNotice(toolName, filePath, sessionId) {
15662
15974
  if (!await claimSession(sessionId, "\0misdelivery")) return null;
15663
15975
  return 'This file is this harness\u2019s own memory \u2014 no other tool the user works with will ever read it. Durable facts about the user belong in their brain, which every tool shares: memgineering remember "<the fact>" --reason "<why>" (add --rule if they stated a standing rule). Write it there instead, or in addition.';
15664
15976
  }
15665
- async function claimSession(sessionId, brain) {
15666
- const marker = join18(
15977
+ function markerFor(sessionId, brain) {
15978
+ return join18(
15667
15979
  guardDir(),
15668
15980
  `${createHash7("sha256").update(`${sessionId}\0${brain}`).digest("hex").slice(0, 32)}`
15669
15981
  );
15982
+ }
15983
+ async function claimSession(sessionId, brain) {
15984
+ const marker = markerFor(sessionId, brain);
15670
15985
  try {
15671
15986
  await mkdir11(guardDir(), { recursive: true, mode: 448 });
15672
15987
  await writeFile9(marker, "", { flag: "wx", mode: 384 });
@@ -15676,6 +15991,9 @@ async function claimSession(sessionId, brain) {
15676
15991
  void pruneOldMarkers();
15677
15992
  return true;
15678
15993
  }
15994
+ async function releaseSession(sessionId, brain) {
15995
+ await unlink3(markerFor(sessionId, brain)).catch(() => void 0);
15996
+ }
15679
15997
  var MARKER_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
15680
15998
  async function pruneOldMarkers() {
15681
15999
  try {
@@ -15690,15 +16008,24 @@ async function pruneOldMarkers() {
15690
16008
  } catch {
15691
16009
  }
15692
16010
  }
15693
- async function hostedRuleLines() {
15694
- const where = await resolveTarget({});
15695
- if (where.kind !== "cloud") return null;
15696
- const { rules, total } = await cloudRules(await openCloudBrain(where));
15697
- return {
15698
- lines: rules.map((r) => renderRuleLine(r.summary ?? r.title)),
15699
- total,
15700
- brainId: where.brainId
15701
- };
16011
+ async function hostedRuleLines(where, contextDir) {
16012
+ const project = await projectOf(contextDir).then((p) => p.name).catch(() => null);
16013
+ const { rules, total } = await cloudRules(await openCloudBrain(where), { project });
16014
+ const grouped = groupHostedLines(rules, project);
16015
+ return { lines: grouped, total };
16016
+ }
16017
+ function groupHostedLines(rules, project) {
16018
+ const line = (r) => renderRuleLine(r.summary ?? r.title);
16019
+ const globals = rules.filter((r) => r.lane === "global");
16020
+ const scoped = rules.filter((r) => r.lane === "project");
16021
+ if (globals.length === 0 || scoped.length === 0) return rules.map(line);
16022
+ return [
16023
+ "Everywhere:",
16024
+ ...globals.map(line),
16025
+ "",
16026
+ `In ${project ?? "this project"}:`,
16027
+ ...scoped.map(line)
16028
+ ];
15702
16029
  }
15703
16030
 
15704
16031
  // src/program.ts
@@ -21118,13 +21445,28 @@ function rulesCommand() {
21118
21445
  ...opts.vault === void 0 ? {} : { vault: opts.vault }
21119
21446
  });
21120
21447
  if (where.kind === "cloud") {
21121
- const { rules: rules2, total: total2 } = await cloudRules(await openCloudBrain(where));
21448
+ const project2 = await projectOf().then((p) => p.name).catch(() => null);
21449
+ const {
21450
+ rules: rules2,
21451
+ total: total2,
21452
+ eligible,
21453
+ elsewhere: elsewhere2 = []
21454
+ } = await cloudRules(await openCloudBrain(where), { project: project2 });
21455
+ const shownOf = eligible ?? rules2.length;
21122
21456
  printDual({
21123
- json: { brain: where.name, hosted: true, rules: rules2, total: total2 },
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
+ },
21124
21466
  human: () => {
21125
21467
  printHuman(`## rules \u2014 ${oneLine(where.name, WIDTH.title)} (${total2})
21126
21468
  `);
21127
- if (rules2.length === 0) {
21469
+ if (rules2.length === 0 && elsewhere2.length === 0) {
21128
21470
  printHuman("_Nothing is marked as a rule in this brain._\n");
21129
21471
  printHuman(
21130
21472
  c.gray(
@@ -21135,11 +21477,32 @@ function rulesCommand() {
21135
21477
  }
21136
21478
  for (const rule of rules2) {
21137
21479
  printHuman(`- ${oneLine(rule.summary ?? rule.title, WIDTH.text)}`);
21138
- printHuman(c.gray(` \`open: ${oneLine(rule.handle, WIDTH.handle)}\``));
21480
+ const lane = rule.lane === "project" ? oneLine(project2 ?? "this project", WIDTH.title) : "global";
21481
+ printHuman(
21482
+ c.gray(
21483
+ ` \`open: ${oneLine(rule.handle, WIDTH.handle)}\`${rule.lane ? ` \xB7 ${lane}` : ""}`
21484
+ )
21485
+ );
21139
21486
  }
21140
- if (total2 > rules2.length) {
21487
+ if (shownOf > rules2.length) {
21141
21488
  printHuman(c.gray(`
21142
- ${total2 - rules2.length} more not shown.`));
21489
+ ${shownOf - rules2.length} more not shown.`));
21490
+ }
21491
+ if (elsewhere2.length > 0) {
21492
+ printHuman(`
21493
+ ### scoped to other projects (${elsewhere2.length})
21494
+ `);
21495
+ printHuman(
21496
+ c.gray("Not shown to an agent working here. They bind where they say.\n")
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
+ }
21143
21506
  }
21144
21507
  printHuman(
21145
21508
  "\nThese are decisions, not suggestions. Follow them without being asked, and say\nso if what you are about to do goes against one."
@@ -21148,12 +21511,9 @@ ${total2 - rules2.length} more not shown.`));
21148
21511
  });
21149
21512
  return;
21150
21513
  }
21151
- const { rules, total, unreachableDefault, brainRoot } = await collectRules(
21152
- opts.vault,
21153
- process.cwd()
21154
- );
21155
- const allIds = rules.map((r) => r.entry.memory.id);
21156
- if (rules.length === 0) {
21514
+ const { rules, elsewhere, total, project, unreachableDefault, brainRoot } = await collectRules(opts.vault, process.cwd());
21515
+ const allIds = [...rules, ...elsewhere].map((r) => r.entry.memory.id);
21516
+ if (rules.length === 0 && elsewhere.length === 0) {
21157
21517
  const hint = unreachableDefault ? `Your default brain is not on disk right now: ${unreachableDefault}
21158
21518
  This answered from \`${brainRoot}\` instead, so rules you wrote in the missing one are not shown \u2014 they are not lost. If it moved, \`memgineering use --default <new path>\`. If it is gone for good, \`memgineering unlink ${unreachableDefault}\`.` : 'Nothing here binds you yet. When the user decides something that should hold \u2014 a colour they will not use, a step never to skip \u2014 record it with `memgineering remember "<the decision>" --rule --reason "<why>"`, and it will be shown before the next edit.';
21159
21519
  printDual({
@@ -21178,27 +21538,52 @@ This answered from \`${brainRoot}\` instead, so rules you wrote in the missing o
21178
21538
  scope: entry.memory.scope,
21179
21539
  path: entry.path
21180
21540
  })),
21541
+ elsewhere: elsewhere.map(({ entry }) => ({
21542
+ id: entry.memory.id,
21543
+ handle: displayHandle(entry.memory.id, allIds),
21544
+ title: entry.memory.title,
21545
+ summary: entry.memory.summary,
21546
+ scope: entry.memory.scope,
21547
+ path: entry.path
21548
+ })),
21181
21549
  total,
21182
- ...total > RULE_LIMIT ? {
21183
- hint: `${total} rules are declared and ${RULE_LIMIT} are shown. A list this long is not read before an edit \u2014 retire the ones that no longer decide anything with \`memgineering retire <handle>\`.`
21550
+ project,
21551
+ ...total > rules.length ? {
21552
+ hint: `${total} rules are declared and ${rules.length} are shown${project === null ? "" : ` for \`${project}\``}. Up to ${RULE_LIMIT} fit at once, split between rules that hold everywhere and rules scoped to this project; anything scoped to a DIFFERENT project is not shown here and binds there instead. If the number is high because old decisions never got retired, \`memgineering retire <handle>\`.`
21184
21553
  } : {}
21185
21554
  },
21186
21555
  human: () => {
21187
- printHuman(`## rules (${rules.length}${total > rules.length ? ` of ${total}` : ""})
21188
- `);
21189
- for (const { entry } of rules) {
21556
+ printHuman(
21557
+ `## rules (${rules.length}${total > rules.length ? ` of ${total}` : ""}${project === null ? "" : ` \xB7 ${oneLine(project, WIDTH.title)}`})
21558
+ `
21559
+ );
21560
+ for (const { entry, lane } of rules) {
21190
21561
  printHuman(`- ${oneLine(entry.memory.summary ?? entry.memory.title, WIDTH.text)}`);
21191
21562
  printHuman(
21192
21563
  c.gray(
21193
- ` \`open: ${displayHandle(entry.memory.id, allIds)}\`${entry.memory.scope ? ` \xB7 ${oneLine(entry.memory.scope, WIDTH.title)}` : ""}`
21564
+ ` \`open: ${displayHandle(entry.memory.id, allIds)}\` \xB7 ${lane === "project" ? oneLine(project ?? "this project", WIDTH.title) : "global"}`
21194
21565
  )
21195
21566
  );
21196
21567
  }
21568
+ if (elsewhere.length > 0) {
21569
+ printHuman(`
21570
+ ### scoped to other projects (${elsewhere.length})
21571
+ `);
21572
+ printHuman(c.gray("Not shown to an agent working here. They bind where they say.\n"));
21573
+ for (const { entry } of elsewhere) {
21574
+ printHuman(`- ${oneLine(entry.memory.summary ?? entry.memory.title, WIDTH.text)}`);
21575
+ printHuman(
21576
+ c.gray(
21577
+ ` \`open: ${displayHandle(entry.memory.id, allIds)}\` \xB7 ${oneLine(entry.memory.scope ?? "", WIDTH.title)}`
21578
+ )
21579
+ );
21580
+ }
21581
+ }
21197
21582
  printHuman("");
21198
21583
  printHuman(
21199
21584
  c.gray(
21200
- total > RULE_LIMIT ? `${total} declared, ${rules.length} shown \u2014 a list this long stops being read.
21201
- Retire what no longer decides anything: \`memgineering retire <handle>\`.` : "These are decisions, not suggestions. Follow them without being asked, and say\nso if what you are about to do goes against one."
21585
+ total > rules.length ? `${total} declared, ${rules.length} shown here. Rules scoped to another project bind there, not here.
21586
+ If the rest are decisions that no longer decide anything: \`memgineering retire <handle>\`.` : "These are decisions, not suggestions. Follow them without being asked, and say\nso if what you are about to do goes against one."
21202
21587
  )
21203
21588
  );
21204
21589
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memgineering",
3
- "version": "0.21.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",