memgineering 0.21.0 → 0.22.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,51 @@ language the reader wants. The bilingual rule the monorepo applies to
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.22.0] — 2026-09-09
15
+
16
+ ### Added
17
+
18
+ - **`consolidate` now asks whether a rule is a rule.** It already proposed
19
+ raising a binding memory to a decision; it never proposed the other
20
+ direction. A memory marked as binding but classified as a fact or a
21
+ procedure is now surfaced as a candidate to stop binding — audited against a
22
+ real brain, 13 of 54 standing rules were a fact, a status, or a
23
+ troubleshooting procedure rather than anything an agent should obey.
24
+
25
+ ### Changed
26
+
27
+ - **`rules` and the before-edit hook now fill two budgets, not one list.**
28
+ Rules that hold everywhere and rules that hold only in this project are drawn
29
+ separately and labelled, so a project's own rules can no longer be pushed out
30
+ by more-used general ones. Measured before the change on a brain with 54
31
+ rules: not one project-scoped rule reached the hook in its own project.
32
+ - **A rule write has to say what it binds.** `remember --rule` now records a
33
+ scope. The CLI fills it from the project you are in, so a rule written at a
34
+ terminal is unaffected; a caller that sends neither a scope nor a project —
35
+ an MCP tool call, a direct API write — is refused and told to send the
36
+ project name, or `global` for a rule that must hold everywhere. `global` is
37
+ stored as no scope at all, which is what such a rule already looked like.
38
+ Ordinary `remember` is unchanged: an observation with no scope is a normal
39
+ observation.
40
+
41
+ ### Fixed
42
+
43
+ - **The before-edit hook no longer pays for the network on every tool call.**
44
+ It claims the session's turn before asking the server rather than after, so
45
+ the second and later calls in a session return without a round-trip. It
46
+ spoke once per session already; it now costs once per session too.
47
+ - **`init` scaffolds the folder `remember` actually writes to.** New brains
48
+ were laid out with `20_TIMELINE/observations/` while writes went to
49
+ `_observations/`, leaving the scaffolded folder empty and a second one beside
50
+ it. Existing notes are not moved.
51
+
52
+ ### Agent guidance
53
+
54
+ The bundled hub and sub-skills changed: the hub now hands the install job to
55
+ `memgineering registry pull` outright instead of describing it, and the rules
56
+ skill covers the two budgets and the scope a rule now carries. Restart your
57
+ agent session after upgrading so it re-reads them.
58
+
14
59
  ## [0.21.0] — 2026-09-08
15
60
 
16
61
  ### 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.22.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
 
@@ -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,19 @@ 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 seven 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 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
223
251
  vector generation and never calls a model during consolidation. Authored
224
252
  scope/entity context permits reciprocal neighbours; without it, evidence must
225
253
  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();
