@lorekit/cli 1.37.0 → 1.39.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 +24 -0
- package/package.json +1 -1
- package/src/control.mjs +78 -0
- package/src/core/lessons.mjs +222 -16
- package/src/deeplink-pure.mjs +15 -3
- package/src/hook.mjs +8 -2
- package/src/lessons-pure.mjs +341 -0
- package/src/store/entry-fields.mjs +79 -0
- package/src/store/format.mjs +6 -0
- package/src/store/local.mjs +27 -2
- package/src/store/remote.mjs +42 -3
- package/src/tree.mjs +10 -3
package/README.md
CHANGED
|
@@ -672,6 +672,30 @@ Both files share this schema — all fields optional:
|
|
|
672
672
|
// transcript; on Cursor/Codex there is none, so "friction"
|
|
673
673
|
// falls back to firing so no lesson is silently lost)
|
|
674
674
|
|
|
675
|
+
"hooks.sessionStart": "hybrid",
|
|
676
|
+
// shape of the block injected at session start:
|
|
677
|
+
// "hybrid" (default) — fill the character budget with the
|
|
678
|
+
// highest-ranked memories, then add one line naming what
|
|
679
|
+
// was left out and where it lives
|
|
680
|
+
// "index" — the same list, no trailing map
|
|
681
|
+
// (truncation is silent)
|
|
682
|
+
// "map" — lead with the scope map plus the
|
|
683
|
+
// three most salient memories
|
|
684
|
+
// repo wins over user; an unrecognised value is ignored and the
|
|
685
|
+
// next layer is tried, so a mistyped repo value falls through to
|
|
686
|
+
// the user layer before defaulting to hybrid
|
|
687
|
+
|
|
688
|
+
"hooks.sessionStart.maxChars": 1500,
|
|
689
|
+
// character budget for that block (default 1500, ~375 tokens)
|
|
690
|
+
// bounded to 200–20000; an out-of-range value is CLAMPED, not
|
|
691
|
+
// rejected — a small number means "keep it short", and honouring
|
|
692
|
+
// the floor is closer to that intent than restoring the default
|
|
693
|
+
// repo wins over user, and a declared-but-unparseable repo value
|
|
694
|
+
// still claims the decision (a typo'd project policy degrades to
|
|
695
|
+
// the default rather than silently becoming a per-machine one)
|
|
696
|
+
// memories are RANKED before the budget is spent, so what
|
|
697
|
+
// survives is the most-recurring and most-recent, not the newest
|
|
698
|
+
|
|
675
699
|
"hooks.adapter": "claude",
|
|
676
700
|
// explicit adapter when auto-detection is ambiguous
|
|
677
701
|
// values: "claude" | "cursor" | "codex"
|
package/package.json
CHANGED
package/src/control.mjs
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// ttl.default — days until a write with no explicit TTL expires
|
|
9
9
|
// hooks.disabled — array of hook event names to suppress (e.g. ["Stop"])
|
|
10
10
|
// hooks.stop — Stop-hook gating ("friction" default | "always" | "off")
|
|
11
|
+
// hooks.sessionStart — injected-block shape ("hybrid" default | "index" | "map")
|
|
12
|
+
// hooks.sessionStart.maxChars — character budget for that block (default 1500)
|
|
11
13
|
// hooks.adapter — explicit adapter override ("claude" | "cursor" | "codex")
|
|
12
14
|
//
|
|
13
15
|
// Two layers of config, two kinds of statement:
|
|
@@ -55,6 +57,56 @@ export function normalizeStopMode(v) {
|
|
|
55
57
|
return null;
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
// SessionStart shape: what the injected block LOOKS like once the budget is
|
|
61
|
+
// spent. All three spend the same character budget; they differ in what they do
|
|
62
|
+
// with the lessons that did not fit.
|
|
63
|
+
// hybrid (default) — top-ranked lessons, then a one-line scope map naming what
|
|
64
|
+
// was left out, so nothing is silently invisible.
|
|
65
|
+
// index — lessons only. The pre-budget behaviour, minus the magic
|
|
66
|
+
// count: a big store is simply truncated with no map.
|
|
67
|
+
// map — the scope map plus a handful of the most salient lessons.
|
|
68
|
+
// For a large store where the inventory matters more than
|
|
69
|
+
// any particular lesson.
|
|
70
|
+
// Friendly spellings for the same reason `normalizeStopMode` has them: these are
|
|
71
|
+
// hand-edited JSON files.
|
|
72
|
+
export const SESSION_START_MODES = ['hybrid', 'index', 'map'];
|
|
73
|
+
export function normalizeSessionStartMode(v) {
|
|
74
|
+
if (typeof v !== 'string') return null;
|
|
75
|
+
const s = v.trim().toLowerCase();
|
|
76
|
+
if (['hybrid', 'both', 'auto', 'default'].includes(s)) return 'hybrid';
|
|
77
|
+
if (['index', 'list', 'lessons', 'full'].includes(s)) return 'index';
|
|
78
|
+
if (['map', 'scopes', 'summary', 'toc'].includes(s)) return 'map';
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The default SessionStart character budget, and the bounds a configured one is
|
|
83
|
+
// held to. ~1500 chars is roughly 375 tokens on the 4-chars-per-token heuristic
|
|
84
|
+
// — enough for a dozen index lines plus the frame, and small enough that it
|
|
85
|
+
// stays a footnote in a context window rather than a section of it.
|
|
86
|
+
//
|
|
87
|
+
// The floor is what one header plus one lesson line needs; below it the block
|
|
88
|
+
// would be a header and nothing else, which is worse than not firing. The
|
|
89
|
+
// ceiling is a backstop against a typo'd `"maxChars": 1500000` turning every
|
|
90
|
+
// session start into a wall of text — the hard lesson ceiling in
|
|
91
|
+
// `core/lessons.mjs` bounds it a second time, from the other direction.
|
|
92
|
+
export const DEFAULT_SESSION_START_MAX_CHARS = 1500;
|
|
93
|
+
export const MIN_SESSION_START_MAX_CHARS = 200;
|
|
94
|
+
export const MAX_SESSION_START_MAX_CHARS = 20000;
|
|
95
|
+
|
|
96
|
+
// Clamp a configured budget into the supported range, or null when the value is
|
|
97
|
+
// not a usable number at all (absent, a bare string, NaN). Total: the caller
|
|
98
|
+
// substitutes the default for null. Out-of-range CLAMPS rather than rejecting —
|
|
99
|
+
// a user who wrote `"maxChars": 50` wants a small block, and honouring the floor
|
|
100
|
+
// is closer to that intent than silently restoring the 1500 default.
|
|
101
|
+
export function normalizeSessionStartMaxChars(v) {
|
|
102
|
+
const n = firstNumber(v);
|
|
103
|
+
if (n === null) return null;
|
|
104
|
+
const i = Math.round(n);
|
|
105
|
+
if (i < MIN_SESSION_START_MAX_CHARS) return MIN_SESSION_START_MAX_CHARS;
|
|
106
|
+
if (i > MAX_SESSION_START_MAX_CHARS) return MAX_SESSION_START_MAX_CHARS;
|
|
107
|
+
return i;
|
|
108
|
+
}
|
|
109
|
+
|
|
58
110
|
// A config value that is meant to be a number, or null when it is absent or is
|
|
59
111
|
// something else entirely. Numeric strings are accepted because JSON configs get
|
|
60
112
|
// hand-edited; the RANGE check happens later, at the point of use.
|
|
@@ -204,6 +256,30 @@ export function resolveControl({
|
|
|
204
256
|
normalizeStopMode(userConfig['hooks.stop']) ||
|
|
205
257
|
'friction';
|
|
206
258
|
|
|
259
|
+
// `hooks.sessionStart` — repo layer wins over user layer, default `hybrid`.
|
|
260
|
+
// Chooses the SHAPE of the injected block (see SESSION_START_MODES). An
|
|
261
|
+
// unrecognised value falls through to the next layer and finally to the
|
|
262
|
+
// default, exactly like `hooks.stop`: a mistyped shape must degrade to the
|
|
263
|
+
// sensible one, never blank the injection.
|
|
264
|
+
const hooksSessionStart =
|
|
265
|
+
normalizeSessionStartMode(repoConfig['hooks.sessionStart']) ||
|
|
266
|
+
normalizeSessionStartMode(userConfig['hooks.sessionStart']) ||
|
|
267
|
+
'hybrid';
|
|
268
|
+
|
|
269
|
+
// `hooks.sessionStart.maxChars` — the character budget that block may spend.
|
|
270
|
+
//
|
|
271
|
+
// The LAYER is chosen before the value is parsed, the `ttl.default` rule (see
|
|
272
|
+
// declaresScalar): a repo that declared a budget owns the decision even when
|
|
273
|
+
// the value it declared is garbage, so a typo degrades to the default rather
|
|
274
|
+
// than silently handing the decision to whatever the developer happens to have
|
|
275
|
+
// in their home directory. Two people on the same commit must get the same
|
|
276
|
+
// block.
|
|
277
|
+
const sessionStartMaxCharsRaw = declaresScalar(repoConfig['hooks.sessionStart.maxChars'])
|
|
278
|
+
? repoConfig['hooks.sessionStart.maxChars']
|
|
279
|
+
: userConfig['hooks.sessionStart.maxChars'];
|
|
280
|
+
const hooksSessionStartMaxChars =
|
|
281
|
+
normalizeSessionStartMaxChars(sessionStartMaxCharsRaw) ?? DEFAULT_SESSION_START_MAX_CHARS;
|
|
282
|
+
|
|
207
283
|
// `hooks.adapter` — repo layer wins over user layer (explicit project override).
|
|
208
284
|
const hooksAdapter =
|
|
209
285
|
(typeof repoConfig['hooks.adapter'] === 'string' && repoConfig['hooks.adapter'].trim()) ||
|
|
@@ -242,6 +318,8 @@ export function resolveControl({
|
|
|
242
318
|
scopeDefaults,
|
|
243
319
|
hooksDisabled,
|
|
244
320
|
hooksStop,
|
|
321
|
+
hooksSessionStart,
|
|
322
|
+
hooksSessionStartMaxChars,
|
|
245
323
|
hooksAdapter,
|
|
246
324
|
hooksInstructions,
|
|
247
325
|
};
|
package/src/core/lessons.mjs
CHANGED
|
@@ -8,7 +8,11 @@ import { deriveScope } from '../scope.mjs';
|
|
|
8
8
|
// can't drift from it, and the hot path never pulls in the `lessons-view.mjs`
|
|
9
9
|
// render/`util` stack. (Failure-relevance matching is the store's job now, so
|
|
10
10
|
// the hook no longer needs `matchesQuery` — `search` still does.)
|
|
11
|
-
|
|
11
|
+
// `rankLessons` comes from the same dependency-free module for the same reason:
|
|
12
|
+
// the injected set is chosen by ONE scorer, and a future `memory.relevant` verb
|
|
13
|
+
// must be able to reuse it rather than grow a second ranking with its own idea
|
|
14
|
+
// of what "most useful" means.
|
|
15
|
+
import { resolvePrecedence, rankLessons } from '../lessons-pure.mjs';
|
|
12
16
|
// The deep-link builder is the SAME pure module the `link` command and the
|
|
13
17
|
// `--link` flag use, so the hook's confirmation/nudge links are JSON-encoded
|
|
14
18
|
// correctly (a raw `?scope=global` silently means "all scopes") and can't drift
|
|
@@ -20,9 +24,45 @@ import { loreScopeUrl, buildLessonUrl } from '../deeplink-pure.mjs';
|
|
|
20
24
|
// over MCP, in the agent's context. Advising the number is the only lever the
|
|
21
25
|
// hook has, which is exactly why it must not be a second, hand-kept copy.
|
|
22
26
|
import { resolveDefaultTtlDays, matchesScopePrefix } from '../store/ttl.mjs';
|
|
27
|
+
// The budget default lives with the rest of the config vocabulary in
|
|
28
|
+
// `control.mjs`, so the resolver and the renderer cannot disagree about what an
|
|
29
|
+
// unconfigured workspace gets. `formatLessons` is called directly by tests and
|
|
30
|
+
// by the no-store path in `hook.mjs`, so it needs its own fallback rather than
|
|
31
|
+
// relying on every caller to pass one.
|
|
32
|
+
import { DEFAULT_SESSION_START_MAX_CHARS, SESSION_START_MODES } from '../control.mjs';
|
|
23
33
|
import { FRICTION_FAILURE, FRICTION_STUCK_LOOP } from './friction.mjs';
|
|
24
34
|
|
|
25
|
-
|
|
35
|
+
// THE INJECTED SET IS BOUNDED BY A CHARACTER BUDGET, NOT BY A COUNT.
|
|
36
|
+
//
|
|
37
|
+
// It used to be `MAX_LESSONS = 15` — a number with no derivation. Fifteen of
|
|
38
|
+
// what? A fifteen-line index of terse keys and a fifteen-line index of long ones
|
|
39
|
+
// differ by an order of magnitude in what they cost the context window, and the
|
|
40
|
+
// number said nothing about either. Worse, it was a HARD floor as well as a
|
|
41
|
+
// ceiling: a workspace with six lessons and a workspace with six hundred both
|
|
42
|
+
// got fifteen, so the small store was padded to a number and the large one was
|
|
43
|
+
// truncated to it, silently, with no way for the reader to know which had
|
|
44
|
+
// happened.
|
|
45
|
+
//
|
|
46
|
+
// What actually matters is how much of the window the block occupies, so that is
|
|
47
|
+
// what is spent. `hooks.sessionStart.maxChars` (control.mjs) sets it; the shape
|
|
48
|
+
// the remainder takes is `hooks.sessionStart`. Characters, not tokens: a real
|
|
49
|
+
// tokenizer is a dependency this package does not have and will not take, and
|
|
50
|
+
// the ~4-chars-per-token heuristic is accurate enough for a budget whose job is
|
|
51
|
+
// to bound an order of magnitude.
|
|
52
|
+
//
|
|
53
|
+
// `HARD_LESSON_CEILING` is the second bound, from the other direction. A budget
|
|
54
|
+
// alone cannot stop a store of 500 one-word keys from rendering 400 lines inside
|
|
55
|
+
// it, and a 400-line index is unreadable however few characters it costs. It is
|
|
56
|
+
// deliberately well above any budget a sane `maxChars` can fill, so in normal
|
|
57
|
+
// operation it never binds — it exists so the worst case is bounded, not to
|
|
58
|
+
// shape the common one.
|
|
59
|
+
const HARD_LESSON_CEILING = 40;
|
|
60
|
+
|
|
61
|
+
// How many lessons ride along with the scope map in `map` mode. Small on
|
|
62
|
+
// purpose: the point of that shape is the inventory, and a "map" that is mostly
|
|
63
|
+
// lessons is just `index` with extra steps.
|
|
64
|
+
const MAP_TOP_K = 3;
|
|
65
|
+
|
|
26
66
|
// Cap on lessons injected on a failure — a small, focused "you've seen this
|
|
27
67
|
// before" set, never the whole applicable corpus.
|
|
28
68
|
const MAX_RELEVANT = 3;
|
|
@@ -42,26 +82,112 @@ const MAX_SCAN_CHARS = 4096;
|
|
|
42
82
|
// precedence via the shared pure `resolvePrecedence` (the SAME first-seen /
|
|
43
83
|
// more-specific-wins merge `tree` renders) — so the hook and `tree` provably
|
|
44
84
|
// can't drift. Any per-scope failure is skipped (memory is best-effort).
|
|
45
|
-
|
|
85
|
+
// Per-scope read cap. Unchanged from the count-capped era: it bounds the FETCH,
|
|
86
|
+
// which is a different question from how much gets injected, and raising it
|
|
87
|
+
// would make every session start pay for rows the budget was never going to
|
|
88
|
+
// show. Its one visible consequence is that a scope holding more than this
|
|
89
|
+
// many lessons reports a lower-bound count in the scope map — rendered `25+`
|
|
90
|
+
// rather than a number that looks exact. `memory.scopes` answers it exactly and
|
|
91
|
+
// is the follow-up that replaces this.
|
|
92
|
+
export const SCOPE_READ_LIMIT = 25;
|
|
93
|
+
|
|
94
|
+
export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
|
|
46
95
|
const scope = deriveScope(cwd);
|
|
47
96
|
const groups = [];
|
|
97
|
+
// Per scope: did the read come back full? Then the count below is a floor,
|
|
98
|
+
// not a total, and the map must say so rather than quietly under-report.
|
|
99
|
+
const truncatedScopes = new Set();
|
|
48
100
|
for (const s of scope.readOrder) {
|
|
49
|
-
const res = await store.list({ scope: s, limit:
|
|
101
|
+
const res = await store.list({ scope: s, limit: SCOPE_READ_LIMIT });
|
|
50
102
|
if (!res || !res.ok) continue; // best-effort: a failed scope contributes nothing
|
|
51
|
-
const
|
|
103
|
+
const raw = Array.isArray(res.entries) ? res.entries : [];
|
|
104
|
+
if (raw.length >= SCOPE_READ_LIMIT) truncatedScopes.add(s);
|
|
105
|
+
const entries = raw
|
|
52
106
|
.filter((e) => e && e.key)
|
|
53
107
|
.map((e) => ({ ...e, scope: s }));
|
|
54
108
|
groups.push({ scope: s, error: null, entries });
|
|
55
109
|
}
|
|
56
110
|
// First value per key wins (most-specific scope, since `readOrder` is
|
|
57
111
|
// narrow→broad) — exactly what the old inline `byKey` merge did, now via the
|
|
58
|
-
// one shared resolver.
|
|
112
|
+
// one shared resolver.
|
|
59
113
|
const { groups: resolved } = resolvePrecedence({ groups });
|
|
60
|
-
const
|
|
114
|
+
const winners = [];
|
|
61
115
|
for (const g of resolved) {
|
|
62
|
-
for (const e of g.entries) if (e.winning)
|
|
116
|
+
for (const e of g.entries) if (e.winning) winners.push(e);
|
|
63
117
|
}
|
|
64
|
-
|
|
118
|
+
|
|
119
|
+
// THE TWO STEPS ANSWER DIFFERENT QUESTIONS, AND THE ORDER MATTERS.
|
|
120
|
+
//
|
|
121
|
+
// `resolvePrecedence` decides WHICH COPY of a key survives — a project
|
|
122
|
+
// lesson shadows the global lesson of the same name — and that is a
|
|
123
|
+
// correctness rule, not a preference. `rankLessons` then decides WHICH OF THE
|
|
124
|
+
// SURVIVORS a reader sees first. Ranking runs on the winners only, so a
|
|
125
|
+
// shadowed lesson can never be promoted back into the set by scoring well;
|
|
126
|
+
// precedence still owns the merge, exactly as `tree` renders it.
|
|
127
|
+
//
|
|
128
|
+
// Before this, the cap took whatever the group order happened to hand it,
|
|
129
|
+
// which is recency within a scope. On an active repo the newest cluster is
|
|
130
|
+
// one task's iteration log, so a dozen near-identical one-offs took the whole
|
|
131
|
+
// budget and evicted the lessons that had been re-learned all month. Recency
|
|
132
|
+
// is one factor now, not the ordering.
|
|
133
|
+
//
|
|
134
|
+
// WHAT PRECEDENCE DOES *NOT* DO, STATED SO IT IS NOT MISREAD AS A BUG.
|
|
135
|
+
// Precedence only settles same-key collisions. Across DIFFERENT keys the cap
|
|
136
|
+
// is now a single cross-scope ranking in which `scopeOrder` is a TIEBREAK
|
|
137
|
+
// (`rankLessons` sorts on score first and only compares `scopeRank` inside
|
|
138
|
+
// `SCORE_EPSILON`) — so a recurring `global::` lesson can and will take a
|
|
139
|
+
// slot from a fresher-but-one-off `project::` one, where the old narrow-first
|
|
140
|
+
// group order always filled the budget from the most-specific scope down.
|
|
141
|
+
// That is the intended trade: the budget goes to what has been re-learned,
|
|
142
|
+
// wherever it lives, and a scope-major sort would reinstate exactly the
|
|
143
|
+
// "whatever the group order hands it" behaviour this change removes. If a
|
|
144
|
+
// narrow scope should instead be guaranteed floor space, that is a weighting
|
|
145
|
+
// change in `rankLessons`, not something to re-derive here.
|
|
146
|
+
//
|
|
147
|
+
// `terms: []` is the SessionStart case: nothing has been asked yet, so the
|
|
148
|
+
// relevance factor contributes nothing and the order is recency + salience.
|
|
149
|
+
// `scopeOrder` is passed explicitly rather than left to the scorer's
|
|
150
|
+
// first-appearance default — they agree today, but the hierarchy is
|
|
151
|
+
// `readOrder`'s to state, not an artefact of how this function happens to
|
|
152
|
+
// build its array.
|
|
153
|
+
const ranked = rankLessons(winners, { terms: [], now, scopeOrder: scope.readOrder });
|
|
154
|
+
|
|
155
|
+
// The scope map is built from the SAME pass, over the winners — the set a
|
|
156
|
+
// reader could actually act on, so a shadowed duplicate is not counted twice.
|
|
157
|
+
// Order follows `readOrder` (most-specific first) rather than count, because
|
|
158
|
+
// the map is a map: it should read like the hierarchy it describes.
|
|
159
|
+
const scopeCounts = scopeInventory(ranked, scope.readOrder, truncatedScopes);
|
|
160
|
+
|
|
161
|
+
// `applicable` is the honest denominator for the header — how many the reader
|
|
162
|
+
// has, as opposed to how many fitted. It is counted BEFORE the ceiling, so
|
|
163
|
+
// "8 of 50" stays true no matter how the render is bounded.
|
|
164
|
+
return {
|
|
165
|
+
scope,
|
|
166
|
+
lessons: ranked.slice(0, HARD_LESSON_CEILING),
|
|
167
|
+
scopeCounts,
|
|
168
|
+
applicable: ranked.length,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Per-scope counts over an already-ranked lesson list, in the given scope order.
|
|
173
|
+
// A scope with no surviving lesson is omitted — a map row reading `0` is noise,
|
|
174
|
+
// and the reader cannot act on an empty scope. Pure.
|
|
175
|
+
export function scopeInventory(lessons, scopeOrder = [], truncated = new Set()) {
|
|
176
|
+
const counts = new Map();
|
|
177
|
+
for (const l of Array.isArray(lessons) ? lessons : []) {
|
|
178
|
+
const s = l?.scope;
|
|
179
|
+
if (!s) continue;
|
|
180
|
+
counts.set(s, (counts.get(s) || 0) + 1);
|
|
181
|
+
}
|
|
182
|
+
// `scopeOrder` first (the hierarchy), then anything else that turned up, so a
|
|
183
|
+
// lesson carrying an unexpected scope is still counted rather than dropped.
|
|
184
|
+
const ordered = [...scopeOrder.filter((s) => counts.has(s))];
|
|
185
|
+
for (const s of counts.keys()) if (!ordered.includes(s)) ordered.push(s);
|
|
186
|
+
return ordered.map((s) => ({
|
|
187
|
+
scope: s,
|
|
188
|
+
count: counts.get(s),
|
|
189
|
+
atReadLimit: truncated.has ? truncated.has(s) : false,
|
|
190
|
+
}));
|
|
65
191
|
}
|
|
66
192
|
|
|
67
193
|
// Cap on a lesson's one-line hook in the injected index. Long enough to jog
|
|
@@ -97,23 +223,103 @@ function lessonHook(value, max = HOOK_LEN) {
|
|
|
97
223
|
// `hooks.instructions.SessionStart` in the control config. Lets teams inject
|
|
98
224
|
// project-specific guidance (e.g. "focus on migration safety") without touching
|
|
99
225
|
// the hook internals. Visible even when there are no lessons.
|
|
100
|
-
export function formatLessons(lessons, scope, {
|
|
101
|
-
|
|
102
|
-
|
|
226
|
+
export function formatLessons(lessons, scope, {
|
|
227
|
+
instruction = null,
|
|
228
|
+
mode = 'hybrid',
|
|
229
|
+
maxChars = DEFAULT_SESSION_START_MAX_CHARS,
|
|
230
|
+
scopeCounts = null,
|
|
231
|
+
applicable = null,
|
|
232
|
+
} = {}) {
|
|
233
|
+
const all = Array.isArray(lessons) ? lessons : [];
|
|
234
|
+
if (all.length === 0) {
|
|
103
235
|
// No lessons — only emit if there is a custom instruction to show.
|
|
104
236
|
if (!instruction) return null;
|
|
105
237
|
return (
|
|
106
|
-
`LoreKit: 0
|
|
238
|
+
`LoreKit: 0 memories loaded · ${scope.repoScope || 'this workspace'} ` +
|
|
107
239
|
`— considerations, not rules; read any in full with memory.read.\n\n` +
|
|
108
240
|
`Project instruction: ${instruction}`
|
|
109
241
|
);
|
|
110
242
|
}
|
|
243
|
+
|
|
244
|
+
const budget = Number.isFinite(maxChars) && maxChars > 0
|
|
245
|
+
? maxChars
|
|
246
|
+
: DEFAULT_SESSION_START_MAX_CHARS;
|
|
247
|
+
const shape = SESSION_START_MODES.includes(mode) ? mode : 'hybrid';
|
|
248
|
+
const total = typeof applicable === 'number' && applicable >= all.length ? applicable : all.length;
|
|
249
|
+
|
|
250
|
+
// The map line is composed BEFORE the lessons are chosen, because in `hybrid`
|
|
251
|
+
// its length has to be reserved out of the budget. Appending it afterwards
|
|
252
|
+
// would let the block overrun the very number the config asked for — a budget
|
|
253
|
+
// you can exceed by one more line is not a budget.
|
|
254
|
+
const map = renderScopeMap(scopeCounts);
|
|
255
|
+
const reserve = shape === 'index' || !map ? 0 : map.length + 1;
|
|
256
|
+
|
|
257
|
+
const ceiling = shape === 'map' ? Math.min(MAP_TOP_K, HARD_LESSON_CEILING) : HARD_LESSON_CEILING;
|
|
258
|
+
const { shown } = fitLines(all, budget - reserve, ceiling);
|
|
259
|
+
|
|
260
|
+
// `map` always shows the inventory; `hybrid` shows it only when something was
|
|
261
|
+
// actually left out — otherwise the reader is looking at the complete set and
|
|
262
|
+
// a "…and here is what you are missing" line would be a lie. `index` never
|
|
263
|
+
// shows it, which is the whole difference between `index` and `hybrid`.
|
|
264
|
+
const showMap = Boolean(map) && (shape === 'map' || (shape === 'hybrid' && shown.length < total));
|
|
265
|
+
|
|
266
|
+
// Say `8 of 50` whenever the two differ. The old header reported only what it
|
|
267
|
+
// had rendered, so a truncated block was indistinguishable from a complete one
|
|
268
|
+
// and the agent had no way to know that reaching for `memory.search` was worth
|
|
269
|
+
// it. The count is what makes the truncation self-describing.
|
|
270
|
+
const counted = shown.length === total
|
|
271
|
+
? `${total} ${total === 1 ? 'memory' : 'memories'}`
|
|
272
|
+
: `${shown.length} of ${total} memories`;
|
|
111
273
|
const header =
|
|
112
|
-
`LoreKit: ${
|
|
274
|
+
`LoreKit: ${counted} loaded · ${scope.repoScope || 'this workspace'} ` +
|
|
113
275
|
`— considerations, not rules; read any in full with memory.read.`;
|
|
114
|
-
|
|
276
|
+
|
|
277
|
+
const parts = [header, ...shown.map((s) => s.line)];
|
|
278
|
+
if (showMap) parts.push(map);
|
|
115
279
|
const instructionBlock = instruction ? `\n\nProject instruction: ${instruction}` : '';
|
|
116
|
-
return `${
|
|
280
|
+
return `${parts.join('\n')}${instructionBlock}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// One index line for a lesson. Pure.
|
|
284
|
+
function lessonLine(l) {
|
|
285
|
+
return `- (${l.scope}) ${l.key} — ${lessonHook(l.value)}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Take lessons in order until the next line would not fit, or the ceiling is
|
|
289
|
+
// reached. Returns `{ shown, used }`.
|
|
290
|
+
//
|
|
291
|
+
// THE FIRST LINE IS ALWAYS TAKEN, even when it alone exceeds the budget. A
|
|
292
|
+
// header with nothing under it is strictly worse than one over-long line: the
|
|
293
|
+
// reader learns nothing and cannot tell whether the store is empty or the budget
|
|
294
|
+
// is misconfigured. One line over budget is a visible, self-explaining overrun;
|
|
295
|
+
// zero lines is a silent one. Pure.
|
|
296
|
+
function fitLines(lessons, budget, ceiling) {
|
|
297
|
+
const shown = [];
|
|
298
|
+
let used = 0;
|
|
299
|
+
for (const l of lessons) {
|
|
300
|
+
if (shown.length >= ceiling) break;
|
|
301
|
+
const line = lessonLine(l);
|
|
302
|
+
const cost = line.length + 1; // the newline that joins it
|
|
303
|
+
if (shown.length > 0 && used + cost > budget) break;
|
|
304
|
+
used += cost;
|
|
305
|
+
shown.push({ lesson: l, line });
|
|
306
|
+
}
|
|
307
|
+
return { shown, used };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The scope map: one line naming every scope that holds lessons and how many,
|
|
311
|
+
// so a truncated block still tells the reader WHERE the rest live and which verb
|
|
312
|
+
// reaches them. `25+` marks a scope whose read hit `SCOPE_READ_LIMIT`, so a
|
|
313
|
+
// lower bound never reads as an exact total. Null when there is nothing to
|
|
314
|
+
// describe. Pure.
|
|
315
|
+
export function renderScopeMap(scopeCounts) {
|
|
316
|
+
const rows = (Array.isArray(scopeCounts) ? scopeCounts : [])
|
|
317
|
+
.filter((s) => s && s.scope && Number(s.count) > 0);
|
|
318
|
+
if (rows.length === 0) return null;
|
|
319
|
+
const body = rows
|
|
320
|
+
.map((s) => `${s.scope} ${s.count}${s.atReadLimit ? '+' : ''}`)
|
|
321
|
+
.join(' · ');
|
|
322
|
+
return `More lore: ${body} — memory.search or memory.read to drill in.`;
|
|
117
323
|
}
|
|
118
324
|
|
|
119
325
|
// Distil a small set of significant, lowercased search TERMS from a tool
|
package/src/deeplink-pure.mjs
CHANGED
|
@@ -30,7 +30,12 @@ export const DEFAULT_APP_BASE = 'https://lorekit.io';
|
|
|
30
30
|
export const LORE_PARAM_DEFAULTS = {
|
|
31
31
|
scope: null, // string | null — null means "all scopes"
|
|
32
32
|
q: '', // string search query
|
|
33
|
-
|
|
33
|
+
// { from, to } | { preset } | null. The CLI emits only the { from, to } arm
|
|
34
|
+
// (day strings via --range/--from/--to), which the Explorer still reads as
|
|
35
|
+
// whole UTC days with an INCLUSIVE end day — unchanged. The web model also
|
|
36
|
+
// accepts ISO instants in that arm and a relative { preset: '7d' } arm
|
|
37
|
+
// (packages/web/src/lib/time-range.ts); neither has a CLI flag yet.
|
|
38
|
+
range: null,
|
|
34
39
|
owner: 'all', // 'all' | 'personal' | { orgId }
|
|
35
40
|
// Filter[] | null — the Explorer's multi-dimension filter bar (label / agent /
|
|
36
41
|
// trigger / repo / branch / pr). `null`, NOT `[]`, is the default on purpose:
|
|
@@ -42,7 +47,14 @@ export const LORE_PARAM_DEFAULTS = {
|
|
|
42
47
|
filters: null,
|
|
43
48
|
tags: [], // string[] — legacy label filter (AND across labels); [] means "no filter". Still READ by the app, superseded by `filters`
|
|
44
49
|
view: 'scope', // 'scope' | 'time'
|
|
45
|
-
archived
|
|
50
|
+
// 'active' | 'archived' | 'expiring' | null — the Explorer's Status control.
|
|
51
|
+
// `null`, NOT 'active', is the default for `filters`' reason: the app has to
|
|
52
|
+
// tell "absent" from an explicit choice, because an absent `status` falls back
|
|
53
|
+
// to the legacy `archived` flag while an explicit `status=active` overrides it.
|
|
54
|
+
status: null,
|
|
55
|
+
// boolean — SUPERSEDED by `status`, still READ by the app so existing links
|
|
56
|
+
// (and `lorekit link --archived`) keep resolving to the archived view.
|
|
57
|
+
archived: false,
|
|
46
58
|
lesson: null, // { scope, key } | null — opens the detail sheet
|
|
47
59
|
};
|
|
48
60
|
|
|
@@ -50,7 +62,7 @@ export const LORE_PARAM_DEFAULTS = {
|
|
|
50
62
|
// Mirrors the `useUrlState` call order in `LoreExplorer.tsx` (+ the `lesson`
|
|
51
63
|
// param last), so `filters` and `tags` sit between `owner` and `view`. `scope`
|
|
52
64
|
// precedes `lesson` so a lesson link reads `?scope=…&lesson=…`.
|
|
53
|
-
const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'filters', 'tags', 'view', 'archived', 'lesson'];
|
|
65
|
+
const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'filters', 'tags', 'view', 'status', 'archived', 'lesson'];
|
|
54
66
|
|
|
55
67
|
// Strip trailing slashes from a base URL, falling back to the default when the
|
|
56
68
|
// input is empty/absent. Pure.
|
package/src/hook.mjs
CHANGED
|
@@ -106,8 +106,14 @@ async function run(args) {
|
|
|
106
106
|
}
|
|
107
107
|
return 0;
|
|
108
108
|
}
|
|
109
|
-
const { scope: readScope, lessons } = await fetchLessons(store, root);
|
|
110
|
-
emit(formatLessons(lessons, readScope, {
|
|
109
|
+
const { scope: readScope, lessons, scopeCounts, applicable } = await fetchLessons(store, root);
|
|
110
|
+
emit(formatLessons(lessons, readScope, {
|
|
111
|
+
instruction: sessionInstruction,
|
|
112
|
+
mode: control.hooksSessionStart,
|
|
113
|
+
maxChars: control.hooksSessionStartMaxChars,
|
|
114
|
+
scopeCounts,
|
|
115
|
+
applicable,
|
|
116
|
+
}));
|
|
111
117
|
return 0;
|
|
112
118
|
}
|
|
113
119
|
|
package/src/lessons-pure.mjs
CHANGED
|
@@ -199,3 +199,344 @@ export function resolveScopeKeyArgs(positionals = [], options = {}) {
|
|
|
199
199
|
const { scope, key } = resolveScopeArg(first, isScope);
|
|
200
200
|
return { scope: scope || '', key, consumed: 1 };
|
|
201
201
|
}
|
|
202
|
+
|
|
203
|
+
// ── ranking: which lessons are worth the context budget ──────────────────────
|
|
204
|
+
//
|
|
205
|
+
// `resolvePrecedence` above answers "which SCOPE's copy of a key wins". This
|
|
206
|
+
// answers the different question the injection path actually has: given the
|
|
207
|
+
// winners, which ones does a reader most need to see FIRST.
|
|
208
|
+
//
|
|
209
|
+
// It exists because ordering by recency alone is actively harmful on a busy
|
|
210
|
+
// repo. The newest cluster of writes is usually one task's iteration log — a
|
|
211
|
+
// dozen near-identical one-off lessons — and under a recency sort that cluster
|
|
212
|
+
// takes every slot, evicting the durable lessons that have been re-learned a
|
|
213
|
+
// dozen times. Recency is a signal, not the ranking.
|
|
214
|
+
//
|
|
215
|
+
// The score is a weighted sum of three factors, each normalised to [0,1]:
|
|
216
|
+
//
|
|
217
|
+
// recency — exponential decay on age. Half-life, not a cliff: a lesson does
|
|
218
|
+
// not stop mattering on a particular day.
|
|
219
|
+
// salience — log(1 + seenCount), normalised across the candidate set. A
|
|
220
|
+
// lesson written eight times has been re-learned; one written
|
|
221
|
+
// once may just be noise. Logarithmic because the interesting
|
|
222
|
+
// step is 1 → 3, not 40 → 42, and normalised across the set
|
|
223
|
+
// because "recurring" only means anything relative to its peers.
|
|
224
|
+
// relevance — how much of the caller's query this lesson matches. Exactly 0
|
|
225
|
+
// when no terms are supplied, which is the SessionStart case: it
|
|
226
|
+
// then contributes the same constant to every candidate and the
|
|
227
|
+
// ordering is recency + salience alone.
|
|
228
|
+
//
|
|
229
|
+
// PURE AND TOTAL, with one scoped exception. `now` is a PARAMETER: the
|
|
230
|
+
// arithmetic never reads the clock, every factor is a function of the value
|
|
231
|
+
// passed in, and a caller that supplies one gets a ranking that is exactly
|
|
232
|
+
// reproducible in a test and in a bug report. `scoreLesson` and `rankLessons`
|
|
233
|
+
// default it to `Date.now()` at the call boundary so the common caller need not
|
|
234
|
+
// thread a clock through — that default is the ONLY clock read, it happens once
|
|
235
|
+
// per call, and passing `now` explicitly removes it. Every missing or malformed
|
|
236
|
+
// field degrades to its zero rather than throwing — this runs on the
|
|
237
|
+
// SessionStart hot path behind a hook that must exit 0, and losing the whole
|
|
238
|
+
// injection to save one unparseable timestamp is a bad trade.
|
|
239
|
+
|
|
240
|
+
// Age at which a lesson's recency factor halves. Two weeks is roughly the span
|
|
241
|
+
// over which a repo's "what am I working on" context turns over: yesterday's
|
|
242
|
+
// lesson should clearly outrank last month's, without last month's dropping to
|
|
243
|
+
// nothing — a year-old lesson that has recurred 30 times still deserves a slot.
|
|
244
|
+
export const RECENCY_HALF_LIFE_DAYS = 14;
|
|
245
|
+
|
|
246
|
+
// Equal thirds. Deliberately not tuned: with no corpus to tune against, an
|
|
247
|
+
// invented weighting is a guess wearing a decimal point. They are a parameter
|
|
248
|
+
// so a caller can experiment, and so a future PR can change them with evidence.
|
|
249
|
+
//
|
|
250
|
+
// FROZEN. An exported mutable object is shared state, and this one is the
|
|
251
|
+
// fallback the totality guarantee rests on: a caller that zeroed its fields
|
|
252
|
+
// turned `scoreLesson`'s "fall back to the defaults" branch into unbounded
|
|
253
|
+
// recursion (a real `RangeError`, raised inside a hook the header promises will
|
|
254
|
+
// never throw). Freezing makes the corruption a `TypeError` at the assignment,
|
|
255
|
+
// in the caller's own frame, instead of a stack overflow three layers down.
|
|
256
|
+
export const DEFAULT_RANK_WEIGHTS = Object.freeze({ recency: 1, salience: 1, relevance: 1 });
|
|
257
|
+
|
|
258
|
+
// Two scores closer than this are the same score. Sized well below any
|
|
259
|
+
// difference the factors can produce meaningfully (a one-second age gap moves a
|
|
260
|
+
// score by ~1e-6 at the default half-life) and well above float noise.
|
|
261
|
+
export const SCORE_EPSILON = 1e-9;
|
|
262
|
+
|
|
263
|
+
const MS_PER_DAY = 86400000;
|
|
264
|
+
|
|
265
|
+
// Milliseconds since the epoch for a value that may be an ISO string, a Date, a
|
|
266
|
+
// number, or junk. `null` when it cannot be read as a time — never NaN, which
|
|
267
|
+
// would propagate silently through the arithmetic below and sort the entry to
|
|
268
|
+
// wherever NaN happens to land.
|
|
269
|
+
function timeOf(value) {
|
|
270
|
+
if (value == null || value === '') return null;
|
|
271
|
+
const t = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
272
|
+
return Number.isFinite(t) ? t : null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Recency factor in [0,1]: 1 for something written now, 0.5 at one half-life.
|
|
277
|
+
*
|
|
278
|
+
* An unknown timestamp scores 0, NOT 0.5. The alternative — treating unknown as
|
|
279
|
+
* average — would let a lesson with no `updatedAt` outrank a real one that is
|
|
280
|
+
* merely a month old, on the strength of having less information about it.
|
|
281
|
+
*
|
|
282
|
+
* A FUTURE timestamp is clamped to 1 rather than allowed to exceed it. Clock
|
|
283
|
+
* skew between a writer and a reader is ordinary; a lesson from thirty seconds
|
|
284
|
+
* in the future is simply new, and letting age go negative would hand it an
|
|
285
|
+
* unbounded score that beats every honestly-dated lesson in the set.
|
|
286
|
+
*/
|
|
287
|
+
export function recencyFactor(updatedAt, now, halfLifeDays = RECENCY_HALF_LIFE_DAYS) {
|
|
288
|
+
const t = timeOf(updatedAt);
|
|
289
|
+
const nowMs = timeOf(now);
|
|
290
|
+
if (t === null || nowMs === null) return 0;
|
|
291
|
+
const halfLife = Number.isFinite(halfLifeDays) && halfLifeDays > 0
|
|
292
|
+
? halfLifeDays
|
|
293
|
+
: RECENCY_HALF_LIFE_DAYS;
|
|
294
|
+
const ageDays = Math.max(0, (nowMs - t) / MS_PER_DAY);
|
|
295
|
+
return Math.exp((-Math.LN2 * ageDays) / halfLife);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Salience factor in [0,1] — recurrence, relative to the most-recurring lesson
|
|
300
|
+
* in the same candidate set.
|
|
301
|
+
*
|
|
302
|
+
* `maxSeenCount` is passed in rather than derived here because the factor is
|
|
303
|
+
* only meaningful within a set: eight sightings is remarkable next to a set of
|
|
304
|
+
* one-offs and unremarkable next to a set of hundreds. `rankLessons` computes
|
|
305
|
+
* it once for the whole set.
|
|
306
|
+
*
|
|
307
|
+
* A set whose maximum is 0 or 1 yields 0 for every member — no lesson in it has
|
|
308
|
+
* recurred, so salience has nothing to say and the other factors decide. Note
|
|
309
|
+
* this makes a set of all-one-offs rank purely on recency, which is correct:
|
|
310
|
+
* the ranking only claims to separate recurring from non-recurring.
|
|
311
|
+
*
|
|
312
|
+
* A `seenCount` ABOVE `maxSeenCount` is clamped to 1 rather than allowed to
|
|
313
|
+
* exceed it, for the same reason `recencyFactor` clamps a future timestamp.
|
|
314
|
+
* `rankLessons` derives the maximum from the set, so in-set the clamp never
|
|
315
|
+
* binds; it binds when a caller reaches this (or `scoreLesson`) directly with a
|
|
316
|
+
* maximum that is not the set's, and without it the `[0,1]` both docblocks
|
|
317
|
+
* promise would simply be false — `salienceFactor(5, 2)` is 1.63.
|
|
318
|
+
*/
|
|
319
|
+
export function salienceFactor(seenCount, maxSeenCount) {
|
|
320
|
+
const n = Number.isFinite(seenCount) ? Math.max(0, seenCount) : 0;
|
|
321
|
+
const max = Number.isFinite(maxSeenCount) ? Math.max(0, maxSeenCount) : 0;
|
|
322
|
+
if (max <= 1) return 0;
|
|
323
|
+
return Math.min(1, Math.log1p(n) / Math.log1p(max));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Relevance factor in [0,1] — the fraction of the caller's terms this lesson
|
|
328
|
+
* matches.
|
|
329
|
+
*
|
|
330
|
+
* Matching is `matchesQuery`'s: a LITERAL, case-insensitive substring of the
|
|
331
|
+
* key or the value. That is the same primitive `search` and the failure hook
|
|
332
|
+
* use, so a lesson that a `lorekit search <term>` would surface is a lesson
|
|
333
|
+
* this ranks up — one matcher, one meaning of "matches".
|
|
334
|
+
*
|
|
335
|
+
* The fraction is over DISTINCT terms, so a term repeated in the caller's list
|
|
336
|
+
* cannot inflate a lesson's score, and it is a fraction rather than a count so
|
|
337
|
+
* a three-term query and a ten-term query produce comparable numbers.
|
|
338
|
+
*
|
|
339
|
+
* Empty terms is 0, never 1. This is the SessionStart case and it must not
|
|
340
|
+
* silently become "everything is maximally relevant" — which, being a constant,
|
|
341
|
+
* would not change the ORDER but would compress the score range and make every
|
|
342
|
+
* downstream threshold meaningless.
|
|
343
|
+
*
|
|
344
|
+
* `terms` is a list, a lone term, or any `Set` — every form is normalised the
|
|
345
|
+
* same way, so `new Set([' Timeout '])` and `[' Timeout ']` score identically.
|
|
346
|
+
* Normalisation is NOT a caller responsibility and there is no pre-normalised
|
|
347
|
+
* fast path on this function: an unnormalised `Set` reaching the matcher scored
|
|
348
|
+
* `''` as a match on everything and a padded term as a match on nothing.
|
|
349
|
+
* `rankLessons` gets the once-per-ranking saving from the internal
|
|
350
|
+
* `relevanceFromTerms` instead, where the set is one this module built.
|
|
351
|
+
*/
|
|
352
|
+
export function relevanceFactor(entry, terms) {
|
|
353
|
+
return relevanceFromTerms(entry, distinctTerms(terms));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// The matcher, over a set this module has already normalised. Internal on
|
|
357
|
+
// purpose: it is the seam that lets `rankLessons` normalise the query ONCE for
|
|
358
|
+
// the whole ranking instead of once per candidate (O(entries × terms) for a
|
|
359
|
+
// result that cannot differ between entries) without turning "hand me a
|
|
360
|
+
// correctly-shaped Set" into part of the public contract.
|
|
361
|
+
function relevanceFromTerms(entry, distinct) {
|
|
362
|
+
if (distinct.size === 0) return 0;
|
|
363
|
+
let hits = 0;
|
|
364
|
+
for (const term of distinct) if (matchesQuery(entry, term)) hits += 1;
|
|
365
|
+
return hits / distinct.size;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// The caller's query as a set of distinct, lowercased, non-empty terms. Accepts
|
|
369
|
+
// a list, a `Set`, or a lone value, and normalises the CONTENTS in every case —
|
|
370
|
+
// an earlier version short-circuited on `instanceof Set`, which let a
|
|
371
|
+
// hand-built set skip trimming and empty-filtering and diverge from the list
|
|
372
|
+
// path.
|
|
373
|
+
function distinctTerms(terms) {
|
|
374
|
+
const list = Array.isArray(terms) || terms instanceof Set ? [...terms] : [terms];
|
|
375
|
+
return new Set(
|
|
376
|
+
list
|
|
377
|
+
.map((t) => String(t == null ? '' : t).toLowerCase().trim())
|
|
378
|
+
.filter(Boolean),
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Score one lesson in [0,1].
|
|
384
|
+
*
|
|
385
|
+
* `maxSeenCount` belongs to the candidate SET, so this is normally reached
|
|
386
|
+
* through `rankLessons` rather than called directly; it is exported so a caller
|
|
387
|
+
* can explain a ranking ("why is this one third?") without re-deriving the
|
|
388
|
+
* arithmetic.
|
|
389
|
+
*
|
|
390
|
+
* The weighted sum is divided by the total weight, so the result stays in [0,1]
|
|
391
|
+
* whatever weights are supplied and two runs with different weightings remain
|
|
392
|
+
* comparable. A weight set that sums to zero falls back to the defaults rather
|
|
393
|
+
* than dividing by zero — ONCE, by substitution rather than by recursion. The
|
|
394
|
+
* recursive form was total only while `DEFAULT_RANK_WEIGHTS` still summed above
|
|
395
|
+
* zero, which made a totality guarantee depend on an exported object nobody had
|
|
396
|
+
* mutated yet. If even the defaults are degenerate every lesson scores 0, which
|
|
397
|
+
* is honest (no weighted signal is left) and hands the ordering to the
|
|
398
|
+
* tiebreakers instead of to a stack overflow.
|
|
399
|
+
*/
|
|
400
|
+
export function scoreLesson(entry, {
|
|
401
|
+
terms = [],
|
|
402
|
+
now = Date.now(),
|
|
403
|
+
weights = DEFAULT_RANK_WEIGHTS,
|
|
404
|
+
maxSeenCount = 0,
|
|
405
|
+
halfLifeDays = RECENCY_HALF_LIFE_DAYS,
|
|
406
|
+
} = {}) {
|
|
407
|
+
return scoreWithTerms(entry, distinctTerms(terms), { now, weights, maxSeenCount, halfLifeDays });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// `scoreLesson` with the query already normalised. `rankLessons` calls this so
|
|
411
|
+
// the whole ranking normalises the query once; `scoreLesson` normalises and
|
|
412
|
+
// delegates, so no caller has to know a normalised set exists.
|
|
413
|
+
function scoreWithTerms(entry, termSet, { now, weights, maxSeenCount, halfLifeDays }) {
|
|
414
|
+
let w = {
|
|
415
|
+
recency: numberOr(weights?.recency, DEFAULT_RANK_WEIGHTS.recency),
|
|
416
|
+
salience: numberOr(weights?.salience, DEFAULT_RANK_WEIGHTS.salience),
|
|
417
|
+
relevance: numberOr(weights?.relevance, DEFAULT_RANK_WEIGHTS.relevance),
|
|
418
|
+
};
|
|
419
|
+
let total = w.recency + w.salience + w.relevance;
|
|
420
|
+
if (!(total > 0)) {
|
|
421
|
+
w = {
|
|
422
|
+
recency: numberOr(DEFAULT_RANK_WEIGHTS.recency, 0),
|
|
423
|
+
salience: numberOr(DEFAULT_RANK_WEIGHTS.salience, 0),
|
|
424
|
+
relevance: numberOr(DEFAULT_RANK_WEIGHTS.relevance, 0),
|
|
425
|
+
};
|
|
426
|
+
total = w.recency + w.salience + w.relevance;
|
|
427
|
+
}
|
|
428
|
+
if (!(total > 0)) return 0;
|
|
429
|
+
const recency = recencyFactor(entry?.updatedAt ?? entry?.updated_at ?? entry?.updated, now, halfLifeDays);
|
|
430
|
+
const salience = salienceFactor(seenCountFrom(entry), maxSeenCount);
|
|
431
|
+
const relevance = relevanceFromTerms(entry, termSet);
|
|
432
|
+
return (w.recency * recency + w.salience * salience + w.relevance * relevance) / total;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// A non-negative finite number, or the fallback. Guards a caller passing a
|
|
436
|
+
// string, null, or NaN as a weight.
|
|
437
|
+
function numberOr(value, fallback) {
|
|
438
|
+
const n = typeof value === 'string' ? Number(value) : value;
|
|
439
|
+
return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// The recurrence count off an entry, in either the store-projected form
|
|
443
|
+
// (`seenCount`, what `store/entry-fields.mjs` produces) or the raw REST/
|
|
444
|
+
// frontmatter spelling. Anything unreadable is 0 — no evidence of recurrence,
|
|
445
|
+
// which is not the same claim as one sighting.
|
|
446
|
+
function seenCountFrom(entry) {
|
|
447
|
+
const raw = entry?.seenCount ?? entry?.seen_count;
|
|
448
|
+
const n = typeof raw === 'string' ? Number(raw) : raw;
|
|
449
|
+
return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : 0;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Rank lessons best-first, returning a NEW array — the input is never reordered
|
|
454
|
+
* in place, because callers hold it (`fetchLessons` builds it from the
|
|
455
|
+
* precedence resolution and `tree` renders the same objects).
|
|
456
|
+
*
|
|
457
|
+
* Ties are broken deterministically, and the order of the tiebreakers is the
|
|
458
|
+
* design:
|
|
459
|
+
*
|
|
460
|
+
* 1. score, descending.
|
|
461
|
+
* 2. SCOPE PRECEDENCE — the position of the entry's scope in `scopeOrder`,
|
|
462
|
+
* which defaults to the order scopes first appear in the input. That
|
|
463
|
+
* default is not a convenience: `fetchLessons` hands entries over in
|
|
464
|
+
* `readOrder`, most-specific first, so the default IS the precedence
|
|
465
|
+
* hierarchy, and a project lesson beats a global one it ties with, for
|
|
466
|
+
* free and without this module knowing what a scope is.
|
|
467
|
+
* 3. key, lexicographically — the last resort, so the answer cannot depend on
|
|
468
|
+
* hash iteration order or on which page a row arrived in.
|
|
469
|
+
*
|
|
470
|
+
* Sorting on floats makes exact ties rarer than they look, so tiebreaker 2 also
|
|
471
|
+
* quietly matters for NEAR ties: two lessons written in the same minute with
|
|
472
|
+
* the same count differ in the tenth decimal place, which is noise, not a
|
|
473
|
+
* preference. `SCORE_EPSILON` treats a difference that small as a tie so the
|
|
474
|
+
* meaningful tiebreaker decides instead of floating-point dust.
|
|
475
|
+
*
|
|
476
|
+
* That tie is applied by QUANTISING each score onto a `SCORE_EPSILON` grid, not
|
|
477
|
+
* by an `abs(a - b) <= SCORE_EPSILON` comparison. The comparison form reads
|
|
478
|
+
* more naturally and is wrong: approximate equality is not TRANSITIVE, so three
|
|
479
|
+
* scores a grid-step apart give a≈b, b≈c and c>a, the comparator stops being a
|
|
480
|
+
* strict weak ordering, and the result depends on the order the rows arrived in
|
|
481
|
+
* — exactly the determinism this docblock claims. Measured before the fix:
|
|
482
|
+
* three lessons 3ms apart produced three different orderings across the six
|
|
483
|
+
* permutations of one input. Rounding first makes "same score" an equivalence
|
|
484
|
+
* relation, so the scope/key tiebreak is what decides every near tie.
|
|
485
|
+
*
|
|
486
|
+
* The residual cost is a boundary: two scores closer than a grid step can still
|
|
487
|
+
* land in adjacent buckets and be ordered by score. That is unavoidable for any
|
|
488
|
+
* transitive notion of approximate equality, and it is a far smaller defect
|
|
489
|
+
* than an ordering that changes with input order.
|
|
490
|
+
*/
|
|
491
|
+
export function rankLessons(entries = [], {
|
|
492
|
+
terms = [],
|
|
493
|
+
now = Date.now(),
|
|
494
|
+
weights = DEFAULT_RANK_WEIGHTS,
|
|
495
|
+
halfLifeDays = RECENCY_HALF_LIFE_DAYS,
|
|
496
|
+
scopeOrder = null,
|
|
497
|
+
} = {}) {
|
|
498
|
+
const list = Array.isArray(entries) ? entries.filter((e) => e && typeof e === 'object') : [];
|
|
499
|
+
if (list.length === 0) return [];
|
|
500
|
+
|
|
501
|
+
// Normalise the query ONCE for the whole ranking, not once per candidate —
|
|
502
|
+
// `scoreWithTerms` takes the set this module built straight through.
|
|
503
|
+
const termSet = distinctTerms(terms);
|
|
504
|
+
|
|
505
|
+
// One pass for the set-relative normaliser, so scoring stays O(n).
|
|
506
|
+
let maxSeenCount = 0;
|
|
507
|
+
for (const e of list) maxSeenCount = Math.max(maxSeenCount, seenCountFrom(e));
|
|
508
|
+
|
|
509
|
+
// Scope precedence: an explicit order wins, else first-appearance order.
|
|
510
|
+
const rankByScope = new Map();
|
|
511
|
+
for (const s of Array.isArray(scopeOrder) ? scopeOrder : []) {
|
|
512
|
+
if (!rankByScope.has(s)) rankByScope.set(s, rankByScope.size);
|
|
513
|
+
}
|
|
514
|
+
for (const e of list) {
|
|
515
|
+
const s = e.scope;
|
|
516
|
+
if (s !== undefined && !rankByScope.has(s)) rankByScope.set(s, rankByScope.size);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const scored = list.map((entry, index) => ({
|
|
520
|
+
entry,
|
|
521
|
+
index,
|
|
522
|
+
// The score rounded onto the SCORE_EPSILON grid. Comparing THIS rather than
|
|
523
|
+
// the raw score is what keeps "close enough to be a tie" transitive — see
|
|
524
|
+
// the docblock. A score is in [0,1] and the grid is 1e-9, so the bucket is
|
|
525
|
+
// always a safe integer.
|
|
526
|
+
bucket: Math.round(
|
|
527
|
+
scoreWithTerms(entry, termSet, { now, weights, maxSeenCount, halfLifeDays }) / SCORE_EPSILON,
|
|
528
|
+
),
|
|
529
|
+
scopeRank: rankByScope.has(entry.scope) ? rankByScope.get(entry.scope) : Number.MAX_SAFE_INTEGER,
|
|
530
|
+
key: String(entry.key ?? ''),
|
|
531
|
+
}));
|
|
532
|
+
|
|
533
|
+
scored.sort((a, b) => {
|
|
534
|
+
if (a.bucket !== b.bucket) return b.bucket - a.bucket;
|
|
535
|
+
if (a.scopeRank !== b.scopeRank) return a.scopeRank - b.scopeRank;
|
|
536
|
+
if (a.key !== b.key) return a.key < b.key ? -1 : 1;
|
|
537
|
+
return a.index - b.index; // stable: equal in every respect keeps input order
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
return scored.map((s) => s.entry);
|
|
541
|
+
}
|
|
542
|
+
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The read-shape fields a ranking layer needs, derived from a store row.
|
|
2
|
+
//
|
|
3
|
+
// Both stores answer with rows in their OWN vocabulary — the remote store hands
|
|
4
|
+
// back a REST `MemoryEntry` (`seen_count`, `updated_at`), the local store hands
|
|
5
|
+
// back parsed frontmatter (`seen_count`, `updated`). A ranker cannot care which
|
|
6
|
+
// one it is holding, so the READ-FIELD projection lives here, once, and both
|
|
7
|
+
// stores apply it on the way out. Two copies of "which key holds the timestamp"
|
|
8
|
+
// is exactly the drift the repo's mirror guards exist to prevent — see
|
|
9
|
+
// `updatedAtOf` for the one other place in the CLI that maps the same pair, and
|
|
10
|
+
// why it is not the same projection.
|
|
11
|
+
//
|
|
12
|
+
// TOTAL FUNCTIONS. Every entry point below is defined for any input — a null
|
|
13
|
+
// row, a string where a number belongs, a hand-edited frontmatter scalar, a
|
|
14
|
+
// response from a backend deployed before the column existed. This code runs on
|
|
15
|
+
// the SessionStart hot path behind a hook that must always exit 0, so a throw
|
|
16
|
+
// here would cost the user their lesson injection to save a field nobody
|
|
17
|
+
// promised. Missing or unusable degrades to the documented default and the
|
|
18
|
+
// caller ranks on what it does have.
|
|
19
|
+
//
|
|
20
|
+
// Zero-dependency: no imports, not even node builtins.
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How many times this lesson has been written.
|
|
24
|
+
*
|
|
25
|
+
* `0` — not `1` — is the absent case, and the distinction is load-bearing.
|
|
26
|
+
* A live remote row always carries at least `1` (the column is `NOT NULL
|
|
27
|
+
* DEFAULT 1`), so `0` can only mean "this store did not tell me", which a
|
|
28
|
+
* salience score should read as no evidence rather than as one sighting.
|
|
29
|
+
* Fractions are floored and negatives clamped: the count is a tally.
|
|
30
|
+
*/
|
|
31
|
+
export function seenCountOf(row) {
|
|
32
|
+
const raw = row?.seen_count;
|
|
33
|
+
const n = typeof raw === 'string' ? Number(raw) : raw;
|
|
34
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) return 0;
|
|
35
|
+
return Math.max(0, Math.floor(n));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* When this lesson was last written, as an ISO 8601 string, or `null`.
|
|
40
|
+
*
|
|
41
|
+
* Accepts either store's spelling (`updated_at` remote, `updated` local) and
|
|
42
|
+
* normalises through `Date` so a caller can compare two stores' entries without
|
|
43
|
+
* knowing which produced them. An unparseable value is `null`, never `Invalid
|
|
44
|
+
* Date` and never the raw text: a recency decay fed `NaN` silently sinks the
|
|
45
|
+
* entry to the bottom, which is a worse failure than admitting the timestamp is
|
|
46
|
+
* unknown.
|
|
47
|
+
*
|
|
48
|
+
* `normalizeEntry` (`src/lessons-view.mjs`) maps the same two spellings at the
|
|
49
|
+
* OPPOSITE precedence — `updated ?? updated_at`, local first — and that is
|
|
50
|
+
* deliberate, not drift: it builds the human/`--json` VIEW row, where a local
|
|
51
|
+
* file's own `updated` is the authoritative one and the value is passed through
|
|
52
|
+
* verbatim for rendering. This function builds the RANKING row, where the
|
|
53
|
+
* remote column is authoritative and the value is normalised through `Date`.
|
|
54
|
+
* Two different projections over one pair of keys, so neither can call the
|
|
55
|
+
* other; keep them in step by hand when a spelling changes.
|
|
56
|
+
*/
|
|
57
|
+
export function updatedAtOf(row) {
|
|
58
|
+
const raw = row?.updated_at ?? row?.updated;
|
|
59
|
+
if (raw == null || raw === '') return null;
|
|
60
|
+
const d = new Date(raw);
|
|
61
|
+
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A store row plus the two derived read fields.
|
|
66
|
+
*
|
|
67
|
+
* ADDITIVE by construction: the row is spread through untouched, so every
|
|
68
|
+
* existing caller keeps reading the exact keys it always did and only a caller
|
|
69
|
+
* that asks for `seenCount` / `updatedAt` sees anything new. A non-object row
|
|
70
|
+
* yields the defaults rather than throwing, so a malformed page cannot take
|
|
71
|
+
* down a whole listing.
|
|
72
|
+
*/
|
|
73
|
+
export function withReadFields(row) {
|
|
74
|
+
return {
|
|
75
|
+
...(row && typeof row === 'object' ? row : {}),
|
|
76
|
+
seenCount: seenCountOf(row),
|
|
77
|
+
updatedAt: updatedAtOf(row),
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/store/format.mjs
CHANGED
|
@@ -28,6 +28,12 @@ export const FIELDS = [
|
|
|
28
28
|
// ttl.mjs). Null / absent means it never expires. Appended like the origin
|
|
29
29
|
// columns: a file written before this existed simply decodes it as absent.
|
|
30
30
|
'expires_at',
|
|
31
|
+
// Recurrence — how many times this lesson has been written, mirroring the
|
|
32
|
+
// hosted `memories.seen_count` column (migration 00058) so an offline store
|
|
33
|
+
// carries the same salience signal a remote one does. Appended like the
|
|
34
|
+
// columns above: a file written before this existed decodes it as absent,
|
|
35
|
+
// which the read projection reports as 0 rather than inventing a count.
|
|
36
|
+
'seen_count',
|
|
31
37
|
];
|
|
32
38
|
|
|
33
39
|
// Serialize an entry ({ ...columns, value }) into file text.
|
package/src/store/local.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import path from 'node:path';
|
|
|
10
10
|
import { serializeEntry, parseEntry, slugify, scopeToDir } from './format.mjs';
|
|
11
11
|
import { normalizeCreatedAt } from './created-at.mjs';
|
|
12
12
|
import { isLive, resolveExpiresAt } from './ttl.mjs';
|
|
13
|
+
import { seenCountOf, withReadFields } from './entry-fields.mjs';
|
|
13
14
|
|
|
14
15
|
export function createLocalStore(baseDir) {
|
|
15
16
|
return new LocalStore(baseDir);
|
|
@@ -73,13 +74,18 @@ class LocalStore {
|
|
|
73
74
|
}
|
|
74
75
|
rows.sort((a, b) => String(b.updated || '').localeCompare(String(a.updated || '')));
|
|
75
76
|
if (limit) rows = rows.slice(0, limit);
|
|
76
|
-
|
|
77
|
+
// The same additive projection the remote store applies, so a caller that
|
|
78
|
+
// ranks entries never has to ask which store produced them.
|
|
79
|
+
return { ok: true, entries: rows.map(withReadFields) };
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
// read({ scope, key }) → { ok, entry } — null when absent, archived, or expired.
|
|
80
83
|
async read({ scope, key } = {}) {
|
|
81
84
|
const found = this._findByKey(scope, key);
|
|
82
|
-
return {
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
entry: found && isLive(found.entry) ? withReadFields(found.entry) : null,
|
|
88
|
+
};
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
// write(...) → { ok, entry } — upsert by scope+key. Preserves `created` and
|
|
@@ -127,6 +133,21 @@ class LocalStore {
|
|
|
127
133
|
updated: existing ? now : override || now,
|
|
128
134
|
archived_at: null,
|
|
129
135
|
expires_at,
|
|
136
|
+
// Recurrence, counted the way the hosted `memory_write` RPC counts it
|
|
137
|
+
// (migration 00058): a write against a key this store already holds IS
|
|
138
|
+
// the next sighting. `seenCountOf` floors an absent/hand-edited value to
|
|
139
|
+
// 0, so a file written before this column existed resumes at 1 on its
|
|
140
|
+
// next write rather than throwing or restarting the tally at 2.
|
|
141
|
+
//
|
|
142
|
+
// Reviving an ARCHIVED entry restarts at 1, matching the hosted RPC.
|
|
143
|
+
// The two stores get there differently — every conflict predicate on
|
|
144
|
+
// `memories` is partial on `archived_at is null`, so the server INSERTS a
|
|
145
|
+
// fresh row, while this store revives the file in place (see the docblock
|
|
146
|
+
// above) — but the count means the same thing on both: the lesson was
|
|
147
|
+
// retired and is being learned again, not seen once more.
|
|
148
|
+
seen_count: existing && !existing.entry.archived_at
|
|
149
|
+
? seenCountOf(existing.entry) + 1
|
|
150
|
+
: 1,
|
|
130
151
|
value: value == null ? '' : String(value),
|
|
131
152
|
};
|
|
132
153
|
const file = existing ? existing.file : this._freshPath(dir, key);
|
|
@@ -158,6 +179,10 @@ class LocalStore {
|
|
|
158
179
|
updated: entry.updated ?? now,
|
|
159
180
|
archived_at: entry.archived_at ?? null,
|
|
160
181
|
expires_at: entry.expires_at ?? null,
|
|
182
|
+
// Verbatim, like every field here: migrate relocates a store, it does not
|
|
183
|
+
// re-sight its lessons, so a relocated entry must keep the count it had.
|
|
184
|
+
// An entry that never carried one lands as null and reads back as 0.
|
|
185
|
+
seen_count: entry.seen_count ?? null,
|
|
161
186
|
value: entry.value == null ? '' : String(entry.value),
|
|
162
187
|
};
|
|
163
188
|
const file = existing ? existing.file : this._freshPath(dir, entry.key);
|
package/src/store/remote.mjs
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// Zero-dependency.
|
|
21
21
|
import { restFetch, mcpToRestBase } from '../mcp.mjs';
|
|
22
22
|
import { getActiveTraceparent } from '../telemetry.mjs';
|
|
23
|
+
import { withReadFields } from './entry-fields.mjs';
|
|
23
24
|
|
|
24
25
|
// Drop undefined/null args so JSON payloads stay tidy.
|
|
25
26
|
function stripUndefined(obj) {
|
|
@@ -74,7 +75,10 @@ class RemoteStore {
|
|
|
74
75
|
const data = res.data ?? {};
|
|
75
76
|
return {
|
|
76
77
|
ok: true,
|
|
77
|
-
|
|
78
|
+
// `withReadFields` is additive — every key the route returned survives —
|
|
79
|
+
// so this projection costs existing callers nothing and gives a ranker
|
|
80
|
+
// the same `seenCount`/`updatedAt` pair the local store answers with.
|
|
81
|
+
entries: (data.entries ?? []).map(withReadFields),
|
|
78
82
|
hasMore: data.hasMore ?? false,
|
|
79
83
|
nextCursor: data.nextCursor ?? null,
|
|
80
84
|
};
|
|
@@ -97,12 +101,45 @@ class RemoteStore {
|
|
|
97
101
|
const data = res.data ?? {};
|
|
98
102
|
return {
|
|
99
103
|
ok: true,
|
|
100
|
-
entries: data.entries ?? [],
|
|
104
|
+
entries: (data.entries ?? []).map(withReadFields),
|
|
101
105
|
hasMore: data.hasMore ?? false,
|
|
102
106
|
nextCursor: data.nextCursor ?? null,
|
|
103
107
|
};
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
// Top-K lessons RANKED for a query — `GET /memories/relevant`.
|
|
111
|
+
//
|
|
112
|
+
// The difference from `search()` is the ordering, and it is the whole point:
|
|
113
|
+
// search returns what MATCHES (ordered `updated_at desc` by the handler),
|
|
114
|
+
// this returns what is worth READING, scored on recency + salience +
|
|
115
|
+
// relevance by the same ranking the SessionStart hook applies. It answers in
|
|
116
|
+
// a compact index — scope, key, a one-line hook, the score — never full
|
|
117
|
+
// bodies, so a caller pays for the shortlist and fetches only what it wants.
|
|
118
|
+
//
|
|
119
|
+
// `scopes` is ordered MOST-SPECIFIC FIRST and that order is meaningful: the
|
|
120
|
+
// server uses it to break ties, so passing `deriveScope().readOrder` verbatim
|
|
121
|
+
// gives a project lesson precedence over the global one it ties with.
|
|
122
|
+
//
|
|
123
|
+
// Returns the store's standard `{ ok, entries }` envelope so a caller can
|
|
124
|
+
// treat it like any other read, plus `candidates` — how many the FTS matched
|
|
125
|
+
// before ranking — so it can say "3 of 47" rather than implying it saw
|
|
126
|
+
// everything.
|
|
127
|
+
async relevant({ q, scopes, limit, minScore } = {}) {
|
|
128
|
+
const p = new URLSearchParams();
|
|
129
|
+
if (q) p.set('q', q);
|
|
130
|
+
if (scopes?.length) p.set('scopes', Array.isArray(scopes) ? scopes.join(',') : scopes);
|
|
131
|
+
if (limit) p.set('limit', String(limit));
|
|
132
|
+
if (minScore != null) p.set('min_score', String(minScore));
|
|
133
|
+
const res = await this._rest(`/memories/relevant?${p}`);
|
|
134
|
+
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
135
|
+
const data = res.data ?? {};
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
entries: Array.isArray(data.entries) ? data.entries : [],
|
|
139
|
+
candidates: Number(data.candidates) || 0,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
106
143
|
async read({ scope, key } = {}) {
|
|
107
144
|
const p = new URLSearchParams();
|
|
108
145
|
if (scope) p.set('scope', scope);
|
|
@@ -112,7 +149,9 @@ class RemoteStore {
|
|
|
112
149
|
const res = await this._rest(`/memories?${p}`);
|
|
113
150
|
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
114
151
|
const entries = res.data?.entries ?? [];
|
|
115
|
-
|
|
152
|
+
// Same projection as list/search — a single read must not answer with a
|
|
153
|
+
// different shape than the listing the caller found the key in.
|
|
154
|
+
return { ok: true, entry: entries[0] ? withReadFields(entries[0]) : null };
|
|
116
155
|
}
|
|
117
156
|
|
|
118
157
|
async write(args = {}) {
|
package/src/tree.mjs
CHANGED
|
@@ -9,9 +9,16 @@
|
|
|
9
9
|
// first) and keeps the FIRST value seen per key, so a more-specific scope
|
|
10
10
|
// shadows a broader scope's same-key lesson. `tree` resolves over that same
|
|
11
11
|
// `readOrder` set via the pure `resolvePrecedence`, so it shows the same
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// RESOLUTION — which copy of a key wins and which are shadowed — that the agent
|
|
13
|
+
// is injected under.
|
|
14
|
+
//
|
|
15
|
+
// It does NOT show the injected ORDER. The hook ranks the precedence winners
|
|
16
|
+
// with the pure `rankLessons` (recency + salience + relevance, scope only a
|
|
17
|
+
// tiebreak) and then spends a character budget on them
|
|
18
|
+
// (`hooks.sessionStart.maxChars`), so what the agent reads first is the
|
|
19
|
+
// scorer's order, not `readOrder`'s. `tree` stays a precedence view: unbudgeted,
|
|
20
|
+
// unranked, grouped narrow→broad, so a large workspace may list more winners
|
|
21
|
+
// than the hook injects and in a different order.
|
|
15
22
|
//
|
|
16
23
|
// NOTE on scope coverage: `readOrder` is the injected set. As of the smart-hooks
|
|
17
24
|
// PR it INCLUDES `project::` (project is the most-specific scope and now wins /
|