@lorekit/cli 1.45.0 → 1.47.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/README.md CHANGED
@@ -466,9 +466,9 @@ that lesson's detail sheet. It sets **both** the `lesson` param (which opens the
466
466
  sheet) and `scope` — not because scope is needed to find the lesson (the sidebar
467
467
  reads one unfiltered recent set), but so the Explorer list *behind* the sheet is
468
468
  filtered to the lesson's own scope. Filter flags mirror the Explorer: `--q`
469
- (search), `--owner <all|personal|orgId>`, `--tags <a,b,c>` (label filter, AND
469
+ (search), `--owner <all|personal|org-slug>`, `--tags <a,b,c>` (label filter, AND
470
470
  across labels; comma-separated or a JSON array), `--range`/`--from`/`--to`,
471
- `--archived`, `--view <scope|time>`.
471
+ `--archived`.
472
472
 
473
473
  Every param is `encodeURIComponent(JSON.stringify(value))` — the exact inverse of
474
474
  how the dashboard's `useUrlState` reads it back (`JSON.parse`, falling back to the
@@ -717,6 +717,26 @@ Both files share this schema — all fields optional:
717
717
  // memories are RANKED before the budget is spent, so what
718
718
  // survives is the most-recurring and most-recent, not the newest
719
719
 
720
+ "hooks.sessionStart.loopCap": 2,
721
+ // how many memories one self-improvement loop (a
722
+ // "loop::<bucket>" tag) may contribute to that block
723
+ // (default 2, bounded 0–40; 0 excludes loop buckets
724
+ // entirely so only general memories are read). Clamped,
725
+ // not rejected; repo wins over user with the same
726
+ // declared-value-owns-the-layer rule as maxChars
727
+
728
+ "hooks.sessionStart.branchHint": "on",
729
+ // whether the block is nudged toward the current git
730
+ // branch's topic — on "feat/embedding-pipeline",
731
+ // embedding memories are lifted (the leading type/author
732
+ // segment is ignored). Default "on"; it only ever lifts
733
+ // an on-topic memory, never buries one. "off" restores
734
+ // the plain most-recurring / most-recent read. Repo wins
735
+ // over user, but — following hooks.userPrompt, not the
736
+ // maxChars layer-lock — a declared-but-unparseable repo
737
+ // value FALLS THROUGH to a valid user value rather than
738
+ // owning the layer
739
+
720
740
  "hooks.adapter": "claude",
721
741
  // explicit adapter when auto-detection is ambiguous
722
742
  // values: "claude" | "cursor" | "codex"
package/bin/lorekit.mjs CHANGED
@@ -88,7 +88,7 @@ ${c.bold('Commands')}
88
88
  link (url) Print a shareable dashboard deep-link URL for the current context,
89
89
  a scope, or a specific lesson (opens its detail sheet). No args
90
90
  links to the cwd's most-specific scope. Filter flags mirror the
91
- Explorer (--q / --owner / --tags / --range / --archived / --view);
91
+ Explorer (--q / --owner / --tags / --range / --archived);
92
92
  --base or LOREKIT_APP_URL override the dashboard host. --json. Pipe it:
93
93
  lorekit link | pbcopy.
94
94
  bootstrap Apply the BYOD schema to a user-supplied Supabase database.
@@ -585,13 +585,12 @@ ${c.bold('Options')}
585
585
  --key <key> Name the key explicitly — the way to link to a key
586
586
  that itself contains \`::\`
587
587
  --q <text> Pre-fill the Explorer search box
588
- --owner <o> Ownership filter: all | personal | <orgId>
588
+ --owner <o> Ownership filter: all | personal | <org-slug>
589
589
  --tags <a,b,c> Label filter (AND across labels); comma-separated or a JSON array
590
590
  --range <json> Date range as {"from":"YYYY-MM-DD","to":"YYYY-MM-DD"}
591
591
  --from <date> Range start (shorthand for --range)
592
592
  --to <date> Range end (shorthand for --range)
593
593
  --archived Include archived memories
594
- --view <mode> Explorer view: scope | time
595
594
  --base <url> Dashboard base URL (else LOREKIT_APP_URL, default https://lorekit.io)
596
595
  --json Machine-readable { url, surface, base, params }
597
596
 
@@ -658,7 +657,13 @@ const KNOWN_FLAGS = [
658
657
  'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'mcp-json', 'force', 'deep', 'adapter',
659
658
  'event', 'json', 'scope', 'key', 'threshold', 'help', 'version', 'telemetry',
660
659
  'value', 'tags', 'source-agent', 'trigger', 'kind', 'host', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
661
- 'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
660
+ // `view` is accepted-and-IGNORED, not documented: the Explorer dropped the
661
+ // scope/time tab so the flag is a no-op, but `link` is a HUMAN_COMMAND that
662
+ // rejects UNKNOWN options — so keeping `view` listed for a release stops an
663
+ // existing `lorekit link --view time` from hard-failing on an unknown-option
664
+ // error. It is parsed and discarded (nothing reads `args.view`). Remove it once
665
+ // 1.x links have aged out.
666
+ 'link', 'base', 'q', 'owner', 'range', 'archived', 'view',
662
667
  'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
663
668
  // Scale-aware survey flags
664
669
  'all', 'max', 'since', 'until', 'key-prefix', 'cluster-by-key',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.45.0",
3
+ "version": "1.47.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": {
package/src/control.mjs CHANGED
@@ -10,6 +10,8 @@
10
10
  // hooks.stop — Stop-hook gating ("friction" default | "always" | "off")
11
11
  // hooks.sessionStart — injected-block shape ("hybrid" default | "index" | "map")
12
12
  // hooks.sessionStart.maxChars — character budget for that block (default 1500)
13
+ // hooks.sessionStart.loopCap — max lessons per self-improvement loop bucket (default 2; 0 excludes them)
14
+ // hooks.sessionStart.branchHint — nudge the read toward the git branch topic ("on" default | "off")
13
15
  // hooks.userPrompt — the per-turn relevance pull ("on" default | "off")
14
16
  // hooks.adapter — explicit adapter override ("claude" | "cursor" | "codex")
15
17
  //
@@ -140,6 +142,31 @@ export function normalizeSessionStartMaxChars(v) {
140
142
  return i;
141
143
  }
142
144
 
145
+ // The default per-loop-bucket cap for the SessionStart read, and the bounds a
146
+ // configured one is held to. 2 keeps each self-improvement loop's top couple of
147
+ // lessons without letting one bucket flood a general session; 0 is a meaningful
148
+ // setting — exclude loop buckets entirely and read only general codebase lessons.
149
+ // The ceiling is a generous backstop against a typo'd cap, not a shared constant:
150
+ // `core/lessons.mjs` bounds the whole read at its own hard lesson ceiling
151
+ // downstream, so any loopCap at or above that never binds regardless of the exact
152
+ // number here — they are deliberately independent, not kept in lockstep.
153
+ export const DEFAULT_SESSION_START_LOOP_CAP = 2;
154
+ export const MIN_SESSION_START_LOOP_CAP = 0;
155
+ export const MAX_SESSION_START_LOOP_CAP = 40;
156
+
157
+ // Clamp a configured loop cap into range, or null when it is not a usable number
158
+ // (absent, a bare string, NaN). Total: the caller substitutes the default for
159
+ // null. Out-of-range CLAMPS rather than rejecting, like the maxChars budget — and
160
+ // `0` is honoured, not floored away, because "exclude loop buckets" is a real ask.
161
+ export function normalizeSessionStartLoopCap(v) {
162
+ const n = firstNumber(v);
163
+ if (n === null) return null;
164
+ const i = Math.round(n);
165
+ if (i < MIN_SESSION_START_LOOP_CAP) return MIN_SESSION_START_LOOP_CAP;
166
+ if (i > MAX_SESSION_START_LOOP_CAP) return MAX_SESSION_START_LOOP_CAP;
167
+ return i;
168
+ }
169
+
143
170
  // A config value that is meant to be a number, or null when it is absent or is
144
171
  // something else entirely. Numeric strings are accepted because JSON configs get
145
172
  // hand-edited; the RANGE check happens later, at the point of use.
@@ -328,6 +355,26 @@ export function resolveControl({
328
355
  const hooksSessionStartMaxChars =
329
356
  normalizeSessionStartMaxChars(sessionStartMaxCharsRaw) ?? DEFAULT_SESSION_START_MAX_CHARS;
330
357
 
358
+ // `hooks.sessionStart.loopCap` — how many lessons one `loop::<bucket>` may
359
+ // contribute. Same layer-before-parse rule as maxChars (declaresScalar): a repo
360
+ // that declared a cap owns it even when the value is garbage, so two people on
361
+ // the same commit get the same read. `0` is a valid, deliberate value, so the
362
+ // default is only substituted when NOTHING usable was declared.
363
+ const sessionStartLoopCapRaw = declaresScalar(repoConfig['hooks.sessionStart.loopCap'])
364
+ ? repoConfig['hooks.sessionStart.loopCap']
365
+ : userConfig['hooks.sessionStart.loopCap'];
366
+ const normalizedLoopCap = normalizeSessionStartLoopCap(sessionStartLoopCapRaw);
367
+ const hooksSessionStartLoopCap =
368
+ normalizedLoopCap === null ? DEFAULT_SESSION_START_LOOP_CAP : normalizedLoopCap;
369
+
370
+ // `hooks.sessionStart.branchHint` — whether the read is nudged toward the git
371
+ // branch topic. On/off (the `hooks.userPrompt` vocabulary), default `on`, repo
372
+ // layer wins. Off restores the pre-branch-query read: recency + salience only.
373
+ const hooksSessionStartBranchHint =
374
+ normalizeUserPromptMode(repoConfig['hooks.sessionStart.branchHint']) ||
375
+ normalizeUserPromptMode(userConfig['hooks.sessionStart.branchHint']) ||
376
+ 'on';
377
+
331
378
  // `hooks.adapter` — repo layer wins over user layer (explicit project override).
332
379
  const hooksAdapter =
333
380
  (typeof repoConfig['hooks.adapter'] === 'string' && repoConfig['hooks.adapter'].trim()) ||
@@ -368,6 +415,8 @@ export function resolveControl({
368
415
  hooksUserPrompt,
369
416
  hooksSessionStart,
370
417
  hooksSessionStartMaxChars,
418
+ hooksSessionStartLoopCap,
419
+ hooksSessionStartBranchHint,
371
420
  hooksAdapter,
372
421
  hooksInstructions,
373
422
  };
@@ -75,7 +75,9 @@ const HARD_LESSON_CEILING = 40;
75
75
  // back by their own host through a tag filter. Two per bucket keeps the signal
76
76
  // (a loop's top couple of lessons still surface) without the flood; general,
77
77
  // non-loop lessons are never capped. Bounded, not shaped: on a store with no
78
- // loop lessons it never binds.
78
+ // loop lessons it never binds. This is the DEFAULT — a repo/user can override it
79
+ // with `hooks.sessionStart.loopCap` (0 excludes loop buckets entirely), which
80
+ // `fetchLessons` receives as its `loopCap` option.
79
81
  const SESSION_START_LOOP_CAP = 2;
80
82
 
81
83
  // How many lessons ride along with the scope map in `map` mode. Small on
@@ -115,7 +117,11 @@ export const SCOPE_READ_LIMIT = 25;
115
117
  // that already hold a resolved scope and for tests that need a deterministic
116
118
  // branch (deriveScope shells out to git, so the ambient branch — often a
117
119
  // detached `HEAD` in CI — cannot exercise the branch-seeded read otherwise).
118
- export async function fetchLessons(store, cwd, { now = Date.now(), scope: scopeOverride = null } = {}) {
120
+ export async function fetchLessons(
121
+ store,
122
+ cwd,
123
+ { now = Date.now(), scope: scopeOverride = null, loopCap = SESSION_START_LOOP_CAP, branchHint = true } = {},
124
+ ) {
119
125
  const scope = scopeOverride || deriveScope(cwd);
120
126
  // Issued BEFORE the per-scope read loop and awaited after it. Nothing in the
121
127
  // inventory depends on the loop, so awaiting it afterwards would cost a
@@ -197,11 +203,12 @@ export async function fetchLessons(store, cwd, { now = Date.now(), scope: scopeO
197
203
  // objective, and scored with different options those would not line up with the
198
204
  // sorted order. `k` is diversification-only; `scopeOrder` is ranking-only and
199
205
  // simply ignored by the diversifier's destructuring.
200
- const rankOpts = sessionRankOpts(scope, now);
206
+ const rankOpts = sessionRankOpts(scope, now, { branchHint });
201
207
  const ranked = rankLessons(winners, rankOpts);
202
208
 
203
209
  // AUDIENCE CAP before diversification: no single self-improvement loop may
204
- // take more than `SESSION_START_LOOP_CAP` of the injected slots, so a general
210
+ // take more than `loopCap` (the `hooks.sessionStart.loopCap` option, default
211
+ // `SESSION_START_LOOP_CAP`) of the injected slots, so a general
205
212
  // session is not flooded with one bot's private `loop::<bucket>` bookkeeping.
206
213
  // General (non-loop) lessons pass through uncapped — they are what the cap
207
214
  // frees room for. Applied to the ranked list so the survivors are each
@@ -217,7 +224,7 @@ export async function fetchLessons(store, cwd, { now = Date.now(), scope: scopeO
217
224
  // which the bounded read never fetched. Reaching those is the recency-window
218
225
  // limit (the `order=rank` CANDIDATE_LIMIT problem, one scope down), not this
219
226
  // cap's to solve; the cap still does its job of unflooding across scopes.
220
- const capped = capPerBucket(ranked, { cap: SESSION_START_LOOP_CAP, bucketOf: loopBucketOf });
227
+ const capped = capPerBucket(ranked, { cap: loopCap, bucketOf: loopBucketOf });
221
228
 
222
229
  // ── the scope map: EXACT counts when the store can enumerate ───────────────
223
230
  //
@@ -587,9 +594,12 @@ export function branchQueryTerms(scope) {
587
594
  // the branch never matched. `fetchLessons` feeds the ONE object this returns to
588
595
  // both `rankLessons` and (spread) the diversifier, so their scores agree on
589
596
  // terms and the clock.
590
- export function sessionRankOpts(scope, now) {
597
+ export function sessionRankOpts(scope, now, { branchHint = true } = {}) {
591
598
  return {
592
- terms: branchQueryTerms(scope),
599
+ // `branchHint: false` (config `hooks.sessionStart.branchHint: off`) restores
600
+ // the pre-branch-query read — no terms, so relevance contributes nothing and
601
+ // the order is recency + salience.
602
+ terms: branchHint ? branchQueryTerms(scope) : [],
593
603
  now,
594
604
  scopeOrder: scope && scope.readOrder ? scope.readOrder : null,
595
605
  };
@@ -36,7 +36,7 @@ export const LORE_PARAM_DEFAULTS = {
36
36
  // accepts ISO instants in that arm and a relative { preset: '7d' } arm
37
37
  // (packages/web/src/lib/time-range.ts); neither has a CLI flag yet.
38
38
  range: null,
39
- owner: 'all', // 'all' | 'personal' | { orgId }
39
+ owner: 'all', // 'all' | 'personal' | '<org-slug>' folds into an owner filter (00064)
40
40
  // Filter[] | null — the Explorer's multi-dimension filter bar (label / agent /
41
41
  // trigger / repo / branch / pr). `null`, NOT `[]`, is the default on purpose:
42
42
  // the app has to tell "the param is absent" from "the bar is explicitly
@@ -46,7 +46,6 @@ export const LORE_PARAM_DEFAULTS = {
46
46
  // "unfiltered".
47
47
  filters: null,
48
48
  tags: [], // string[] — legacy label filter (AND across labels); [] means "no filter". Still READ by the app, superseded by `filters`
49
- view: 'scope', // 'scope' | 'time'
50
49
  // 'active' | 'archived' | 'expiring' | null — the Explorer's Status control.
51
50
  // `null`, NOT 'active', is the default for `filters`' reason: the app has to
52
51
  // tell "absent" from an explicit choice, because an absent `status` falls back
@@ -60,9 +59,9 @@ export const LORE_PARAM_DEFAULTS = {
60
59
 
61
60
  // A stable, readable param order (also makes URLs deterministic for tests).
62
61
  // Mirrors the `useUrlState` call order in `LoreExplorer.tsx` (+ the `lesson`
63
- // param last), so `filters` and `tags` sit between `owner` and `view`. `scope`
62
+ // param last), so `filters` and `tags` sit between `owner` and `status`. `scope`
64
63
  // precedes `lesson` so a lesson link reads `?scope=…&lesson=…`.
65
- const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'filters', 'tags', 'view', 'status', 'archived', 'lesson'];
64
+ const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'filters', 'tags', 'status', 'archived', 'lesson'];
66
65
 
67
66
  // Strip trailing slashes from a base URL, falling back to the default when the
68
67
  // input is empty/absent. Pure.
@@ -148,18 +147,18 @@ export { resolveScopeArg, resolveScopeKeyArgs, isScopeString, scopeIssue } from
148
147
 
149
148
  // ── Flag → param coercion (pure, shared by the `link` command) ────────────────
150
149
 
151
- // Coerce the `--owner` flag to an `OwnerFilter`: 'all' (default) / 'personal' /
152
- // any other non-empty string`{ orgId }`. Pure.
150
+ // Coerce the `--owner` flag to the legacy `owner` param value: `personal`, an
151
+ // org SLUG, or `all` (default omitted). Returns the STRING the app folds into
152
+ // an owner filter (`filtersFromLegacyOwner`) when the `filters` param is absent
153
+ // — which is why the CLI keeps writing the legacy param rather than `filters`:
154
+ // `owner` and the legacy `tags` param fold together, so `--owner acme --tags
155
+ // perf` yields BOTH, whereas a `filters` param would make the app ignore the
156
+ // legacy tags. NOT the old `{orgId}` OBJECT: the owner facet keys on the SLUG,
157
+ // and the app cannot resolve a uuid to a slug in this pure path, so the object
158
+ // form silently dropped the filter it named (00064). A slug lands verbatim. Pure.
153
159
  export function parseOwnerArg(owner) {
154
- if (typeof owner !== 'string' || !owner || owner === 'all') return 'all';
155
- if (owner === 'personal') return 'personal';
156
- return { orgId: owner };
157
- }
158
-
159
- // Coerce the `--view` flag to a `ViewMode`: only 'time' is non-default; anything
160
- // else (incl. absent/invalid) → 'scope'. Pure.
161
- export function parseViewArg(view) {
162
- return view === 'time' ? 'time' : 'scope';
160
+ if (typeof owner !== 'string' || !owner) return 'all';
161
+ return owner;
163
162
  }
164
163
 
165
164
  // Coerce the `--tags` flag to a normalized `string[]` label filter, mirroring the
package/src/hook.mjs CHANGED
@@ -115,7 +115,10 @@ async function run(args) {
115
115
  }
116
116
  return 0;
117
117
  }
