@lorekit/cli 1.65.0 → 1.67.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/bin/lorekit.mjs CHANGED
@@ -103,7 +103,8 @@ ${c.bold('Commands')}
103
103
  purge: remote only, account-wide, irreversible, --yes required
104
104
  non-interactively. Takes no options.
105
105
  groom Preview (default) or --run a retention sweep: --policy-id <id> or
106
- --scope <s> [+ --min-age-days/--unseen-days/--max-seen-count/--max-read-count].
106
+ --scope <s> [+ --min-age-days/--unseen-days/--max-seen-count/
107
+ --max-read-count/--max-opened-count].
107
108
  Remote only. --run soft-archives matches (recoverable via
108
109
  restore); prompts for confirmation, --yes to skip. --json.
109
110
  policy Manage saved retention rules: list / create / update / delete.
@@ -385,6 +386,7 @@ ${c.bold('Options')}
385
386
  --ttl-days <n> Days until auto-expiry 1–365 (local or remote)
386
387
  --clear-ttl Remove any existing expiry (make it permanent)
387
388
  --org <slug> Write to this org's scope (remote only)
389
+ --cited <a,b> \`scope::key\` refs this lesson applied (remote only)
388
390
  --origin-repo <o/n> Override the derived provenance repository
389
391
  --origin-branch <b> Override the derived provenance branch
390
392
  --origin-commit <s> Override the derived provenance commit SHA
@@ -899,7 +901,8 @@ ${c.bold('Options')}
899
901
 
900
902
  ${c.bold('Usage')}
901
903
  lorekit groom --policy-id <id> [--run] [--yes] [--json]
902
- lorekit groom --scope <s> [--min-age-days <n>] [--unseen-days <n>] [--max-seen-count <n>] [--max-read-count <n>] [--run] [--yes] [--json]
904
+ lorekit groom --scope <s> [--min-age-days <n>] [--unseen-days <n>] [--max-seen-count <n>]
905
+ [--max-read-count <n>] [--max-opened-count <n>] [--run] [--yes] [--json]
903
906
 
904
907
  Resolves the SAME candidates a saved policy or an inline condition set would
905
908
  catch, via the retention-policy candidate query — a previewed count always
@@ -919,8 +922,11 @@ ${c.bold('Options')}
919
922
  --min-age-days <n> Match only lessons at least n days old
920
923
  --unseen-days <n> Match lessons unseen for at least n days (never-seen always matches)
921
924
  --max-seen-count <n> Match only lessons that recurred at most n times (WRITES)
922
- --max-read-count <n> Match only lessons READ at most n times. Bulk list/search
923
- reads count here, unlike --unseen-days
925
+ --max-read-count <n> Match only lessons DELIVERED at most n times. Bulk list/search
926
+ reads count here, unlike --unseen-days, so this runs to
927
+ the hundreds — a small value matches nothing
928
+ --max-opened-count <n> Match only lessons an agent DELIBERATELY fetched at most n
929
+ times. Bulk reads do NOT count, so 0 means never chosen
924
930
  --run Archive the matches instead of previewing
925
931
  -y, --yes Confirm --run; required when non-interactive
926
932
  --json Machine-readable result
@@ -934,13 +940,14 @@ ${c.bold('Usage')}
934
940
  lorekit policy list [--json]
935
941
  lorekit policy create --scope <s> --name <n> [--mode review|auto] [--enabled]
936
942
  [--min-age-days <n>] [--unseen-days <n>] [--max-seen-count <n>]
937
- [--max-read-count <n>]
943
+ [--max-read-count <n>] [--max-opened-count <n>]
938
944
  lorekit policy update <id> [--name <n>] [--mode review|auto] [--enabled|--disabled]
939
945
  [--min-age-days <n>|--clear-min-age-days] [...] [--json]
940
946
  lorekit policy delete <id> [--yes] [--json]
941
947
 
942
948
  A policy is a saved retention rule: a scope plus AND-ed conditions
943
- (min-age-days / unseen-days / max-seen-count / max-read-count). \`mode: review\` surfaces it for
949
+ (min-age-days / unseen-days / max-seen-count / max-read-count / max-opened-count).
950
+ \`mode: review\` surfaces it for
944
951
  you to run by hand with ${c.cyan('lorekit groom --policy-id')}; \`mode: auto\` gets swept
945
952
  nightly, but ONLY once you also pass --enabled — auto starts disabled on
946
953
  every new policy so a saved rule never archives anything unattended.
@@ -952,9 +959,11 @@ ${c.bold('Options')}
952
959
  --name <n> Policy name (create) / new name (update)
953
960
  --mode <review|auto> Match mode (create/update)
954
961
  --enabled / --disabled Turn auto-mode on/off (create/update)
955
- --min-age-days <n>, --unseen-days <n>, --max-seen-count <n>, --max-read-count <n>
962
+ --min-age-days <n>, --unseen-days <n>, --max-seen-count <n>, --max-read-count <n>,
963
+ --max-opened-count <n>
956
964
  Conditions (create/update)
957
- --clear-min-age-days, --clear-unseen-days, --clear-max-seen-count, --clear-max-read-count
965
+ --clear-min-age-days, --clear-unseen-days, --clear-max-seen-count,
966
+ --clear-max-read-count, --clear-max-opened-count
958
967
  Remove a condition (update only)
959
968
  -y, --yes Confirm delete; required when non-interactive
960
969
  --json Machine-readable result
@@ -1059,7 +1068,8 @@ const KNOWN_FLAGS = [
1059
1068
  'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
1060
1069
  'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'mcp-json', 'completions', 'complete', 'force', 'deep', 'adapter',
1061
1070
  'event', 'json', 'scope', 'key', 'threshold', 'help', 'version', 'telemetry',
1062
- 'value', 'tags', 'source-agent', 'trigger', 'kind', 'host', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
1071
+ 'value', 'tags', 'source-agent', 'trigger', 'kind', 'host', 'ttl-days', 'clear-ttl', 'org', 'cited',
1072
+ 'remote', 'local',
1063
1073
  // `view` is accepted-and-IGNORED, not documented: the Explorer dropped the
1064
1074
  // scope/time tab so the flag is a no-op, but `link` is a HUMAN_COMMAND that
1065
1075
  // rejects UNKNOWN options — so keeping `view` listed for a release stops an
@@ -1074,9 +1084,10 @@ const KNOWN_FLAGS = [
1074
1084
  // `invariants candidates`
1075
1085
  'min-seen-count',
1076
1086
  // groom / policy / protect / pin / unpin
1077
- 'policy-id', 'min-age-days', 'unseen-days', 'max-seen-count', 'max-read-count', 'run',
1087
+ 'policy-id', 'min-age-days', 'unseen-days', 'max-seen-count', 'max-read-count', 'max-opened-count', 'run',
1078
1088
  'name', 'mode', 'enabled', 'disabled',
1079
- 'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count', 'off',
1089
+ 'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count',
1090
+ 'clear-max-opened-count', 'off',
1080
1091
  // `obligations`
1081
1092
  'files', 'strict', 'strict-all',
1082
1093
  ];
@@ -1091,7 +1102,7 @@ async function main() {
1091
1102
  const argv = process.argv.slice(2);
1092
1103
  const args = parseArgs(argv, {
1093
1104
  aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
1094
- booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all', 'run', 'enabled', 'disabled', 'off', 'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count', 'strict', 'strict-all'],
1105
+ booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all', 'run', 'enabled', 'disabled', 'off', 'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count', 'clear-max-opened-count', 'strict', 'strict-all'],
1095
1106
  known: KNOWN_FLAGS,
1096
1107
  });
1097
1108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.65.0",
3
+ "version": "1.67.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -69,7 +69,8 @@ memory.write {
69
69
  value: "<observation in markdown>",
70
70
  tags: ["skill::lorekit-memory", "source::stuck-loop"],
71
71
  source_agent: "<your agent name, if known>",
72
- trigger: "stuck-loop"
72
+ trigger: "stuck-loop",
73
+ cited: ["global::pnpm-first"]
73
74
  }
74
75
  ```