@@ -2796,19 +2849,38 @@ function kindPromotions(entries) {
2796
2849
  const out = [];
2797
2850
  for (const entry of entries) {
2798
2851
  if (entry.memory.binding !== true) continue;
2799
- if (entry.memory.kind === "decision") continue;
2852
+ const kind = entry.memory.kind;
2853
+ if (kind !== void 0 && RULE_SHAPED_KINDS.has(kind)) continue;
2854
+ if (kind === void 0) {
2855
+ out.push({
2856
+ candidate: {
2857
+ rule: "kind_promotion",
2858
+ memory_id: entry.memory.id,
2859
+ path: entry.path,
2860
+ title: entry.memory.title,
2861
+ other_id: null,
2862
+ other_path: null,
2863
+ 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"
2867
+ },
2868
+ confidence: 4
2869
+ });
2870
+ continue;
2871
+ }
2800
2872
  out.push({
2801
2873
  candidate: {
2802
- rule: "kind_promotion",
2874
+ rule: "rule_demotion",
2803
2875
  memory_id: entry.memory.id,
2804
2876
  path: entry.path,
2805
2877
  title: entry.memory.title,
2806
2878
  other_id: null,
2807
2879
  other_path: null,
2808
2880
  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"
2881
+ evidence: `marked as binding but classified as ${kind} \u2014 the author said it is not a decision, so it may not be a rule`,
2882
+ suggested_action: "unmark_rule",
2883
+ suggested_kind: null
2812
2884
  },
2813
2885
  confidence: 4
2814
2886
  });
@@ -2915,7 +2987,7 @@ function* pairsFrom(buckets, budget) {
2915
2987
  }
2916
2988
  }
2917
2989
  }
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;
2990
+ 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
2991
  var init_detect = __esm({
2920
2992
  "../../packages/memory-engine/src/proposal/detect.ts"() {
2921
2993
  "use strict";
@@ -2926,7 +2998,8 @@ var init_detect = __esm({
2926
2998
  "semantic_similarity",
2927
2999
  "shared_context",
2928
3000
  "similar_title",
2929
- "kind_promotion"
3001
+ "kind_promotion",
3002
+ "rule_demotion"
2930
3003
  ];
2931
3004
  DEFAULT_LIMIT = 50;
2932
3005
  DEFAULT_PAIR_BUDGET = 2e5;
@@ -2936,6 +3009,7 @@ var init_detect = __esm({
2936
3009
  SHARED_ENTITY_SEMANTIC_SIMILARITY = 0.75;
2937
3010
  GLOBAL_SEMANTIC_SIMILARITY = 0.72;
2938
3011
  RECIPROCAL_NEIGHBOURS = 2;
3012
+ RULE_SHAPED_KINDS = /* @__PURE__ */ new Set(["decision", "preference"]);
2939
3013
  CandidateComponents = class {
2940
3014
  parent = /* @__PURE__ */ new Map();
2941
3015
  root(id) {
@@ -4051,6 +4125,8 @@ var init_src = __esm({
4051
4125
  "../../packages/memory-engine/src/index.ts"() {
4052
4126
  "use strict";
4053
4127
  init_schema();
4128
+ init_layout();
4129
+ init_rule_budget();
4054
4130
  init_boundary();
4055
4131
  init_markdown_mapping();
4056
4132
  init_build();
@@ -4482,6 +4558,7 @@ var BrainSchema, ConfigSchema, EMPTY_CONFIG;
4482
4558
  var init_config = __esm({
4483
4559
  "src/lib/config.ts"() {
4484
4560
  "use strict";
4561
+ init_src();
4485
4562
  init_atomic();
4486
4563
  init_brand();
4487
4564
  BrainSchema = z8.object({
@@ -4495,8 +4572,11 @@ var init_config = __esm({
4495
4572
  * place for observations in its layout, while a folder someone has kept
4496
4573
  * notes in for years does not — and picking one for them is how a tool
4497
4574
  * starts rearranging a vault it promised only to read.
4575
+ *
4576
+ * The DEFAULT is shared with everything else that writes an observation, and
4577
+ * is not a second opinion about where they go — see `OBSERVATIONS_DIR`.
4498
4578
  */
4499
- observationsDir: z8.string().min(1).default("_observations")
4579
+ observationsDir: z8.string().min(1).default(OBSERVATIONS_DIR)
4500
4580
  });
4501
4581
  ConfigSchema = z8.object({
4502
4582
  brain: z8.object({
@@ -4730,7 +4810,12 @@ var init_cloud_brain = __esm({
4730
4810
  ...askedFromField(args.askedFrom)
4731
4811
  });
4732
4812
  cloudRules = (brain, args = {}) => call(brain, "POST", "/rules", {
4733
- ...args.limit === void 0 ? {} : { limit: args.limit }
4813
+ ...args.limit === void 0 ? {} : { limit: args.limit },
4814
+ // The server cannot see this machine's folders, so the project it is
4815
+ // standing in has to be told. Omitted rather than sent as null when there
4816
+ // is none: absent means "fill the global budget only", which is what a
4817
+ // folder that names no project should get.
4818
+ ...args.project == null ? {} : { project: args.project }
4734
4819
  });
4735
4820
  cloudReindex = (brain, args = {}) => call(brain, "POST", "/reindex", {
4736
4821
  ...args.rebuild === true ? { rebuild: true } : {}
@@ -5542,14 +5627,14 @@ function initCommand() {
5542
5627
  brain: canonicalRoot2,
5543
5628
  folders: FOLDERS,
5544
5629
  files: Object.keys(files(opts.name)),
5545
- observations_dir: OBSERVATIONS_DIR,
5630
+ observations_dir: OBSERVATIONS_DIR2,
5546
5631
  indexed
5547
5632
  },
5548
5633
  human: () => {
5549
5634
  printHuman(`Created a brain at \`${canonicalRoot2}\`.
5550
5635
  `);
5551
5636
  printHuman(`- \`01_BASE/\` \u2014 five files your agent reads first`);
5552
- printHuman(`- \`${OBSERVATIONS_DIR}/\` \u2014 where \`remember\` writes`);
5637
+ printHuman(`- \`${OBSERVATIONS_DIR2}/\` \u2014 where \`remember\` writes`);
5553
5638
  printHuman(`- nine more folders, ready when you need them
5554
5639
  `);
5555
5640
  printHuman(c.gray("The five base files are empty templates.\n"));
@@ -5589,7 +5674,7 @@ async function createBrain(path, name) {
5589
5674
  brains.push({
5590
5675
  root: canonicalRoot2,
5591
5676
  linkedAt: (/* @__PURE__ */ new Date()).toISOString(),
5592
- observationsDir: OBSERVATIONS_DIR
5677
+ observationsDir: OBSERVATIONS_DIR2
5593
5678
  });
5594
5679
  await saveConfig({ ...cfg, brain: withDefaultBrain({ ...cfg.brain, brains }) });
5595
5680
  return { root: canonicalRoot2, indexed: built.entries.length };
@@ -5634,7 +5719,7 @@ function files(name) {
5634
5719
  "| Folder | What lives here |",
5635
5720
  "| --- | --- |",
5636
5721
  "| `10_ENTITIES/` | People, projects, systems \u2014 one note each |",
5637
- "| `20_TIMELINE/` | What happened, when. `observations/` is written by the agent |",
5722
+ "| `20_TIMELINE/` | What happened, when |",
5638
5723
  "| `30_ACTIONS/` | Open work and decisions waiting on something |",
5639
5724
  "| `40_KNOWLEDGE/` | Conclusions worth keeping: how things work, why they were decided |",
5640
5725
  "| `50_SKILLS/` | Procedures \u2014 the steps for doing a thing again |",
@@ -5642,6 +5727,10 @@ function files(name) {
5642
5727
  "| `70_SOURCES/` | Where something came from: links, papers, transcripts |",
5643
5728
  "| `80_SHAREABLE/` | Notes you would be willing to publish |",
5644
5729
  "| `90_ARCHIVE/` | Kept, but no longer current |",
5730
+ `| \`${OBSERVATIONS_DIR2}/\` | Written by the agent \u2014 what \`remember\` records |`,
5731
+ "",
5732
+ "An underscore in front means the folder is the agent's: it is written",
5733
+ "and rewritten without asking. A number in front means it is yours.",
5645
5734
  ""
5646
5735
  ].join("\n"),
5647
5736
  "01_BASE/CRITICAL_FACTS.md": base(
@@ -5701,30 +5790,33 @@ function files(name) {
5701
5790
  )
5702
5791
  };
5703
5792
  }
5704
- var OBSERVATIONS_DIR, FOLDERS;
5793
+ var OBSERVATIONS_DIR2, FOLDERS;
5705
5794
  var init_init = __esm({
5706
5795
  "src/commands/init.ts"() {
5707
5796
  "use strict";
5797
+ init_src();
5708
5798
  init_adapter();
5709
5799
  init_errors();
5710
5800
  init_config();
5711
5801
  init_index_build();
5712
5802
  init_vault();
5713
5803
  init_ui();
5714
- OBSERVATIONS_DIR = "20_TIMELINE/observations";
5804
+ OBSERVATIONS_DIR2 = OBSERVATIONS_DIR;
5715
5805
  FOLDERS = [
5716
5806
  "00_HUB",
5717
5807
  "01_BASE",
5718
5808
  "10_ENTITIES",
5719
5809
  "20_TIMELINE",
5720
- "20_TIMELINE/observations",
5721
5810
  "30_ACTIONS",
5722
5811
  "40_KNOWLEDGE",
5723
5812
  "50_SKILLS",
5724
5813
  "60_TEMPLATES",
5725
5814
  "70_SOURCES",
5726
5815
  "80_SHAREABLE",
5727
- "90_ARCHIVE"
5816
+ "90_ARCHIVE",
5817
+ // Last rather than in numeric order, because it is not in that order: the
5818
+ // numbered folders are the person's and this one is the agent's.
5819
+ OBSERVATIONS_DIR2
5728
5820
  ];
5729
5821
  }
5730
5822
  });
@@ -6163,7 +6255,7 @@ var init_link = __esm({
6163
6255
  init_index_build();
6164
6256
  init_vault();
6165
6257
  init_ui();
6166
- DEFAULT_OBSERVATIONS_DIR = "_observations";
6258
+ DEFAULT_OBSERVATIONS_DIR = OBSERVATIONS_DIR;
6167
6259
  }
6168
6260
  });
6169
6261
 
@@ -12539,6 +12631,14 @@ function rememberCommand() {
12539
12631
  const neverStore = parseNeverStore(opts.neverStore);
12540
12632
  const binding = opts.rule === true || neverStore.length > 0;
12541
12633
  const scope = await scopeToWrite(opts.scope);
12634
+ if (binding && scope === void 0 && !said.noScope) {
12635
+ throw memgError(
12636
+ "invalid_input",
12637
+ "a rule needs a scope, and this folder does not suggest one",
12638
+ '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.'
12639
+ );
12640
+ }
12641
+ const wireScope = scope ?? (binding ? GLOBAL_SCOPE : void 0);
12542
12642
  const target = await resolveTarget({
12543
12643
  ...opts.local === void 0 ? {} : { local: opts.local },
12544
12644
  ...opts.vault === void 0 ? {} : { vault: opts.vault }
@@ -12547,7 +12647,7 @@ function rememberCommand() {
12547
12647
  const cloud = await openCloudBrain(target);
12548
12648
  const written = await cloudRemember(cloud, {
12549
12649
  text: body,
12550
- ...scope ? { scope } : {},
12650
+ ...wireScope ? { scope: wireScope } : {},
12551
12651
  ...rationale ? { reason: rationale } : {},
12552
12652
  ...binding ? { rule: true } : {},
12553
12653
  ...neverStore.length > 0 ? { neverStore } : {},
@@ -13770,8 +13870,11 @@ function consolidateCommand() {
13770
13870
  " semantic_similarity calibrated indexed meaning agrees reciprocally",
13771
13871
  " shared_context two notes in one scope naming the same thing",
13772
13872
  " 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",
13873
+ " kind_promotion a note marked as binding with no kind at all \u2014 the",
13874
+ " marking is deliberate, the classification never happened",
13875
+ " rule_demotion a note marked as binding but classified as a fact, a",
13876
+ " procedure, an episode or a goal \u2014 the author said it is",
13877
+ " not a decision, so the MARKING is the field to doubt",
13775
13878
  "",
13776
13879
  "Nothing here becomes a proposal on its own. Read a candidate, decide what it",
13777
13880
  "means, then say so:",
@@ -13854,7 +13957,12 @@ function renderCandidate(candidate) {
13854
13957
  );
13855
13958
  }
13856
13959
  lines2.push(
13857
- candidate.suggested_kind === null ? c.gray(` suggested: ${candidate.suggested_action}`) : c.gray(` suggested: ${candidate.suggested_action} --kind ${candidate.suggested_kind}`)
13960
+ // `unmark_rule` is not a proposal action and deliberately is not one: taking
13961
+ // a marking BACK is `revise --no-rule`, which already exists, and inventing
13962
+ // a second door to the same field would give a brain two ways to record one
13963
+ // change. So the suggestion names the command rather than a verb this CLI
13964
+ // 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}`)
13858
13966
  );
13859
13967
  return lines2.join("\n");
13860
13968
  }
@@ -15475,7 +15583,7 @@ init_src();
15475
15583
  init_index_build();
15476
15584
  init_ui();
15477
15585
  init_vault();
15478
- var RULE_LIMIT = 8;
15586
+ var RULE_LIMIT = DEFAULT_RULE_BUDGET.global + DEFAULT_RULE_BUDGET.project;
15479
15587
  async function collectRules(vault, contextDir, now = /* @__PURE__ */ new Date()) {
15480
15588
  const { brain, unreachableDefault } = await resolveBrain(vault, contextDir);
15481
15589
  const adapter = await openBrainOrExplain(brain.root);
@@ -15493,11 +15601,21 @@ async function collectRules(vault, contextDir, now = /* @__PURE__ */ new Date())
15493
15601
  here: (event) => isInside(event.context_dir, contextDir)
15494
15602
  }).map((r) => [r.entry.memory.id, r.score])
15495
15603
  );
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));
15604
+ 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));
15605
+ const project = await projectOf(contextDir).then((p) => p.name).catch(() => null);
15606
+ const split = splitRuleBudget(ranked, (r) => declaredScopeOf(r.entry.memory), project);
15497
15607
  return {
15498
15608
  brainRoot: brain.root,
15499
- rules: rules.slice(0, RULE_LIMIT),
15500
- total: rules.length,
15609
+ // Global first: they hold here too, and they are the ones a reader is most
15610
+ // likely to already know, so the project's own decisions land last and
15611
+ // closest to the work.
15612
+ rules: [
15613
+ ...split.global.map((rule) => ({ ...rule, lane: "global" })),
15614
+ ...split.project.map((rule) => ({ ...rule, lane: "project" }))
15615
+ ],
15616
+ elsewhere: split.elsewhere.map((rule) => ({ ...rule, lane: "elsewhere" })),
15617
+ total: ranked.length,
15618
+ project,
15501
15619
  ...unreachableDefault ? { unreachableDefault } : {}
15502
15620
  };
15503
15621
  }
@@ -15508,6 +15626,17 @@ function renderRuleLine(text, width = RULE_WIDTH) {
15508
15626
  function renderRuleLines(rules) {
15509
15627
  return rules.map(({ entry }) => renderRuleLine(entry.memory.summary ?? entry.memory.title));
15510
15628
  }
15629
+ function renderRuleGroups(rules, project) {
15630
+ const globals = rules.filter((rule) => rule.lane === "global");
15631
+ const scoped = rules.filter((rule) => rule.lane === "project");
15632
+ const out = [];
15633
+ 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));
15636
+ return out;
15637
+ }
15638
+ return renderRuleLines(rules);
15639
+ }
15511
15640
 
15512
15641
  // src/commands/guard.ts
15513
15642
  init_deadline();
@@ -15576,11 +15705,16 @@ async function runStopGuard(payload, contextDirFlag) {
15576
15705
  });
15577
15706
  }
15578
15707
  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 };
15708
+ const where = await resolveTarget({}).catch(() => null);
15709
+ if (where?.kind === "cloud") {
15710
+ if (!await claimSession(sessionId, where.brainId)) return null;
15711
+ const hosted = await hostedRuleLines(where, contextDir).catch(() => null);
15712
+ if (hosted !== null) {
15713
+ if (hosted.lines.length > 0) return { lines: hosted.lines, total: hosted.total };
15714
+ await releaseSession(sessionId, where.brainId);
15715
+ return null;
15716
+ }
15717
+ await releaseSession(sessionId, where.brainId);
15584
15718
  }
15585
15719
  const collected = await collectRules(void 0, contextDir).catch(() => null);
15586
15720
  if (collected === null) return null;
@@ -15599,7 +15733,7 @@ async function claimRules(sessionId, contextDir) {
15599
15733
  limit: RULE_LIMIT,
15600
15734
  returnedIds: rules.map((r) => r.entry.memory.id)
15601
15735
  });
15602
- return { lines: renderRuleLines(rules), total };
15736
+ return { lines: renderRuleGroups(rules, collected.project), total };
15603
15737
  }
15604
15738
  function contextFor(lines2, total) {
15605
15739
  return `${rulesQuoted(lines2, total)}
@@ -15662,11 +15796,14 @@ async function misdeliveryNotice(toolName, filePath, sessionId) {
15662
15796
  if (!await claimSession(sessionId, "\0misdelivery")) return null;
15663
15797
  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
15798
  }
15665
- async function claimSession(sessionId, brain) {
15666
- const marker = join18(
15799
+ function markerFor(sessionId, brain) {
15800
+ return join18(
15667
15801
  guardDir(),
15668
15802
  `${createHash7("sha256").update(`${sessionId}\0${brain}`).digest("hex").slice(0, 32)}`
15669
15803
  );
15804
+ }
15805
+ async function claimSession(sessionId, brain) {
15806
+ const marker = markerFor(sessionId, brain);
15670
15807
  try {
15671
15808
  await mkdir11(guardDir(), { recursive: true, mode: 448 });
15672
15809
  await writeFile9(marker, "", { flag: "wx", mode: 384 });
@@ -15676,6 +15813,9 @@ async function claimSession(sessionId, brain) {
15676
15813
  void pruneOldMarkers();
15677
15814
  return true;
15678
15815
  }
15816
+ async function releaseSession(sessionId, brain) {
15817
+ await unlink3(markerFor(sessionId, brain)).catch(() => void 0);
15818
+ }
15679
15819
  var MARKER_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
15680
15820
  async function pruneOldMarkers() {
15681
15821
  try {
@@ -15690,15 +15830,24 @@ async function pruneOldMarkers() {
15690
15830
  } catch {
15691
15831
  }
15692
15832
  }
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
- };
15833
+ async function hostedRuleLines(where, contextDir) {
15834
+ const project = await projectOf(contextDir).then((p) => p.name).catch(() => null);
15835
+ const { rules, total } = await cloudRules(await openCloudBrain(where), { project });
15836
+ const grouped = groupHostedLines(rules, project);
15837
+ return { lines: grouped, total };
15838
+ }
15839
+ function groupHostedLines(rules, project) {
15840
+ const line = (r) => renderRuleLine(r.summary ?? r.title);
15841
+ const globals = rules.filter((r) => r.lane === "global");
15842
+ const scoped = rules.filter((r) => r.lane === "project");
15843
+ if (globals.length === 0 || scoped.length === 0) return rules.map(line);
15844
+ return [
15845
+ "Everywhere:",
15846
+ ...globals.map(line),
15847
+ "",
15848
+ `In ${project ?? "this project"}:`,
15849
+ ...scoped.map(line)
15850
+ ];
15702
15851
  }
15703
15852
 
15704
15853
  // src/program.ts
@@ -21118,9 +21267,13 @@ function rulesCommand() {
21118
21267
  ...opts.vault === void 0 ? {} : { vault: opts.vault }
21119
21268
  });
21120
21269
  if (where.kind === "cloud") {
21121
- const { rules: rules2, total: total2 } = await cloudRules(await openCloudBrain(where));
21270
+ 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
+ });
21274
+ const shownOf = eligible ?? rules2.length;
21122
21275
  printDual({
21123
- json: { brain: where.name, hosted: true, rules: rules2, total: total2 },
21276
+ json: { brain: where.name, hosted: true, rules: rules2, total: total2, project: project2, eligible: shownOf },
21124
21277
  human: () => {
21125
21278
  printHuman(`## rules \u2014 ${oneLine(where.name, WIDTH.title)} (${total2})
21126
21279
  `);
@@ -21135,11 +21288,23 @@ function rulesCommand() {
21135
21288
  }
21136
21289
  for (const rule of rules2) {
21137
21290
  printHuman(`- ${oneLine(rule.summary ?? rule.title, WIDTH.text)}`);
21138
- printHuman(c.gray(` \`open: ${oneLine(rule.handle, WIDTH.handle)}\``));
21291
+ const lane = rule.lane === "project" ? oneLine(project2 ?? "this project", WIDTH.title) : "global";
21292
+ printHuman(
21293
+ c.gray(
21294
+ ` \`open: ${oneLine(rule.handle, WIDTH.handle)}\`${rule.lane ? ` \xB7 ${lane}` : ""}`
21295
+ )
21296
+ );
21139
21297
  }
21140
- if (total2 > rules2.length) {
21298
+ if (shownOf > rules2.length) {
21141
21299
  printHuman(c.gray(`
21142
- ${total2 - rules2.length} more not shown.`));
21300
+ ${shownOf - rules2.length} more not shown.`));
21301
+ }
21302
+ if (total2 > shownOf) {
21303
+ printHuman(
21304
+ c.gray(
21305
+ `${total2 - shownOf} more belong to other projects and are not shown here \u2014 they bind there.`
21306
+ )
21307
+ );
21143
21308
  }
21144
21309
  printHuman(
21145
21310
  "\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 +21313,9 @@ ${total2 - rules2.length} more not shown.`));
21148
21313
  });
21149
21314
  return;
21150
21315
  }
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) {
21316
+ const { rules, elsewhere, total, project, unreachableDefault, brainRoot } = await collectRules(opts.vault, process.cwd());
21317
+ const allIds = [...rules, ...elsewhere].map((r) => r.entry.memory.id);
21318
+ if (rules.length === 0 && elsewhere.length === 0) {
21157
21319
  const hint = unreachableDefault ? `Your default brain is not on disk right now: ${unreachableDefault}
21158
21320
  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
21321
  printDual({
@@ -21178,27 +21340,52 @@ This answered from \`${brainRoot}\` instead, so rules you wrote in the missing o
21178
21340
  scope: entry.memory.scope,
21179
21341
  path: entry.path
21180
21342
  })),
21343
+ elsewhere: elsewhere.map(({ entry }) => ({
21344
+ id: entry.memory.id,
21345
+ handle: displayHandle(entry.memory.id, allIds),
21346
+ title: entry.memory.title,
21347
+ summary: entry.memory.summary,
21348
+ scope: entry.memory.scope,
21349
+ path: entry.path
21350
+ })),
21181
21351
  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>\`.`
21352
+ project,
21353
+ ...total > rules.length ? {
21354
+ 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
21355
  } : {}
21185
21356
  },
21186
21357
  human: () => {
21187
- printHuman(`## rules (${rules.length}${total > rules.length ? ` of ${total}` : ""})
21188
- `);
21189
- for (const { entry } of rules) {
21358
+ printHuman(
21359
+ `## rules (${rules.length}${total > rules.length ? ` of ${total}` : ""}${project === null ? "" : ` \xB7 ${oneLine(project, WIDTH.title)}`})
21360
+ `
21361
+ );
21362
+ for (const { entry, lane } of rules) {
21190
21363
  printHuman(`- ${oneLine(entry.memory.summary ?? entry.memory.title, WIDTH.text)}`);
21191
21364
  printHuman(
21192
21365
  c.gray(
21193
- ` \`open: ${displayHandle(entry.memory.id, allIds)}\`${entry.memory.scope ? ` \xB7 ${oneLine(entry.memory.scope, WIDTH.title)}` : ""}`
21366
+ ` \`open: ${displayHandle(entry.memory.id, allIds)}\` \xB7 ${lane === "project" ? oneLine(project ?? "this project", WIDTH.title) : "global"}`
21194
21367
  )
21195
21368
  );
21196
21369
  }
21370
+ if (elsewhere.length > 0) {
21371
+ printHuman(`
21372
+ ### scoped to other projects (${elsewhere.length})
21373
+ `);
21374
+ printHuman(c.gray("Not shown to an agent working here. They bind where they say.\n"));
21375
+ for (const { entry } of elsewhere) {
21376
+ printHuman(`- ${oneLine(entry.memory.summary ?? entry.memory.title, WIDTH.text)}`);
21377
+ printHuman(
21378
+ c.gray(
21379
+ ` \`open: ${displayHandle(entry.memory.id, allIds)}\` \xB7 ${oneLine(entry.memory.scope ?? "", WIDTH.title)}`
21380
+ )
21381
+ );
21382
+ }
21383
+ }
21197
21384
  printHuman("");
21198
21385
  printHuman(
21199
21386
  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."
21387
+ total > rules.length ? `${total} declared, ${rules.length} shown here. Rules scoped to another project bind there, not here.
21388
+ 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
21389
  )
21203
21390
  );
21204
21391
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memgineering",
3
- "version": "0.21.0",
3
+ "version": "0.22.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",