@lorekit/cli 1.17.0 → 1.18.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/package.json +1 -1
- package/src/adapters/claude.mjs +12 -0
- package/src/core/lessons.mjs +24 -1
- package/src/doctor.mjs +63 -1
- package/src/hook.mjs +21 -0
- package/src/telemetry.mjs +1 -1
package/package.json
CHANGED
package/src/adapters/claude.mjs
CHANGED
|
@@ -9,6 +9,7 @@ export const claude = {
|
|
|
9
9
|
case 'SessionStart':
|
|
10
10
|
return 'read';
|
|
11
11
|
case 'PostToolUse':
|
|
12
|
+
return 'confirm';
|
|
12
13
|
case 'PostToolUseFailure':
|
|
13
14
|
return 'failure';
|
|
14
15
|
case 'Stop':
|
|
@@ -23,11 +24,22 @@ export const claude = {
|
|
|
23
24
|
return event === 'PostToolUseFailure';
|
|
24
25
|
},
|
|
25
26
|
|
|
27
|
+
// Returns true when the PostToolUse event was a successful lorekit memory
|
|
28
|
+
// write. Claude Code reports MCP tool names as
|
|
29
|
+
// "mcp__<server-label>__memory_write" (underscores) — we match the suffix
|
|
30
|
+
// so any server label works. A successful write response always contains
|
|
31
|
+
// a string `id` field returned by the memory_write RPC.
|
|
32
|
+
isLoreWrite(toolName, toolResponse) {
|
|
33
|
+
if (!toolName || !String(toolName).endsWith('memory_write')) return false;
|
|
34
|
+
return toolResponse != null && typeof toolResponse === 'object' && typeof toolResponse.id === 'string';
|
|
35
|
+
},
|
|
36
|
+
|
|
26
37
|
parse(input) {
|
|
27
38
|
return {
|
|
28
39
|
cwd: input.cwd || null,
|
|
29
40
|
sessionId: input.session_id || null,
|
|
30
41
|
toolName: input.tool_name || 'tool',
|
|
42
|
+
toolInput: input.tool_input || null,
|
|
31
43
|
toolResponse: input.tool_response || null,
|
|
32
44
|
event: input.hook_event_name || null,
|
|
33
45
|
};
|
package/src/core/lessons.mjs
CHANGED
|
@@ -174,6 +174,14 @@ function tagsHint(writeScope, { tagsDefault = [], scopeDefaults = null } = {}) {
|
|
|
174
174
|
return ` Include tags: [${tags.map((t) => JSON.stringify(t)).join(', ')}].`;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
// The LoreKit web app URL for the Lore Explorer, pre-filtered to the given scope.
|
|
178
|
+
// Exported so tests can assert the URL shape without re-deriving the encoding.
|
|
179
|
+
export function loreUrl(writeScope) {
|
|
180
|
+
const base = 'https://lorekit.io/lore';
|
|
181
|
+
if (!writeScope || writeScope === 'global') return base;
|
|
182
|
+
return `${base}?scope=${encodeURIComponent(writeScope)}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
177
185
|
// The retrospective nudge emitted at end-of-turn (one-shot per session).
|
|
178
186
|
// `control` is the resolved control object (optional) — carries tagsDefault and
|
|
179
187
|
// scopeDefaults when the repo/user config defines them.
|
|
@@ -182,13 +190,28 @@ export function retrospectiveNudge(scope, control) {
|
|
|
182
190
|
const hint = tagsHint(writeScope, control);
|
|
183
191
|
const instruction = control && control.hooksInstructions && control.hooksInstructions.Stop
|
|
184
192
|
? `\n\nProject instruction: ${control.hooksInstructions.Stop}` : '';
|
|
193
|
+
const url = loreUrl(writeScope);
|
|
185
194
|
return (
|
|
186
195
|
`LoreKit: hit any friction worth remembering — a stuck loop, a repeated ` +
|
|
187
196
|
`failure, a gotcha, a wrong assumption? If so, memory.write to ${writeScope} ` +
|
|
188
|
-
`as an observation; else skip.${hint}${instruction}`
|
|
197
|
+
`as an observation; else skip.${hint}${instruction}\n` +
|
|
198
|
+
`View lore: ${url}`
|
|
189
199
|
);
|
|
190
200
|
}
|
|
191
201
|
|
|
202
|
+
// Terse confirmation emitted via PostToolUse when a memory.write succeeded.
|
|
203
|
+
// `key` is the lesson key from the tool response (may be null when the response
|
|
204
|
+
// shape doesn't surface it). Includes a deep link to the scope's Lore Explorer
|
|
205
|
+
// page so the user can verify immediately.
|
|
206
|
+
export function writeConfirmation(scope, key) {
|
|
207
|
+
const writeScope = scope.repoScope || 'global';
|
|
208
|
+
const keyPart = key ? ` · ${key}` : '';
|
|
209
|
+
const url = key
|
|
210
|
+
? `${loreUrl(writeScope)}&q=${encodeURIComponent(key)}`
|
|
211
|
+
: loreUrl(writeScope);
|
|
212
|
+
return `LoreKit: memory saved to ${writeScope}${keyPart}\nView: ${url}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
192
215
|
// The nudge emitted when a tool failure is detected.
|
|
193
216
|
// `control` is the resolved control object (optional) — carries tagsDefault and
|
|
194
217
|
// scopeDefaults when the repo/user config defines them.
|
package/src/doctor.mjs
CHANGED
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
SKILLS,
|
|
9
9
|
resolveProjectRoot,
|
|
10
10
|
skillInstallDir,
|
|
11
|
+
settingsPath,
|
|
12
|
+
CLAUDE_HOOK_EVENTS,
|
|
13
|
+
LOREKIT_HOOK_RE,
|
|
11
14
|
readLorekitServer,
|
|
12
15
|
readMcpConfig,
|
|
13
16
|
tokenKind,
|
|
@@ -63,6 +66,22 @@ export async function doctor(args) {
|
|
|
63
66
|
}
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
// 2.5. Duplicate-hook detection — warn when the same lorekit hook event is
|
|
70
|
+
// wired in both the project settings and the global settings. This causes
|
|
71
|
+
// Claude Code to fire the hook twice per event, producing doubled terminal
|
|
72
|
+
// output. Common after running `lorekit install` once with --project and
|
|
73
|
+
// once with --global (or via the marketplace plugin on top of a CLI install).
|
|
74
|
+
const dupeEvents = detectDuplicateHooks(root);
|
|
75
|
+
if (dupeEvents.length > 0) {
|
|
76
|
+
record(
|
|
77
|
+
'warn',
|
|
78
|
+
'hooks duplicate',
|
|
79
|
+
`${dupeEvents.join(', ')} registered in BOTH project and global settings — ` +
|
|
80
|
+
`Claude Code fires them twice. Remove one scope: ` +
|
|
81
|
+
`run \`lorekit uninstall --project\` or \`lorekit uninstall --global\`.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
// 3. Resolved control model — which mode, and who decided it.
|
|
67
86
|
const control = loadControl(root, { env: withOverrides(args) });
|
|
68
87
|
record('info', 'memory mode', `${control.mode} ${c.dim('— decided by ' + control.decidedBy)}`);
|
|
@@ -82,8 +101,9 @@ export async function doctor(args) {
|
|
|
82
101
|
// 5. Scope.
|
|
83
102
|
const scope = deriveScope(root);
|
|
84
103
|
if (scope.hasRemote) {
|
|
104
|
+
log('');
|
|
85
105
|
record('info', 'read scope', scope.readOrder.join(' → '));
|
|
86
|
-
record('info', 'write scope', `${scope.repoScope} (default
|
|
106
|
+
record('info', 'write scope', `${scope.repoScope} (default write target)`);
|
|
87
107
|
} else {
|
|
88
108
|
record('warn', 'scope', 'no git remote here — memories fall back to global');
|
|
89
109
|
}
|
|
@@ -289,6 +309,48 @@ async function deepCheckLocal(store, scope, record) {
|
|
|
289
309
|
await store.delete({ scope: writeScope, key, force: true });
|
|
290
310
|
}
|
|
291
311
|
|
|
312
|
+
// Returns the list of CLAUDE_HOOK_EVENTS whose lorekit hook command appears in
|
|
313
|
+
// BOTH the project settings file (.claude/settings.json) and the global one
|
|
314
|
+
// (~/.claude/settings.json). An empty array means no duplicates — healthy.
|
|
315
|
+
function detectDuplicateHooks(root) {
|
|
316
|
+
const dupes = [];
|
|
317
|
+
const projectFile = settingsPath(root, 'project');
|
|
318
|
+
const globalFile = settingsPath(root, 'global');
|
|
319
|
+
|
|
320
|
+
let projectHooks = {};
|
|
321
|
+
let globalHooks = {};
|
|
322
|
+
try {
|
|
323
|
+
const cfg = JSON.parse(fs.readFileSync(projectFile, 'utf8'));
|
|
324
|
+
if (cfg && typeof cfg.hooks === 'object') projectHooks = cfg.hooks;
|
|
325
|
+
} catch { /* absent or unparseable — treat as empty */ }
|
|
326
|
+
try {
|
|
327
|
+
const cfg = JSON.parse(fs.readFileSync(globalFile, 'utf8'));
|
|
328
|
+
if (cfg && typeof cfg.hooks === 'object') globalHooks = cfg.hooks;
|
|
329
|
+
} catch { /* absent or unparseable — treat as empty */ }
|
|
330
|
+
|
|
331
|
+
for (const event of CLAUDE_HOOK_EVENTS) {
|
|
332
|
+
const hasInProject = hooksForEvent(projectHooks, event).some((cmd) => LOREKIT_HOOK_RE.test(cmd));
|
|
333
|
+
const hasInGlobal = hooksForEvent(globalHooks, event).some((cmd) => LOREKIT_HOOK_RE.test(cmd));
|
|
334
|
+
if (hasInProject && hasInGlobal) dupes.push(event);
|
|
335
|
+
}
|
|
336
|
+
return dupes;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Extract the flat list of hook command strings for one event from a hooks
|
|
340
|
+
// object. Handles the nested-group shape Claude Code uses:
|
|
341
|
+
// { [event]: [ { hooks: [ { type, command } ] } ] }
|
|
342
|
+
function hooksForEvent(hooksObj, event) {
|
|
343
|
+
const groups = Array.isArray(hooksObj[event]) ? hooksObj[event] : [];
|
|
344
|
+
const commands = [];
|
|
345
|
+
for (const group of groups) {
|
|
346
|
+
const inner = group && Array.isArray(group.hooks) ? group.hooks : [];
|
|
347
|
+
for (const h of inner) {
|
|
348
|
+
if (h && typeof h.command === 'string') commands.push(h.command);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return commands;
|
|
352
|
+
}
|
|
353
|
+
|
|
292
354
|
function gitTracked(root, dir) {
|
|
293
355
|
// Heuristic: is the store dir ignored by git? If `git check-ignore` names it,
|
|
294
356
|
// it is private; otherwise it will be committed (team-shared).
|
package/src/hook.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
failureQuery,
|
|
16
16
|
relevantLessons,
|
|
17
17
|
formatRelevantLessons,
|
|
18
|
+
writeConfirmation,
|
|
18
19
|
} from './core/lessons.mjs';
|
|
19
20
|
import { isFailure } from './core/failure.mjs';
|
|
20
21
|
import { firstTimeThisSession } from './core/state.mjs';
|
|
@@ -109,6 +110,26 @@ async function run(args) {
|
|
|
109
110
|
return 0;
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
if (intent === 'confirm') {
|
|
114
|
+
// Fire only when a lorekit memory write actually succeeded — the adapter's
|
|
115
|
+
// isLoreWrite() inspects the tool name and the response shape. Any error
|
|
116
|
+
// is swallowed (exit 0 — never block the host).
|
|
117
|
+
try {
|
|
118
|
+
if (adapter.isLoreWrite && adapter.isLoreWrite(parsed.toolName, parsed.toolResponse)) {
|
|
119
|
+
// The lesson key comes from the tool INPUT (what the agent sent), not
|
|
120
|
+
// the response (which only carries id + created_at). toolInput is
|
|
121
|
+
// populated by the adapter's parse() from the raw hook stdin.
|
|
122
|
+
const key = (parsed.toolInput && typeof parsed.toolInput.key === 'string')
|
|
123
|
+
? parsed.toolInput.key
|
|
124
|
+
: null;
|
|
125
|
+
emit(writeConfirmation(scope, key));
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// best-effort — never break the host
|
|
129
|
+
}
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
112
133
|
if (intent === 'failure') {
|
|
113
134
|
const known = adapter.guaranteedFailure ? adapter.guaranteedFailure(event) : false;
|
|
114
135
|
if (!known && !isFailure(parsed.toolName, parsed.toolResponse)) return 0;
|
package/src/telemetry.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { readLorekitJson } from './config.mjs';
|
|
|
32
32
|
// (empty in the source tree, so default export stays off until built/injected).
|
|
33
33
|
const DEFAULT_ENDPOINT = 'https://ingress.europe-west4.gcp.dash0-dev.com';
|
|
34
34
|
const DEFAULT_TOKEN = TELEMETRY_TOKEN; // injected from LOREKIT_TELEMETRY_TOKEN at publish
|
|
35
|
-
const DEFAULT_DATASET = '
|
|
35
|
+
const DEFAULT_DATASET = '';
|
|
36
36
|
|
|
37
37
|
// Flags worth counting (e.g. how many installs are --global). Bounded on
|
|
38
38
|
// purpose: only these booleans are ever attached, never free-form values.
|