75
76
 
@@ -77,6 +78,25 @@ Pick `trigger` / `source::*` from what actually happened:
77
78
  `stuck-loop`, `command-failure`, `gotcha`, `near-miss`, `assumption-wrong`,
78
79
  `paid-off`, or `manual`.
79
80
 
81
+ ## 6b. Credit the lessons you actually applied
82
+
83
+ `cited` is a list of `scope::key` references to lessons that **shaped this
84
+ turn** — ones you were shown at session start or looked up, and then acted on.
85
+ Omit it when nothing applied.
86
+
87
+ This is the only signal that distinguishes a lesson that was *used* from one
88
+ that was merely *shown*. LoreKit can count deliveries and deliberate fetches on
89
+ its own, but a lesson injected at session start is already in your context and
90
+ gets applied without ever being fetched again — so nothing but you can report it.
91
+
92
+ - **Name only what you used.** Citing everything you were shown makes the
93
+ counter measure delivery again, which it already does.
94
+ - **A wrong guess costs nothing.** A reference that does not resolve is dropped
95
+ silently, and a lesson cannot cite itself. Naming a lesson you are unsure of
96
+ is cheaper than omitting one you relied on.
97
+ - **Copy the ref exactly as it was shown to you** (`global::pnpm-first`,
98
+ `repo::acme/app::migration-order`). Do not reconstruct it from memory.
99
+
80
100
  ## 7. Confirm
