@lorekit/cli 1.26.0 → 1.27.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 +14 -1
- package/bin/lorekit.mjs +4 -3
- package/package.json +1 -1
- package/src/adapters/claude.mjs +3 -0
- package/src/control.mjs +22 -0
- package/src/core/friction.mjs +105 -0
- package/src/core/lessons.mjs +30 -17
- package/src/core/state.mjs +19 -3
- package/src/hook.mjs +29 -2
- package/src/mcp-server.mjs +12 -0
- package/src/store/format.mjs +4 -0
- package/src/store/local.mjs +19 -11
- package/src/store/ttl.mjs +69 -0
- package/src/write.mjs +26 -1
package/README.md
CHANGED
|
@@ -53,7 +53,8 @@ without needing a marketplace:
|
|
|
53
53
|
3. **Hooks** — the *deterministic* layer: lessons injected on every
|
|
54
54
|
`SessionStart`, and on a tool failure (`PostToolUseFailure`) any lessons that
|
|
55
55
|
look **relevant to that failure** ("you've hit this before") plus a nudge to
|
|
56
|
-
record the fix, and a retrospective nudge on `Stop
|
|
56
|
+
record the fix, and a retrospective nudge on `Stop` — by default only when the
|
|
57
|
+
session actually hit friction (`hooks.stop`). These fire the shared
|
|
57
58
|
`lorekit hook` engine and are merged into `settings.json` (existing hooks
|
|
58
59
|
preserved).
|
|
59
60
|
|
|
@@ -519,6 +520,18 @@ Both files share this schema — all fields optional:
|
|
|
519
520
|
// suppress specific hook events; union across layers
|
|
520
521
|
// values: "SessionStart" | "PostToolUseFailure" | "Stop"
|
|
521
522
|
|
|
523
|
+
"hooks.stop": "friction",
|
|
524
|
+
// gate the end-of-turn retrospective nudge:
|
|
525
|
+
// "friction" (default) — only nudge once/session when the
|
|
526
|
+
// session hit friction (a failed tool call or a stuck
|
|
527
|
+
// retry loop, read from the transcript); silent otherwise
|
|
528
|
+
// "always" — nudge once per session regardless
|
|
529
|
+
// "off" — never (same effect as disabling Stop)
|
|
530
|
+
// repo wins over user
|
|
531
|
+
// (friction is detectable only on Claude Code, which exposes a
|
|
532
|
+
// transcript; on Cursor/Codex there is none, so "friction"
|
|
533
|
+
// falls back to firing so no lesson is silently lost)
|
|
534
|
+
|
|
522
535
|
"hooks.adapter": "claude",
|
|
523
536
|
// explicit adapter when auto-detection is ambiguous
|
|
524
537
|
// values: "claude" | "cursor" | "codex"
|
package/bin/lorekit.mjs
CHANGED
|
@@ -288,7 +288,8 @@ ${c.bold('Options')}
|
|
|
288
288
|
--tags <a,b,c> Comma-separated tags (default: none)
|
|
289
289
|
--source-agent <n> Source agent name to record (default: none)
|
|
290
290
|
--trigger <slug> Trigger context slug (default: none)
|
|
291
|
-
--ttl-days <n> Days until auto-expiry 1–365 (remote
|
|
291
|
+
--ttl-days <n> Days until auto-expiry 1–365 (local or remote)
|
|
292
|
+
--clear-ttl Remove any existing expiry (make it permanent)
|
|
292
293
|
--org <slug> Write to this org's scope (remote only)
|
|
293
294
|
--origin-repo <o/n> Override the derived provenance repository
|
|
294
295
|
--origin-branch <b> Override the derived provenance branch
|
|
@@ -574,7 +575,7 @@ const KNOWN_FLAGS = [
|
|
|
574
575
|
'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
|
|
575
576
|
'from', 'to', 'apply', 'yes', 'no-hooks', 'force', 'deep', 'adapter',
|
|
576
577
|
'event', 'json', 'scope', 'threshold', 'help', 'version',
|
|
577
|
-
'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'org', 'remote', 'local',
|
|
578
|
+
'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
|
|
578
579
|
'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
|
|
579
580
|
'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
|
|
580
581
|
];
|
|
@@ -601,7 +602,7 @@ async function main() {
|
|
|
601
602
|
const argv = process.argv.slice(2);
|
|
602
603
|
const args = parseArgs(argv, {
|
|
603
604
|
aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
|
|
604
|
-
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'no-origin', 'json', 'remote', 'local', 'link', 'archived'],
|
|
605
|
+
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl'],
|
|
605
606
|
known: KNOWN_FLAGS,
|
|
606
607
|
});
|
|
607
608
|
|
package/package.json
CHANGED
package/src/adapters/claude.mjs
CHANGED
|
@@ -42,6 +42,9 @@ export const claude = {
|
|
|
42
42
|
toolInput: input.tool_input || null,
|
|
43
43
|
toolResponse: input.tool_response || null,
|
|
44
44
|
event: input.hook_event_name || null,
|
|
45
|
+
// Path to the session JSONL (present on Stop/SubagentStop) — read by the
|
|
46
|
+
// friction-gated retrospective to decide whether the session is worth a nudge.
|
|
47
|
+
transcriptPath: input.transcript_path || null,
|
|
45
48
|
};
|
|
46
49
|
},
|
|
47
50
|
|
package/src/control.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// scope.defaults — map of scope-prefix → { tags } applied to every matching write
|
|
6
6
|
// tags.default — array of tags appended to every write (both layers merged)
|
|
7
7
|
// hooks.disabled — array of hook event names to suppress (e.g. ["Stop"])
|
|
8
|
+
// hooks.stop — Stop-hook gating ("friction" default | "always" | "off")
|
|
8
9
|
// hooks.adapter — explicit adapter override ("claude" | "cursor" | "codex")
|
|
9
10
|
//
|
|
10
11
|
// Two layers of config, two kinds of statement:
|
|
@@ -39,6 +40,19 @@ export function normalizeMode(v) {
|
|
|
39
40
|
return null;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// Stop-hook behaviour: `friction` (default — only nudge on detected friction),
|
|
44
|
+
// `always` (nudge once per session regardless), or `off` (never). Accepts a few
|
|
45
|
+
// friendly spellings so config stays forgiving.
|
|
46
|
+
export const STOP_MODES = ['friction', 'always', 'off'];
|
|
47
|
+
export function normalizeStopMode(v) {
|
|
48
|
+
if (typeof v !== 'string') return null;
|
|
49
|
+
const s = v.trim().toLowerCase();
|
|
50
|
+
if (['off', 'none', 'false', 'disabled', 'never'].includes(s)) return 'off';
|
|
51
|
+
if (['always', 'all', 'on', 'true', 'every'].includes(s)) return 'always';
|
|
52
|
+
if (['friction', 'smart', 'auto'].includes(s)) return 'friction';
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
function asList(v) {
|
|
43
57
|
if (Array.isArray(v)) return v;
|
|
44
58
|
if (typeof v === 'string') return v.split(',').map((s) => s.trim()).filter(Boolean);
|
|
@@ -131,6 +145,13 @@ export function resolveControl({
|
|
|
131
145
|
...asList(userConfig['hooks.disabled']),
|
|
132
146
|
]);
|
|
133
147
|
|
|
148
|
+
// `hooks.stop` — repo layer wins over user layer, default `friction`. Gates the
|
|
149
|
+
// end-of-turn retrospective: friction-only (default), always, or off.
|
|
150
|
+
const hooksStop =
|
|
151
|
+
normalizeStopMode(repoConfig['hooks.stop']) ||
|
|
152
|
+
normalizeStopMode(userConfig['hooks.stop']) ||
|
|
153
|
+
'friction';
|
|
154
|
+
|
|
134
155
|
// `hooks.adapter` — repo layer wins over user layer (explicit project override).
|
|
135
156
|
const hooksAdapter =
|
|
136
157
|
(typeof repoConfig['hooks.adapter'] === 'string' && repoConfig['hooks.adapter'].trim()) ||
|
|
@@ -167,6 +188,7 @@ export function resolveControl({
|
|
|
167
188
|
tagsDefault,
|
|
168
189
|
scopeDefaults,
|
|
169
190
|
hooksDisabled,
|
|
191
|
+
hooksStop,
|
|
170
192
|
hooksAdapter,
|
|
171
193
|
hooksInstructions,
|
|
172
194
|
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Friction detection for the Stop (retrospective) hook.
|
|
2
|
+
//
|
|
3
|
+
// The retrospective nudge has no side effect of its own — it exists only to
|
|
4
|
+
// prompt an end-of-turn reflection. Firing it on *every* session is noise: a
|
|
5
|
+
// clean "regenerate four icons" turn had nothing worth remembering. So when
|
|
6
|
+
// `hooks.stop` is `friction` (the default) we read the session transcript and
|
|
7
|
+
// only surface the nudge when the session actually hit friction.
|
|
8
|
+
//
|
|
9
|
+
// Signals are deliberately conservative (a false positive nudges needlessly):
|
|
10
|
+
// - `failure` — a tool call reported an error this session (is_error).
|
|
11
|
+
// - `stuck-loop` — the same tool+input ran >= STUCK_LOOP_THRESHOLD times
|
|
12
|
+
// (a retry loop / repeated dead end).
|
|
13
|
+
//
|
|
14
|
+
// The pure `detectFriction` works on the raw transcript text so it is unit
|
|
15
|
+
// testable with fixture strings; `readSessionFriction` is the thin IO wrapper.
|
|
16
|
+
// Both are best-effort: anything unreadable degrades to `friction: null`
|
|
17
|
+
// ("unknown"), never a throw — a memory hook must never break the host.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
|
|
20
|
+
export const STUCK_LOOP_THRESHOLD = 3;
|
|
21
|
+
|
|
22
|
+
// Reason codes, surfaced in the nudge so the reflection is grounded ("this
|
|
23
|
+
// session hit a failed tool call") rather than a generic prompt.
|
|
24
|
+
export const FRICTION_FAILURE = 'failure';
|
|
25
|
+
export const FRICTION_STUCK_LOOP = 'stuck-loop';
|
|
26
|
+
|
|
27
|
+
// Pure: scan a Claude Code JSONL transcript for friction signals. (Only Claude
|
|
28
|
+
// Code surfaces a transcript path to the hook; the Cursor/Codex adapters don't,
|
|
29
|
+
// so friction there is always `null` — see readSessionFriction / shouldRetrospect.)
|
|
30
|
+
// Returns { friction: boolean, reasons: string[] }. Never throws.
|
|
31
|
+
export function detectFriction(transcriptText, { stuckLoopThreshold = STUCK_LOOP_THRESHOLD } = {}) {
|
|
32
|
+
const reasons = new Set();
|
|
33
|
+
if (typeof transcriptText !== 'string' || transcriptText.length === 0) {
|
|
34
|
+
return { friction: false, reasons: [] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const callCounts = new Map(); // "name input" -> count
|
|
38
|
+
for (const line of transcriptText.split('\n')) {
|
|
39
|
+
const trimmed = line.trim();
|
|
40
|
+
if (!trimmed) continue;
|
|
41
|
+
let entry;
|
|
42
|
+
try {
|
|
43
|
+
entry = JSON.parse(trimmed);
|
|
44
|
+
} catch {
|
|
45
|
+
continue; // tolerate non-JSON / partial lines
|
|
46
|
+
}
|
|
47
|
+
const content = entry && entry.message && Array.isArray(entry.message.content)
|
|
48
|
+
? entry.message.content
|
|
49
|
+
: null;
|
|
50
|
+
if (!content) continue;
|
|
51
|
+
|
|
52
|
+
for (const item of content) {
|
|
53
|
+
if (!item || typeof item !== 'object') continue;
|
|
54
|
+
if (item.type === 'tool_result' && item.is_error === true) {
|
|
55
|
+
reasons.add(FRICTION_FAILURE);
|
|
56
|
+
} else if (item.type === 'tool_use' && typeof item.name === 'string') {
|
|
57
|
+
// Serialize the input so identical retries collide; JSON.stringify is
|
|
58
|
+
// enough for the loop signal (key order is stable within one session).
|
|
59
|
+
let inputKey = '';
|
|
60
|
+
try {
|
|
61
|
+
inputKey = JSON.stringify(item.input ?? null);
|
|
62
|
+
} catch {
|
|
63
|
+
inputKey = '';
|
|
64
|
+
}
|
|
65
|
+
const key = `${item.name} ${inputKey}`;
|
|
66
|
+
const next = (callCounts.get(key) || 0) + 1;
|
|
67
|
+
callCounts.set(key, next);
|
|
68
|
+
if (next >= stuckLoopThreshold) reasons.add(FRICTION_STUCK_LOOP);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { friction: reasons.size > 0, reasons: [...reasons] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// IO wrapper: read the transcript at `transcriptPath` and detect friction.
|
|
77
|
+
// Returns { friction: null, reasons: [] } when the path is absent or unreadable
|
|
78
|
+
// — "unknown", which the caller treats conservatively (does not swallow the
|
|
79
|
+
// nudge on platforms/turns where we cannot measure).
|
|
80
|
+
export function readSessionFriction(transcriptPath, opts = {}) {
|
|
81
|
+
if (!transcriptPath || typeof transcriptPath !== 'string') {
|
|
82
|
+
return { friction: null, reasons: [] };
|
|
83
|
+
}
|
|
84
|
+
let text;
|
|
85
|
+
try {
|
|
86
|
+
text = fs.readFileSync(transcriptPath, 'utf8');
|
|
87
|
+
} catch {
|
|
88
|
+
return { friction: null, reasons: [] };
|
|
89
|
+
}
|
|
90
|
+
return detectFriction(text, opts);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Pure gating decision for the retrospective hook, given the resolved
|
|
94
|
+
// `hooks.stop` mode and the detected friction (true | false | null=unknown):
|
|
95
|
+
// - off → never
|
|
96
|
+
// - always → always
|
|
97
|
+
// - friction → only on positively-detected friction; `null` (undetectable,
|
|
98
|
+
// e.g. no transcript on Cursor/Codex) falls back to firing so no
|
|
99
|
+
// lesson is silently lost where we cannot measure.
|
|
100
|
+
export function shouldRetrospect(mode, friction) {
|
|
101
|
+
if (mode === 'off') return false;
|
|
102
|
+
if (mode === 'always') return true;
|
|
103
|
+
// friction mode
|
|
104
|
+
return friction !== false;
|
|
105
|
+
}
|
package/src/core/lessons.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { resolvePrecedence, matchesQuery } from '../lessons-pure.mjs';
|
|
|
13
13
|
// correctly (a raw `?scope=global` silently means "all scopes") and can't drift
|
|
14
14
|
// from the command-line links.
|
|
15
15
|
import { loreScopeUrl, buildLessonUrl } from '../deeplink-pure.mjs';
|
|
16
|
+
import { FRICTION_FAILURE, FRICTION_STUCK_LOOP } from './friction.mjs';
|
|
16
17
|
|
|
17
18
|
const MAX_LESSONS = 15;
|
|
18
19
|
// Cap on lessons injected on a failure — a small, focused "you've seen this
|
|
@@ -161,7 +162,8 @@ export function formatRelevantLessons(lessons) {
|
|
|
161
162
|
|
|
162
163
|
// Build a tags hint string from config-resolved tags and scope defaults. Returns
|
|
163
164
|
// "" when there are no configured tags (no hint appended to the nudge).
|
|
164
|
-
function tagsHint(writeScope,
|
|
165
|
+
function tagsHint(writeScope, control) {
|
|
166
|
+
const { tagsDefault = [], scopeDefaults = null } = control || {};
|
|
165
167
|
const tags = [...tagsDefault];
|
|
166
168
|
if (scopeDefaults) {
|
|
167
169
|
for (const [prefix, cfg] of Object.entries(scopeDefaults)) {
|
|
@@ -179,30 +181,41 @@ function tagsHint(writeScope, { tagsDefault = [], scopeDefaults = null } = {}) {
|
|
|
179
181
|
return ` Include tags: [${tags.map((t) => JSON.stringify(t)).join(', ')}].`;
|
|
180
182
|
}
|
|
181
183
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
184
|
+
// One-line phrases for the detected friction reason codes (see core/friction.mjs),
|
|
185
|
+
// so the nudge names what happened instead of a generic prompt.
|
|
186
|
+
const REASON_PHRASES = {
|
|
187
|
+
[FRICTION_FAILURE]: 'a failed tool call',
|
|
188
|
+
[FRICTION_STUCK_LOOP]: 'a repeated retry',
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Join detected reason codes into a readable clause ("a failed tool call and a
|
|
192
|
+
// repeated retry"). Empty/unknown reasons → "" (caller uses the generic prompt).
|
|
193
|
+
function describeReasons(reasons) {
|
|
194
|
+
const phrases = (Array.isArray(reasons) ? reasons : [])
|
|
195
|
+
.map((r) => REASON_PHRASES[r])
|
|
196
|
+
.filter(Boolean);
|
|
197
|
+
if (phrases.length === 0) return '';
|
|
198
|
+
if (phrases.length === 1) return phrases[0];
|
|
199
|
+
return `${phrases.slice(0, -1).join(', ')} and ${phrases[phrases.length - 1]}`;
|
|
189
200
|
}
|
|
190
201
|
|
|
191
202
|
// The retrospective nudge emitted at end-of-turn (one-shot per session).
|
|
192
203
|
// `control` is the resolved control object (optional) — carries tagsDefault and
|
|
193
|
-
// scopeDefaults when the repo/user config defines them.
|
|
194
|
-
|
|
204
|
+
// scopeDefaults when the repo/user config defines them. `opts.reasons` is the
|
|
205
|
+
// detected friction reason codes (from core/friction.mjs) when `hooks.stop` is
|
|
206
|
+
// `friction`; when present the nudge names them so the reflection is grounded.
|
|
207
|
+
// Kept to a single line — the lore deep-link lives on the write CONFIRMATION,
|
|
208
|
+
// which is where a link is actually actionable.
|
|
209
|
+
export function retrospectiveNudge(scope, control, { reasons = [] } = {}) {
|
|
195
210
|
const writeScope = scope.repoScope || 'global';
|
|
196
211
|
const hint = tagsHint(writeScope, control);
|
|
197
212
|
const instruction = control && control.hooksInstructions && control.hooksInstructions.Stop
|
|
198
213
|
? `\n\nProject instruction: ${control.hooksInstructions.Stop}` : '';
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
`LoreKit:
|
|
202
|
-
`
|
|
203
|
-
|
|
204
|
-
`View lore: ${url}`
|
|
205
|
-
);
|
|
214
|
+
const detected = describeReasons(reasons);
|
|
215
|
+
const lead = detected
|
|
216
|
+
? `LoreKit: this session hit ${detected} — a lesson worth saving?`
|
|
217
|
+
: `LoreKit: any friction worth remembering (stuck loop, repeat failure, gotcha, wrong assumption)?`;
|
|
218
|
+
return `${lead} memory.write to ${writeScope}; else skip.${hint}${instruction}`;
|
|
206
219
|
}
|
|
207
220
|
|
|
208
221
|
// Terse confirmation emitted via PostToolUse when a memory.write succeeded.
|
package/src/core/state.mjs
CHANGED
|
@@ -11,17 +11,33 @@ function stateDir() {
|
|
|
11
11
|
return base;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function markerPath(sessionId, tag) {
|
|
15
|
+
const hash = crypto.createHash('sha256').update(`${sessionId}:${tag}`).digest('hex').slice(0, 16);
|
|
16
|
+
return path.join(stateDir(), `${hash}.seen`);
|
|
17
|
+
}
|
|
18
|
+
|
|
14
19
|
// Returns true the FIRST time called for a given (sessionId, tag), false after.
|
|
15
20
|
// Missing sessionId → always true (cannot throttle without a key).
|
|
16
21
|
export function firstTimeThisSession(sessionId, tag) {
|
|
17
22
|
if (!sessionId) return true;
|
|
18
|
-
const hash = crypto.createHash('sha256').update(`${sessionId}:${tag}`).digest('hex').slice(0, 16);
|
|
19
|
-
const marker = path.join(stateDir(), `${hash}.seen`);
|
|
20
23
|
try {
|
|
21
24
|
// wx fails if the file already exists → not the first time.
|
|
22
|
-
fs.writeFileSync(
|
|
25
|
+
fs.writeFileSync(markerPath(sessionId, tag), '', { flag: 'wx' });
|
|
23
26
|
return true;
|
|
24
27
|
} catch {
|
|
25
28
|
return false;
|
|
26
29
|
}
|
|
27
30
|
}
|
|
31
|
+
|
|
32
|
+
// Read-only peek: has a (sessionId, tag) marker already been written this
|
|
33
|
+
// session? Unlike `firstTimeThisSession` this NEVER creates the marker, so a
|
|
34
|
+
// caller can ask "did the failure hook already fire?" without consuming a
|
|
35
|
+
// throttle it does not own. Missing sessionId (or any IO error) → false.
|
|
36
|
+
export function sessionMarkerExists(sessionId, tag) {
|
|
37
|
+
if (!sessionId) return false;
|
|
38
|
+
try {
|
|
39
|
+
return fs.existsSync(markerPath(sessionId, tag));
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/hook.mjs
CHANGED
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
writeConfirmation,
|
|
19
19
|
} from './core/lessons.mjs';
|
|
20
20
|
import { isFailure } from './core/failure.mjs';
|
|
21
|
-
import {
|
|
21
|
+
import { readSessionFriction, shouldRetrospect, FRICTION_FAILURE } from './core/friction.mjs';
|
|
22
|
+
import { firstTimeThisSession, sessionMarkerExists } from './core/state.mjs';
|
|
22
23
|
import { recordFixture } from './core/record.mjs';
|
|
23
24
|
import { claude } from './adapters/claude.mjs';
|
|
24
25
|
import { cursor } from './adapters/cursor.mjs';
|
|
@@ -160,8 +161,34 @@ async function run(args) {
|
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
if (intent === 'retrospective') {
|
|
164
|
+
// `hooks.stop` gates the retrospective: `friction` (default) reads the
|
|
165
|
+
// session transcript and only nudges when it hit real friction; `always`
|
|
166
|
+
// keeps the once-per-session nudge; `off` is silent. The friction read is
|
|
167
|
+
// side-effect-free and happens BEFORE the once-per-session throttle is
|
|
168
|
+
// consumed, so a clean early turn stays silent without burning the marker —
|
|
169
|
+
// a later turn that does hit friction can still fire (once).
|
|
170
|
+
const stopMode = control.hooksStop || 'friction';
|
|
171
|
+
let reasons = [];
|
|
172
|
+
let friction = null;
|
|
173
|
+
if (stopMode === 'friction') {
|
|
174
|
+
({ friction, reasons } = readSessionFriction(parsed.transcriptPath));
|
|
175
|
+
// The transcript is written ASYNCHRONOUSLY and may lag the current turn
|
|
176
|
+
// (Claude Code hooks reference, `transcript_path`), so a Stop fired right
|
|
177
|
+
// after a failing tool call can read a positively-clean `false`. The
|
|
178
|
+
// PostToolUseFailure hook already left a session-keyed marker when it
|
|
179
|
+
// fired, which is a transcript-independent witness of the exact same
|
|
180
|
+
// predicate `detectFriction` calls FRICTION_FAILURE (any errored tool
|
|
181
|
+
// result anywhere this session). Peek at it (read-only — never consume
|
|
182
|
+
// the marker) and upgrade. This covers the `failure` signal ONLY;
|
|
183
|
+
// `stuck-loop` remains transcript-only and is still exposed to the lag.
|
|
184
|
+
if (friction === false && sessionMarkerExists(parsed.sessionId, 'failure')) {
|
|
185
|
+
friction = true;
|
|
186
|
+
reasons = [FRICTION_FAILURE];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (!shouldRetrospect(stopMode, friction)) return 0;
|
|
163
190
|
if (!firstTimeThisSession(parsed.sessionId, 'retro')) return 0;
|
|
164
|
-
emit(retrospectiveNudge(scope, control));
|
|
191
|
+
emit(retrospectiveNudge(scope, control, { reasons }));
|
|
165
192
|
return 0;
|
|
166
193
|
}
|
|
167
194
|
|
package/src/mcp-server.mjs
CHANGED
|
@@ -56,6 +56,18 @@ export const MEMORY_TOOL_DEFS = [
|
|
|
56
56
|
description:
|
|
57
57
|
'Optional ISO 8601 creation date for migrating a pre-existing memory. Rejected if invalid or in the future. Applies only when the memory is first created.',
|
|
58
58
|
},
|
|
59
|
+
ttl_days: {
|
|
60
|
+
type: 'integer',
|
|
61
|
+
minimum: 1,
|
|
62
|
+
maximum: 365,
|
|
63
|
+
description:
|
|
64
|
+
'Optional time-to-live in days (1–365). The memory auto-expires that many days after this write and is then hidden from reads.',
|
|
65
|
+
},
|
|
66
|
+
clear_ttl: {
|
|
67
|
+
type: 'boolean',
|
|
68
|
+
description:
|
|
69
|
+
'Remove any existing expiry, making the memory permanent again. Takes precedence over ttl_days when both are supplied.',
|
|
70
|
+
},
|
|
59
71
|
origin_repo: {
|
|
60
72
|
type: 'string',
|
|
61
73
|
description:
|
package/src/store/format.mjs
CHANGED
|
@@ -24,6 +24,10 @@ export const FIELDS = [
|
|
|
24
24
|
'origin_branch',
|
|
25
25
|
'origin_commit',
|
|
26
26
|
'origin_pr',
|
|
27
|
+
// Expiry — the absolute ISO instant this lesson auto-expires (see src/store/
|
|
28
|
+
// ttl.mjs). Null / absent means it never expires. Appended like the origin
|
|
29
|
+
// columns: a file written before this existed simply decodes it as absent.
|
|
30
|
+
'expires_at',
|
|
27
31
|
];
|
|
28
32
|
|
|
29
33
|
// Serialize an entry ({ ...columns, value }) into file text.
|
package/src/store/local.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import fs from 'node:fs';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { serializeEntry, parseEntry, slugify, scopeToDir } from './format.mjs';
|
|
11
11
|
import { normalizeCreatedAt } from './created-at.mjs';
|
|
12
|
+
import { isLive, resolveExpiresAt } from './ttl.mjs';
|
|
12
13
|
|
|
13
14
|
export function createLocalStore(baseDir) {
|
|
14
15
|
return new LocalStore(baseDir);
|
|
@@ -61,11 +62,12 @@ class LocalStore {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
// list({ scope, tags, limit }) → { ok, entries } — newest-first, tag-filtered,
|
|
64
|
-
// archived hidden.
|
|
65
|
+
// archived hidden, expired hidden (lazily, mirroring the remote read paths).
|
|
65
66
|
async list({ scope, tags, limit } = {}) {
|
|
67
|
+
const now = new Date();
|
|
66
68
|
let rows = this._readAll(scope)
|
|
67
69
|
.map((r) => r.entry)
|
|
68
|
-
.filter((e) =>
|
|
70
|
+
.filter((e) => isLive(e, now));
|
|
69
71
|
if (Array.isArray(tags) && tags.length) {
|
|
70
72
|
rows = rows.filter((e) => tags.every((t) => (e.tags || []).includes(t)));
|
|
71
73
|
}
|
|
@@ -74,11 +76,10 @@ class LocalStore {
|
|
|
74
76
|
return { ok: true, entries: rows };
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
// read({ scope, key }) → { ok, entry } — null when absent or
|
|
79
|
+
// read({ scope, key }) → { ok, entry } — null when absent, archived, or expired.
|
|
78
80
|
async read({ scope, key } = {}) {
|
|
79
81
|
const found = this._findByKey(scope, key);
|
|
80
|
-
|
|
81
|
-
return { ok: true, entry };
|
|
82
|
+
return { ok: true, entry: found && isLive(found.entry) ? found.entry : null };
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
// write(...) → { ok, entry } — upsert by scope+key. Preserves `created` and
|
|
@@ -92,19 +93,22 @@ class LocalStore {
|
|
|
92
93
|
// an invalid or future-dated value rather than throwing, matching the store
|
|
93
94
|
// contract's error surfacing.
|
|
94
95
|
async write({
|
|
95
|
-
scope, key, value, tags, source_agent, trigger, created_at,
|
|
96
|
+
scope, key, value, tags, source_agent, trigger, created_at, ttl_days, clear_ttl,
|
|
96
97
|
origin_repo, origin_branch, origin_commit, origin_pr,
|
|
97
98
|
} = {}) {
|
|
98
|
-
|
|
99
|
+
const now = new Date().toISOString();
|
|
100
|
+
const existing = this._findByKey(scope, key);
|
|
101
|
+
let override, expires_at;
|
|
99
102
|
try {
|
|
100
103
|
override = normalizeCreatedAt(created_at);
|
|
104
|
+
expires_at = resolveExpiresAt({
|
|
105
|
+
clearTtl: clear_ttl, ttlDays: ttl_days, now, current: existing?.entry.expires_at,
|
|
106
|
+
});
|
|
101
107
|
} catch (e) {
|
|
102
108
|
return { ok: false, error: e.message };
|
|
103
109
|
}
|
|
104
110
|
const dir = this._dir(scope);
|
|
105
111
|
fs.mkdirSync(dir, { recursive: true });
|
|
106
|
-
const now = new Date().toISOString();
|
|
107
|
-
const existing = this._findByKey(scope, key);
|
|
108
112
|
const created = existing ? existing.entry.created || now : override || now;
|
|
109
113
|
const entry = {
|
|
110
114
|
scope,
|
|
@@ -122,6 +126,7 @@ class LocalStore {
|
|
|
122
126
|
created,
|
|
123
127
|
updated: existing ? now : override || now,
|
|
124
128
|
archived_at: null,
|
|
129
|
+
expires_at,
|
|
125
130
|
value: value == null ? '' : String(value),
|
|
126
131
|
};
|
|
127
132
|
const file = existing ? existing.file : this._freshPath(dir, key);
|
|
@@ -152,6 +157,7 @@ class LocalStore {
|
|
|
152
157
|
created: entry.created ?? now,
|
|
153
158
|
updated: entry.updated ?? now,
|
|
154
159
|
archived_at: entry.archived_at ?? null,
|
|
160
|
+
expires_at: entry.expires_at ?? null,
|
|
155
161
|
value: entry.value == null ? '' : String(entry.value),
|
|
156
162
|
};
|
|
157
163
|
const file = existing ? existing.file : this._freshPath(dir, entry.key);
|
|
@@ -258,9 +264,10 @@ class LocalStore {
|
|
|
258
264
|
// lossy for `project::{name}` (stored by basename only). Returns
|
|
259
265
|
// `[{ scope, count }]`, unsorted.
|
|
260
266
|
async listScopes() {
|
|
267
|
+
const now = new Date();
|
|
261
268
|
const counts = new Map();
|
|
262
269
|
for (const { entry } of this._walkEntries()) {
|
|
263
|
-
if (entry.
|
|
270
|
+
if (!entry.scope || !isLive(entry, now)) continue;
|
|
264
271
|
counts.set(entry.scope, (counts.get(entry.scope) || 0) + 1);
|
|
265
272
|
}
|
|
266
273
|
return [...counts.entries()].map(([scope, count]) => ({ scope, count }));
|
|
@@ -392,12 +399,13 @@ class TwoTierStore {
|
|
|
392
399
|
// a lesson present in both tiers is counted once — project shadows home, the
|
|
393
400
|
// same first-wins merge `list()` uses. Returns `[{ scope, count }]`, unsorted.
|
|
394
401
|
async listScopes() {
|
|
402
|
+
const now = new Date();
|
|
395
403
|
const seen = new Set(); // `${scope}\x00${key}` — dedup across tiers
|
|
396
404
|
const counts = new Map();
|
|
397
405
|
const tiers = this.projectActive() ? [this.project, this.home] : [this.home];
|
|
398
406
|
for (const tier of tiers) {
|
|
399
407
|
for (const { entry } of tier._walkEntries()) {
|
|
400
|
-
if (entry.
|
|
408
|
+
if (!entry.scope || !isLive(entry, now)) continue;
|
|
401
409
|
const id = `${entry.scope}\x00${entry.key ?? ''}`;
|
|
402
410
|
if (seen.has(id)) continue;
|
|
403
411
|
seen.add(id);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Zero-dependency mirror of the TTL (time-to-live) contract used by the hosted
|
|
2
|
+
// MCP server (packages/mcp-core/src/ttl.ts) and the `memory_write` RPC
|
|
3
|
+
// (migrations 00030/00031). Keeps the local file store's expiry semantics
|
|
4
|
+
// identical to the remote one, so a memory written offline expires the same way
|
|
5
|
+
// it would have online — and a local↔remote migration is lossless.
|
|
6
|
+
//
|
|
7
|
+
// - `ttl_days` (1–365) sets `expires_at = <write instant> + N days`, mirroring
|
|
8
|
+
// the RPC's `now() + interval` — NOT `created + N`, so a backdated migration
|
|
9
|
+
// (created_at override) still expires relative to when it was written.
|
|
10
|
+
// - `clear_ttl` removes the expiry, making the row permanent again; it beats
|
|
11
|
+
// `ttl_days` when both are supplied (the RPC's tri-state precedence).
|
|
12
|
+
// - a read filters an expired row out lazily (there is no purge daemon
|
|
13
|
+
// offline), exactly as the remote read paths do.
|
|
14
|
+
|
|
15
|
+
export const TTL_MIN_DAYS = 1;
|
|
16
|
+
export const TTL_MAX_DAYS = 365;
|
|
17
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
// Validate and normalise an optional `ttl_days` write parameter.
|
|
20
|
+
// Returns the integer number of days, or null when no TTL was supplied.
|
|
21
|
+
// Throws Error on a present-but-invalid value (fractional, out of range, NaN).
|
|
22
|
+
export function parseTtlDays(input) {
|
|
23
|
+
if (input === undefined || input === null) return null;
|
|
24
|
+
const n = typeof input === 'number' ? input : Number(input);
|
|
25
|
+
if (!Number.isFinite(n)) throw new Error('ttl_days must be a finite number');
|
|
26
|
+
if (!Number.isInteger(n)) throw new Error('ttl_days must be an integer');
|
|
27
|
+
if (n < TTL_MIN_DAYS) throw new Error(`ttl_days must be >= ${TTL_MIN_DAYS}`);
|
|
28
|
+
if (n > TTL_MAX_DAYS) throw new Error(`ttl_days must be <= ${TTL_MAX_DAYS}`);
|
|
29
|
+
return n;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The absolute ISO expiry instant for a memory: `from` (ISO string or Date)
|
|
33
|
+
// advanced by `ttlDays` whole days.
|
|
34
|
+
export function expiresAtFrom(ttlDays, from) {
|
|
35
|
+
const base = from instanceof Date ? from.getTime() : Date.parse(from);
|
|
36
|
+
return new Date(base + ttlDays * DAY_MS).toISOString();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Whether a stored `expires_at` has elapsed at `now`. Absent → never expires.
|
|
40
|
+
// An unparseable value fails SAFE (treated as never-expiring) so a corrupt or
|
|
41
|
+
// hand-edited frontmatter field can never hide a lesson from every read.
|
|
42
|
+
export function isExpired(expiresAt, now = new Date()) {
|
|
43
|
+
if (!expiresAt) return false;
|
|
44
|
+
const ms = Date.parse(expiresAt);
|
|
45
|
+
if (Number.isNaN(ms)) return false;
|
|
46
|
+
return ms <= now.getTime();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Whether a stored entry is currently visible to reads: not archived and not
|
|
50
|
+
// expired. The SINGLE definition of "live", shared by every read path (list /
|
|
51
|
+
// read / listScopes) so a future hidden dimension is added once, never
|
|
52
|
+
// re-spelled per call site. The raw primitives (getEntry / _findByKey for
|
|
53
|
+
// delete / archive) deliberately bypass this so they can still act on hidden rows.
|
|
54
|
+
export function isLive(entry, now = new Date()) {
|
|
55
|
+
return !entry.archived_at && !isExpired(entry.expires_at, now);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Resolve a write's `expires_at` from the tri-state TTL inputs, mirroring
|
|
59
|
+
// memory_write (00030/00031): `clearTtl` wins (→ permanent, and `ttlDays` is
|
|
60
|
+
// never even validated); else a supplied `ttlDays` sets expiry from `now`; else
|
|
61
|
+
// the row keeps whatever `current` expiry it already had. Throws (via
|
|
62
|
+
// parseTtlDays) on an invalid `ttlDays` only when NOT clearing, so the caller
|
|
63
|
+
// can surface `{ ok:false }`.
|
|
64
|
+
export function resolveExpiresAt({ clearTtl, ttlDays, now, current } = {}) {
|
|
65
|
+
if (clearTtl) return null;
|
|
66
|
+
const days = parseTtlDays(ttlDays);
|
|
67
|
+
if (days != null) return expiresAtFrom(days, now);
|
|
68
|
+
return current ?? null;
|
|
69
|
+
}
|
package/src/write.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// --source-agent <name> Which agent recorded this lesson (default: none)
|
|
15
15
|
// --trigger <slug> What prompted the write (default: none)
|
|
16
16
|
// --ttl-days <n> Days until the memory auto-expires (1–365)
|
|
17
|
+
// --clear-ttl Remove any existing expiry (make the memory permanent)
|
|
17
18
|
// --org <slug> Write to this org (remote only)
|
|
18
19
|
//
|
|
19
20
|
// Provenance — where the lesson is being recorded FROM. Derived automatically
|
|
@@ -43,6 +44,7 @@ import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
|
43
44
|
import { log, err, heading, status, c } from './util.mjs';
|
|
44
45
|
import { parseScopeKey } from './lessons-view.mjs';
|
|
45
46
|
import { deriveOrigin, mergeOrigin } from './origin.mjs';
|
|
47
|
+
import { parseTtlDays } from './store/ttl.mjs';
|
|
46
48
|
|
|
47
49
|
// Read all of stdin to a string. Resolves to '' when stdin IS a TTY (no pipe).
|
|
48
50
|
function readStdin() {
|
|
@@ -108,7 +110,28 @@ export async function write(args) {
|
|
|
108
110
|
const tags = args.tags ? String(args.tags).split(',').map((t) => t.trim()).filter(Boolean) : [];
|
|
109
111
|
const sourceAgent = typeof args['source-agent'] === 'string' ? args['source-agent'] : undefined;
|
|
110
112
|
const trigger = typeof args.trigger === 'string' ? args.trigger : undefined;
|
|
111
|
-
|
|
113
|
+
// `--ttl-days` is validated HERE, at the flag seam, rather than being left to the
|
|
114
|
+
// store: a truthiness test silently swallowed `--ttl-days 0` (falsy) and
|
|
115
|
+
// `--ttl-days abc` (NaN, dropped again by the `ttl_days` spread further down), so
|
|
116
|
+
// both exited 0 having written no expiry while `--ttl-days 999` correctly errored.
|
|
117
|
+
// The seam matters as much as the check — `store/remote.mjs` forwards `ttl_days`
|
|
118
|
+
// verbatim and `JSON.stringify(NaN)` would reach the server as `null`, so a
|
|
119
|
+
// store-side fix would leave the remote path silently broken. Mirrors how
|
|
120
|
+
// `--origin-pr` is handled below: an explicitly supplied value is a caller
|
|
121
|
+
// assertion, so a malformed one is a usage error.
|
|
122
|
+
let ttlDays;
|
|
123
|
+
if (args['ttl-days'] !== undefined) {
|
|
124
|
+
// A bare `--ttl-days` with no value parses as boolean `true` (see parseArgs);
|
|
125
|
+
// feed NaN so the shared validator rejects it instead of silently meaning 1 day.
|
|
126
|
+
const rawTtlDays = args['ttl-days'] === true ? NaN : args['ttl-days'];
|
|
127
|
+
try {
|
|
128
|
+
ttlDays = parseTtlDays(rawTtlDays);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
err(`${c.red('Error:')} --ttl-days is invalid — ${(e && e.message) || String(e)}`);
|
|
131
|
+
return 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const clearTtl = Boolean(args['clear-ttl']);
|
|
112
135
|
const orgSlug = typeof args.org === 'string' ? args.org : undefined;
|
|
113
136
|
|
|
114
137
|
// ── Provenance ────────────────────────────────────────────────────────────
|
|
@@ -198,6 +221,7 @@ export async function write(args) {
|
|
|
198
221
|
...(sourceAgent ? { source_agent: sourceAgent } : {}),
|
|
199
222
|
...(trigger ? { trigger } : {}),
|
|
200
223
|
...(ttlDays ? { ttl_days: ttlDays } : {}),
|
|
224
|
+
...(clearTtl ? { clear_ttl: true } : {}),
|
|
201
225
|
...(orgSlug ? { org: orgSlug } : {}),
|
|
202
226
|
...origin,
|
|
203
227
|
};
|
|
@@ -249,5 +273,6 @@ export async function write(args) {
|
|
|
249
273
|
'lorekit.cli.write.inserted': inserted,
|
|
250
274
|
'lorekit.cli.write.has_tags': tags.length > 0,
|
|
251
275
|
'lorekit.cli.write.has_ttl': Boolean(ttlDays),
|
|
276
|
+
'lorekit.cli.write.clear_ttl': clearTtl,
|
|
252
277
|
};
|
|
253
278
|
}
|