118
- const { scope: readScope, lessons, scopeCounts, applicable } = await fetchLessons(store, root);
118
+ const { scope: readScope, lessons, scopeCounts, applicable } = await fetchLessons(store, root, {
119
+ loopCap: control.hooksSessionStartLoopCap,
120
+ branchHint: control.hooksSessionStartBranchHint !== 'off',
121
+ });
119
122
  emit(formatLessons(lessons, readScope, {
120
123
  instruction: sessionInstruction,
121
124
  mode: control.hooksSessionStart,
package/src/link.mjs CHANGED
@@ -22,7 +22,6 @@ import {
22
22
  buildLoreUrl,
23
23
  mostSpecificScope,
24
24
  parseOwnerArg,
25
- parseViewArg,
26
25
  parseRangeArg,
27
26
  parseTagsArg,
28
27
  resolveScopeKeyArgs,
@@ -55,7 +54,6 @@ export async function link(args) {
55
54
  // Filter flags (all optional; each JSON-encoded + default-omitted downstream).
56
55
  const q = typeof args.q === 'string' ? args.q : '';
57
56
  const owner = parseOwnerArg(args.owner);
58
- const view = parseViewArg(args.view);
59
57
  const range = parseRangeArg(args);
60
58
  const tags = parseTagsArg(args.tags);
61
59
  const archived = Boolean(args.archived);
@@ -65,7 +63,6 @@ export async function link(args) {
65
63
  (typeof args.scope === 'string' && Boolean(args.scope)) ||
66
64
  Boolean(q) ||
67
65
  owner !== 'all' ||
68
- view !== 'scope' ||
69
66
  range !== null ||
70
67
  tags.length > 0 ||
71
68
  archived;
@@ -84,7 +81,6 @@ export async function link(args) {
84
81
  if (q) params.q = q;
85
82
  if (owner !== 'all') params.owner = owner;
86
83
  if (tags.length) params.tags = tags;
87
- if (view !== 'scope') params.view = view;
88
84
  if (range !== null) params.range = range;
89
85
  if (archived) params.archived = true;
90
86