81
101
 
82
102
  State in one line what you recorded and where
@@ -31,6 +31,8 @@ export function parseGroomRequest(args) {
31
31
  if (maxSeen.error) return { error: maxSeen.error };
32
32
  const maxRead = parseIntFlag(args['max-read-count'], 'max-read-count');
33
33
  if (maxRead.error) return { error: maxRead.error };
34
+ const maxOpened = parseIntFlag(args['max-opened-count'], 'max-opened-count');
35
+ if (maxOpened.error) return { error: maxOpened.error };
34
36
 
35
37
  return {
36
38
  request: {
@@ -39,6 +41,7 @@ export function parseGroomRequest(args) {
39
41
  unseen_days: unseen.value,
40
42
  max_seen_count: maxSeen.value,
41
43
  max_read_count: maxRead.value,
44
+ max_opened_count: maxOpened.value,
42
45
  },
43
46
  };
44
47
  }
@@ -333,7 +333,11 @@ async function run(args, meter) {
333
333
  }
334
334
  if (!shouldRetrospect(stopMode, friction)) return 0;
335
335
  if (!firstTimeThisSession(parsed.sessionId, 'retro')) return 0;
336
- emit(retrospectiveNudge(scope, control, { reasons }));
336
+ // The shown set is what BOTH injection paths recorded this session, so the
337
+ // citation ask names the actual candidates rather than a convention. Read
338
+ // here and not earlier: every return above this line emits nothing, and the
339
+ // read is a file the throttled path should not pay for.
340
+ emit(retrospectiveNudge(scope, control, { reasons, shown: [...shownLessons(parsed.sessionId)] }));
337
341
  return 0;
338
342
  }
339
343
 
@@ -31,6 +31,7 @@ function formatPolicy(p) {
31
31
  if (p.unseen_days != null) conditions.push(`unseen_days=${p.unseen_days}`);
32
32
  if (p.max_seen_count != null) conditions.push(`max_seen_count=${p.max_seen_count}`);
33
33
  if (p.max_read_count != null) conditions.push(`max_read_count=${p.max_read_count}`);
34
+ if (p.max_opened_count != null) conditions.push(`max_opened_count=${p.max_opened_count}`);
34
35
  const mode = p.mode === 'auto' ? (p.enabled ? c.green('auto (enabled)') : c.dim('auto (disabled)')) : c.dim('review');
35
36
  return `${c.cyan(p.id)} ${c.bold(p.name)} ${c.dim(p.scope)} ${mode}${conditions.length ? ` ${c.dim(conditions.join(', '))}` : ''}`;
36
37
  }
@@ -51,7 +52,7 @@ async function list(args, store) {
51
52
 
52
53
  async function create(args, store) {
53
54
  if (!args.scope || !args.name) {
54
- err(`${c.red('Usage:')} lorekit policy create --scope <scope> --name <name> [--mode review|auto] [--enabled] [--min-age-days N] [--unseen-days N] [--max-seen-count N] [--max-read-count N]`);
55
+ err(`${c.red('Usage:')} lorekit policy create --scope <scope> --name <name> [--mode review|auto] [--enabled] [--min-age-days N] [--unseen-days N] [--max-seen-count N] [--max-read-count N] [--max-opened-count N]`);
55
56
  return 1;
56
57
  }
57
58
  const minAge = parseIntFlag(args['min-age-days'], 'min-age-days');
@@ -62,6 +63,8 @@ async function create(args, store) {
62
63
  if (maxSeen.error) { err(`${c.red('Error:')} ${maxSeen.error}`); return 1; }
63
64
  const maxRead = parseIntFlag(args['max-read-count'], 'max-read-count');
64
65
  if (maxRead.error) { err(`${c.red('Error:')} ${maxRead.error}`); return 1; }
66
+ const maxOpened = parseIntFlag(args['max-opened-count'], 'max-opened-count');
67
+ if (maxOpened.error) { err(`${c.red('Error:')} ${maxOpened.error}`); return 1; }
65
68
  if (args.mode !== undefined && args.mode !== 'review' && args.mode !== 'auto') {
66
69
  err(`${c.red('Error:')} --mode must be "review" or "auto"`);
67
70
  return 1;
@@ -76,6 +79,7 @@ async function create(args, store) {
76
79
  unseen_days: unseen.value,
77
80
  max_seen_count: maxSeen.value,
78
81
  max_read_count: maxRead.value,
82
+ max_opened_count: maxOpened.value,
79
83
  });
80
84
  if (!res.ok) {
81
85
  const msg = res.error?.message ?? res.error ?? res.networkError ?? 'the server rejected the request';
@@ -91,7 +95,7 @@ async function create(args, store) {
91
95
  async function update(args, store) {
92
96
  const id = args._[2];
93
97
  if (!id) {
94
- err(`${c.red('Usage:')} lorekit policy update <id> [--name N] [--mode review|auto] [--enabled|--disabled] [--min-age-days N] [--unseen-days N] [--max-seen-count N] [--max-read-count N]`);
98
+ err(`${c.red('Usage:')} lorekit policy update <id> [--name N] [--mode review|auto] [--enabled|--disabled] [--min-age-days N] [--unseen-days N] [--max-seen-count N] [--max-read-count N] [--max-opened-count N]`);
95
99
  return 1;
96
100
  }
97
101
  if (args.mode !== undefined && args.mode !== 'review' && args.mode !== 'auto') {
@@ -109,6 +113,7 @@ async function update(args, store) {
109
113
  ['unseen-days', 'clear-unseen-days', 'unseen_days'],
110
114
  ['max-seen-count', 'clear-max-seen-count', 'max_seen_count'],
111
115
  ['max-read-count', 'clear-max-read-count', 'max_read_count'],
116
+ ['max-opened-count', 'clear-max-opened-count', 'max_opened_count'],
112
117
  ]) {
113
118
  if (args[clearFlag]) { patch[field] = null; continue; }
114
119
  if (args[flag] === undefined) continue;
@@ -29,6 +29,14 @@
29
29
  // omitted, a configured default may apply — see below.
30
30
  // --clear-ttl Remove any existing expiry (make the memory permanent)
31
31
  // --org <slug> Write to this org (remote only)
32
+ // --cited <ref,ref> `scope::key` refs this lesson APPLIED (remote only)
33
+ //
34
+ // `--cited` credits the lore that shaped the work being written up. It is the
35
+ // only signal that distinguishes a lesson that was read from one that was used:
36
+ // a lesson injected at SessionStart is already in context and is applied without
37
+ // ever being fetched, so the delivered/chosen ratio cannot see it. Unresolvable
38
+ // refs cost nothing — the server drops them — so naming a lesson you are unsure
39
+ // of is cheaper than omitting one you relied on.
32
40
  //
33
41
  // Provenance — where the lesson is being recorded FROM. Derived automatically
34
42
  // from git + the CI environment (repo, branch, commit, and the pull request
@@ -222,6 +230,16 @@ export async function write(args) {
222
230
 
223
231
  const orgSlug = typeof args.org === 'string' ? args.org : undefined;
224
232
 
233
+ // Split on commas like `--tags`, so there is one list-flag convention. The
234
+ // refs themselves are NOT parsed here: `scope::key` splits on the first `::`
235
+ // whose left half is a legal scope, a rule that lives once in
236
+ // `parseMemoryRef` (mcp-core + its edge mirror) and would become a second,
237
+ // weaker implementation if this seam guessed at it. A ref this command cannot
238
+ // resolve is dropped server-side, silently and by design.
239
+ const cited = args.cited
240
+ ? String(args.cited).split(',').map((r) => r.trim()).filter(Boolean)
241
+ : [];
242
+
225
243
  // ── Provenance ────────────────────────────────────────────────────────────
226
244
  // Derived from git + CI unless --no-origin; explicit --origin-* flags win.
227
245
  // A field that is neither supplied nor derivable is omitted, never sent as
@@ -300,6 +318,16 @@ export async function write(args) {
300
318
  return 1;
301
319
  }
302
320
 
321
+ // The offline store has no citation ledger — migration 00107's table lives in
322
+ // Postgres — so a local write would accept the flag and record nothing. Named
323
+ // rather than dropped: a credit that silently went nowhere is invisible in
324
+ // exactly the place the whole signal exists to be visible.
325
+ if (cited.length && storeName === 'local') {
326
+ err(`${c.red('Error:')} --cited is remote-only (the offline store records no citations)`);
327
+ err(`Re-run with ${c.cyan('--remote')}, or drop --cited to write the lesson locally.`);
328
+ return 1;
329
+ }
330
+
303
331
  // ── Write ──────────────────────────────────────────────────────────────────
304
332
  const writeArgs = {
305
333
  scope,
@@ -313,6 +341,7 @@ export async function write(args) {
313
341
  ...(ttlDays ? { ttl_days: ttlDays } : {}),
314
342
  ...(clearTtl ? { clear_ttl: true } : {}),
315
343
  ...(orgSlug ? { org: orgSlug } : {}),
344
+ ...(cited.length ? { cited } : {}),
316
345
  ...origin,
317
346
  };
318
347
 
@@ -346,6 +375,7 @@ export async function write(args) {
346
375
  trigger: trigger || null,
347
376
  ttl_days: reportedTtlDays,
348
377
  ttl_source: reportedTtlSource,
378
+ cited,
349
379
  origin,
350
380
  }, null, 2));
351
381
  } else {
@@ -828,6 +828,43 @@ function ttlHint(writeScope, control) {
828
828
  return ` Set ttl_days: ${days} (this scope's configured default) unless the lesson is durable enough to keep forever.`;
829
829
  }
830
830
 
831
+ // How many injected lesson refs the citation hint names before it summarises the
832
+ // rest. The nudge competes for the same context the lessons themselves occupy, so
833
+ // the list is a prompt for recall, not an inventory — an agent that applied a
834
+ // lesson beyond the cap can still name it, because `cited` takes any ref.
835
+ export const CITED_HINT_MAX = 8;
836
+
837
+ // Build the citation clause appended to the retrospective nudge, from the
838
+ // `scope::key` ids this session has already injected (core/state.mjs's shown
839
+ // set). Empty string when nothing was injected — with no candidates the ask
840
+ // would be an instruction to invent references.
841
+ //
842
+ // WHY THE ASK EXISTS. `opened_count / read_count` measures whether a lesson was
843
+ // deliberately FETCHED, and a lesson injected at SessionStart is already in
844
+ // context and gets applied without ever being fetched. So the ratio under-counts
845
+ // the dominant delivery path by construction, and only the agent knows the
846
+ // difference. `cited` (migration 00107) is where it says so.
847
+ //
848
+ // The ids are taken in the store's own order, which is injection order, so the
849
+ // SessionStart set leads — it is both the largest and the one in context for the
850
+ // whole turn. Taking the newest instead would favour a per-prompt injection that
851
+ // arrived seconds before the nudge.
852
+ export function citationHint(shown) {
853
+ const ids = (Array.isArray(shown) ? shown : [...(shown || [])]).filter(
854
+ (id) => typeof id === 'string' && id.length > 0,
855
+ );
856
+ if (ids.length === 0) return '';
857
+ const named = ids.slice(0, CITED_HINT_MAX);
858
+ const rest = ids.length - named.length;
859
+ const more = rest > 0 ? ` (+${rest} more)` : '';
860
+ return (
861
+ ` If any injected lesson shaped this turn, name it on the write as` +
862
+ ` cited: [${named.map((id) => JSON.stringify(id)).join(', ')}]${more} —` +
863
+ ` drop the ones you did not use. LoreKit can see which lessons were delivered,` +
864
+ ` never which were applied; this is the only signal that says so.`
865
+ );
866
+ }
867
+
831
868
  // One-line phrases for the detected friction reason codes (see core/friction.mjs),
832
869
  // so the nudge names what happened instead of a generic prompt.
833
870
  const REASON_PHRASES = {
@@ -851,11 +888,14 @@ function describeReasons(reasons) {
851
888
  // scopeDefaults when the repo/user config defines them. `opts.reasons` is the
852
889
  // detected friction reason codes (from core/friction.mjs) when `hooks.stop` is
853
890
  // `friction`; when present the nudge names them so the reflection is grounded.
854
- // Kept to a single line — the lore deep-link lives on the write CONFIRMATION,
855
- // which is where a link is actually actionable.
856
- export function retrospectiveNudge(scope, control, { reasons = [] } = {}) {
891
+ // `opts.shown` is the session's injected `scope::key` set (core/state.mjs), which
892
+ // turns the citation ask into a list the agent can pick from rather than a
893
+ // convention it has to remember; omitting it drops the clause entirely.
894
+ // The lore deep-link lives on the write CONFIRMATION, which is where a link is
895
+ // actually actionable.
896
+ export function retrospectiveNudge(scope, control, { reasons = [], shown = [] } = {}) {
857
897
  const writeScope = scope.repoScope || 'global';
858
- const hint = `${tagsHint(writeScope, control)}${ttlHint(writeScope, control)}`;
898
+ const hint = `${tagsHint(writeScope, control)}${ttlHint(writeScope, control)}${citationHint(shown)}`;
859
899
  const instruction = control && control.hooksInstructions && control.hooksInstructions.Stop
860
900
  ? `\n\nProject instruction: ${control.hooksInstructions.Stop}` : '';
861
901
  const detected = describeReasons(reasons);
@@ -55,6 +55,7 @@ const FLAG = {
55
55
  'ttl-days': { desc: 'Days until auto-expiry (1..365)', arg: 'n' },
56
56
  'clear-ttl': { desc: 'Remove any existing expiry' },
57
57
  org: { desc: "Write to this org's scope (remote)", arg: 'slug' },
58
+ cited: { desc: 'scope::key refs this lesson applied (remote)', arg: 'a,b' },
58
59
  'origin-repo': { desc: 'Override the provenance repository', arg: 'owner/name' },
59
60
  'origin-branch': { desc: 'Override the provenance branch', arg: 'branch' },
60
61
  'origin-commit': { desc: 'Override the provenance commit', arg: 'sha' },
@@ -77,7 +78,8 @@ const FLAG = {
77
78
  'min-age-days': { desc: 'Match lessons at least n days old', arg: 'n' },
78
79
  'unseen-days': { desc: 'Match lessons unseen for at least n days', arg: 'n' },
79
80
  'max-seen-count': { desc: 'Match lessons that recurred at most n times', arg: 'n' },
80
- 'max-read-count': { desc: 'Match lessons read at most n times (bulk reads count)', arg: 'n' },
81
+ 'max-read-count': { desc: 'Match lessons delivered at most n times (bulk reads count)', arg: 'n' },
82
+ 'max-opened-count': { desc: 'Match lessons deliberately fetched at most n times (0 = never chosen)', arg: 'n' },
81
83
  run: { desc: 'Archive the matches instead of previewing' },
82
84
  name: { desc: 'Policy name', arg: 'name' },
83
85
  enabled: { desc: 'Turn auto-mode on' },
@@ -86,6 +88,7 @@ const FLAG = {
86
88
  'clear-unseen-days': { desc: 'Remove the unseen-days condition' },
87
89
  'clear-max-seen-count': { desc: 'Remove the max-seen-count condition' },
88
90
  'clear-max-read-count': { desc: 'Remove the max-read-count condition' },
91
+ 'clear-max-opened-count': { desc: 'Remove the max-opened-count condition' },
89
92
  off: { desc: 'Unprotect instead of protect' },
90
93
  };
91
94
 
@@ -138,7 +141,7 @@ const COMMANDS = [
138
141
  flags: ['yes', 'endpoint', 'token'] },
139
142
  { name: 'write', summary: 'Create or update a memory', positional: 'address',
140
143
  flags: ['dir', 'scope', 'key', 'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'clear-ttl',
141
- 'org', 'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin', 'remote', 'local',
144
+ 'org', 'cited', 'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin', 'remote', 'local',
142
145
  'json', 'endpoint', 'token', 'store'] },
143
146
  { name: 'archive', summary: 'Hide a memory without losing it', positional: 'address',
144
147
  flags: ['scope', 'key', 'remote', 'local', 'json'] },
@@ -153,11 +156,13 @@ const COMMANDS = [
153
156
  flags: ['yes', 'json', 'endpoint', 'token'] },
154
157
  { name: 'groom', summary: 'Preview or run a retention sweep',
155
158
  values: { mode: ['review', 'auto'] },
156
- flags: ['policy-id', 'scope', 'min-age-days', 'unseen-days', 'max-seen-count', 'max-read-count', 'run', 'yes', 'json', 'endpoint', 'token'] },
159
+ flags: ['policy-id', 'scope', 'min-age-days', 'unseen-days', 'max-seen-count', 'max-read-count', 'max-opened-count',
160
+ 'run', 'yes', 'json', 'endpoint', 'token'] },
157
161
  { name: 'policy', summary: 'Manage saved retention rules',
158
162
  values: { mode: ['review', 'auto'] },
159
163
  flags: ['scope', 'name', 'mode', 'enabled', 'disabled', 'min-age-days', 'unseen-days', 'max-seen-count', 'max-read-count',
160
- 'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count', 'yes', 'json', 'endpoint', 'token'] },
164
+ 'max-opened-count', 'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count',
165
+ 'clear-max-opened-count', 'yes', 'json', 'endpoint', 'token'] },
161
166
  { name: 'protect', summary: 'Mark a memory protected, excluded from every grooming sweep', positional: 'address',
162
167
  flags: ['off', 'scope', 'key', 'json', 'endpoint', 'token'] },
163
168
  { name: 'pin', summary: 'Shorthand for `protect` (protected=true)', positional: 'address',
@@ -52,6 +52,8 @@ export const mirrorPairs = [
52
52
  { core: 'packages/mcp-core/src/auth/org-permissions.ts', edge: 'supabase/functions/mcp/org-permissions.ts', driftChecked: true },
53
53
  { core: 'packages/mcp-core/src/webhook/webhook-installation.ts', edge: 'supabase/functions/mcp/webhook-installation.ts', driftChecked: true },
54
54
  { core: 'packages/mcp-core/src/webhook/github-app-jwt.ts', edge: 'supabase/functions/mcp/github-app-jwt.ts', driftChecked: true },
55
+ { core: 'packages/mcp-core/src/webhook/comment-relevance.ts', edge: 'supabase/functions/mcp/comment-relevance.ts', driftChecked: true },
56
+ { core: 'packages/mcp-core/src/webhook/github-review-parse.ts', edge: 'supabase/functions/mcp/github-review-parse.ts', driftChecked: true },
55
57
  { core: 'packages/mcp-core/src/telemetry/trace-context.ts', edge: 'supabase/functions/_shared/telemetry/trace-context.ts', driftChecked: true },
56
58
  { core: 'packages/mcp-core/src/rest/rest-tool-name.ts', edge: 'supabase/functions/_shared/rest/rest-tool-name.ts', driftChecked: true },
57
59
  // The `X-LoreKit-Session-Kind` validator (migration 00082). Also has a
@@ -283,7 +283,7 @@ class RemoteStore {
283
283
  async write(args = {}) {
284
284
  const {
285
285
  scope, key, value, tags, source_agent, trigger, kind, host, org, ttl_days, clear_ttl, created_at,
286
- origin_repo, origin_branch, origin_commit, origin_pr,
286
+ cited, origin_repo, origin_branch, origin_commit, origin_pr,
287
287
  } = args;
288
288
  const body = { scope, key, value };
289
289
  if (tags !== undefined) body.tags = tags;
@@ -295,6 +295,11 @@ class RemoteStore {
295
295
  if (ttl_days !== undefined) body.ttl_days = ttl_days;
296
296
  if (clear_ttl !== undefined) body.clear_ttl = clear_ttl;
297
297
  if (created_at !== undefined) body.created_at = created_at;
298
+ // Citations are a fact about the RUN this write belongs to, not a column on
299
+ // the row (migration 00107) — the server resolves each ref, records the
300
+ // credit, and drops what it cannot resolve. Sent verbatim: which refs are
301
+ // legal is the server's grammar to decide, not this transport's.
302
+ if (cited !== undefined) body.cited = cited;
298
303
  // Provenance — only sent when known. Omitting a field leaves whatever the
299
304
  // row already recorded intact (the RPC coalesces), which is what makes a
300
305
  // write from a machine with no git context non-destructive.
@@ -707,8 +712,8 @@ class RemoteStore {
707
712
  }
708
713
 
709
714
  // POST /policies → the created policy object.
710
- async policyCreate({ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count } = {}) {
711
- const body = stripUndefined({ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count });
715
+ async policyCreate({ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count } = {}) {
716
+ const body = stripUndefined({ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count });
712
717
  const res = await this._rest('/memories/policies', { method: 'POST', body });
713
718
  if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
714
719
  return { ok: true, policy: res.data };
@@ -735,8 +740,8 @@ class RemoteStore {
735
740
  // POST /groom/preview → { count, keys: [{ scope, key }] } — the SAME
736
741
  // candidates a groom() run would archive. Pass either `policy_id` or
737
742
  // `scope` (+ optional conditions), never both.
738
- async groomPreview({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count } = {}) {
739
- const body = stripUndefined({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count });
743
+ async groomPreview({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count } = {}) {
744
+ const body = stripUndefined({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count });
740
745
  const res = await this._rest('/memories/groom/preview', { method: 'POST', body });
741
746
  if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
742
747
  return { ok: true, count: res.data?.count ?? 0, keys: Array.isArray(res.data?.keys) ? res.data.keys : [] };
@@ -744,8 +749,8 @@ class RemoteStore {
744
749
 
745
750
  // POST /groom/run → archives every previewed candidate, in one transaction.
746
751
  // Soft-archive only (recoverable via restore); never hard-deletes.
747
- async groomRun({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count } = {}) {
748
- const body = stripUndefined({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count });
752
+ async groomRun({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count } = {}) {
753
+ const body = stripUndefined({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count });
749
754
  const res = await this._rest('/memories/groom/run', { method: 'POST', body });
750
755
  if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
751
756
  return { ok: true, archived: res.data?.archived ?? 0, keys: Array.isArray(res.data?.keys) ? res.data.keys : [] };
@@ -147,6 +147,13 @@ export const MCP_TOOL_DEFS = [
147
147
  "type": "integer",
148
148
  "minimum": 1,
149
149
  "description": "Provenance: the pull request number this memory was recorded from. Combined with origin_repo it renders as a link to the PR."
150
+ },
151
+ "cited": {
152
+ "type": "array",
153
+ "items": {
154
+ "type": "string"
155
+ },
156
+ "description": "The lessons that actually shaped this run, as `scope::key` strings — exactly the labels they were injected under. Name only the ones you applied; an empty or omitted list is the honest answer when none were. Silently ignored where a reference names nothing you can see, so a wrong guess costs nothing and the write always succeeds."
150
157
  }
151
158
  }
152
159
  }
@@ -535,6 +542,12 @@ export const MCP_TOOL_DEFS = [
535
542
  "maximum": 100000,
536
543
  "description": "Match only lessons READ at most this many times — the counter that says whether a lesson was ever actually used, unlike `max_seen_count` which counts WRITES. Counts EVERY read, a bulk `memory.list`/`memory.search` appearance included (unlike `unseen_days`, which only counts targeted opens). Reads have only been counted since the counter shipped, so a long-lived lesson can show a low count it never earned."
537
544
  },
545
+ "max_opened_count": {
546
+ "type": "integer",
547
+ "minimum": 0,
548
+ "maximum": 100000,
549
+ "description": "Match only lessons an agent DELIBERATELY fetched at most this many times — the count behind `last_opened_at`. Unlike `max_read_count` a bulk `memory.list`/`memory.search` ride-along does NOT count, so `0` means \"nothing ever chose this\" rather than \"this lesson happens to live in a narrow scope\". Backfilled over the whole recorded history, so it carries no cutover caveat."
550
+ },
538
551
  "tags": {
539
552
  "type": "array",
540
553
  "items": {
@@ -720,6 +733,12 @@ export const MCP_TOOL_DEFS = [
720
733
  "maximum": 100000,
721
734
  "description": "Match only lessons READ at most this many times — the counter that says whether a lesson was ever actually used, unlike `max_seen_count` which counts WRITES. Counts EVERY read, a bulk `memory.list`/`memory.search` appearance included (unlike `unseen_days`, which only counts targeted opens). Reads have only been counted since the counter shipped, so a long-lived lesson can show a low count it never earned. Omit to leave unchanged; pass explicit null to clear."
722
735
  },
736
+ "max_opened_count": {
737
+ "type": "integer",
738
+ "minimum": 0,
739
+ "maximum": 100000,
740
+ "description": "Match only lessons an agent DELIBERATELY fetched at most this many times — the count behind `last_opened_at`. Unlike `max_read_count` a bulk `memory.list`/`memory.search` ride-along does NOT count, so `0` means \"nothing ever chose this\" rather than \"this lesson happens to live in a narrow scope\". Backfilled over the whole recorded history, so it carries no cutover caveat. Omit to leave unchanged; pass explicit null to clear."
741
+ },
723
742
  "tags": {
724
743
  "type": "array",
725
744
  "items": {
@@ -906,6 +925,12 @@ export const MCP_TOOL_DEFS = [
906
925
  "maximum": 100000,
907
926
  "description": "Match only lessons READ at most this many times — the counter that says whether a lesson was ever actually used, unlike `max_seen_count` which counts WRITES. Counts EVERY read, a bulk `memory.list`/`memory.search` appearance included (unlike `unseen_days`, which only counts targeted opens). Reads have only been counted since the counter shipped, so a long-lived lesson can show a low count it never earned."
908
927
  },
928
+ "max_opened_count": {
929
+ "type": "integer",
930
+ "minimum": 0,
931
+ "maximum": 100000,
932
+ "description": "Match only lessons an agent DELIBERATELY fetched at most this many times — the count behind `last_opened_at`. Unlike `max_read_count` a bulk `memory.list`/`memory.search` ride-along does NOT count, so `0` means \"nothing ever chose this\" rather than \"this lesson happens to live in a narrow scope\". Backfilled over the whole recorded history, so it carries no cutover caveat."
933
+ },
909
934
  "tags": {
910
935
  "type": "array",
911
936
  "items": {
@@ -1076,6 +1101,12 @@ export const MCP_TOOL_DEFS = [
1076
1101
  "maximum": 100000,
1077
1102
  "description": "Match only lessons READ at most this many times — the counter that says whether a lesson was ever actually used, unlike `max_seen_count` which counts WRITES. Counts EVERY read, a bulk `memory.list`/`memory.search` appearance included (unlike `unseen_days`, which only counts targeted opens). Reads have only been counted since the counter shipped, so a long-lived lesson can show a low count it never earned."
1078
1103
  },
1104
+ "max_opened_count": {
1105
+ "type": "integer",
1106
+ "minimum": 0,
1107
+ "maximum": 100000,
1108
+ "description": "Match only lessons an agent DELIBERATELY fetched at most this many times — the count behind `last_opened_at`. Unlike `max_read_count` a bulk `memory.list`/`memory.search` ride-along does NOT count, so `0` means \"nothing ever chose this\" rather than \"this lesson happens to live in a narrow scope\". Backfilled over the whole recorded history, so it carries no cutover caveat."
1109
+ },
1079
1110
  "tags": {
1080
1111
  "type": "array",
1081
1112
  "items": {