@lorekit/cli 1.0.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 +320 -0
- package/bin/lorekit.mjs +117 -0
- package/package.json +36 -0
- package/skill/lorekit-memory/SKILL.md +139 -0
- package/skill/lorekit-memory/references/scope-resolution.md +65 -0
- package/skill/lorekit-memory/rules/intake.md +61 -0
- package/skill/lorekit-memory/rules/retrospective.md +85 -0
- package/src/adapters/claude.mjs +41 -0
- package/src/adapters/codex.mjs +36 -0
- package/src/adapters/cursor.mjs +42 -0
- package/src/config.mjs +132 -0
- package/src/control.mjs +152 -0
- package/src/core/failure.mjs +28 -0
- package/src/core/lessons.mjs +60 -0
- package/src/core/record.mjs +27 -0
- package/src/core/state.mjs +27 -0
- package/src/doctor.mjs +251 -0
- package/src/hook.mjs +106 -0
- package/src/install.mjs +95 -0
- package/src/mcp-server.mjs +233 -0
- package/src/mcp.mjs +89 -0
- package/src/migrate.mjs +149 -0
- package/src/scope.mjs +59 -0
- package/src/store/format.mjs +99 -0
- package/src/store/index.mjs +18 -0
- package/src/store/local.mjs +313 -0
- package/src/store/remote.mjs +90 -0
- package/src/util.mjs +77 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Intake — reading lessons before you act
|
|
2
|
+
|
|
3
|
+
Run this at task start, on first navigation into an unfamiliar area, and
|
|
4
|
+
before any hard-to-reverse operation.
|
|
5
|
+
|
|
6
|
+
## 1. Resolve the current scope
|
|
7
|
+
|
|
8
|
+
Derive scope from the working repository:
|
|
9
|
+
|
|
10
|
+
- `origin` remote `owner/repo` → `repo::{owner}/{repo}`
|
|
11
|
+
- current branch → `branch::{owner}/{repo}::{branch}`
|
|
12
|
+
- no git remote → fall back to `global` (and `project::{dir}` for a monorepo)
|
|
13
|
+
|
|
14
|
+
Lowercase every segment.
|
|
15
|
+
See [../references/scope-resolution.md](../references/scope-resolution.md) for the exact derivation.
|
|
16
|
+
|
|
17
|
+
## 2. List narrow-to-broad
|
|
18
|
+
|
|
19
|
+
Query each scope from most specific to least, and merge the results:
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
memory.list { scope: "branch::{owner}/{repo}::{branch}" }
|
|
23
|
+
memory.list { scope: "repo::{owner}/{repo}" }
|
|
24
|
+
memory.list { scope: "project::{name}" } # only if a monorepo
|
|
25
|
+
memory.list { scope: "global" }
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
When the same key appears at multiple levels, the **more specific scope wins**.
|
|
29
|
+
|
|
30
|
+
Keep it cheap: a `limit` of 20–50 per scope is plenty.
|
|
31
|
+
Filter with `tags: ["skill::lorekit-memory"]` when you only want this skill's
|
|
32
|
+
lessons; drop the filter to see everything an agent has recorded.
|
|
33
|
+
|
|
34
|
+
## 3. Search when the task has a keyword
|
|
35
|
+
|
|
36
|
+
If the task is about a specific subsystem, error, or tool, add a full-text
|
|
37
|
+
search across the owner's repos and global:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
memory.search {
|
|
41
|
+
q: "<subsystem or error keywords>",
|
|
42
|
+
scopes: ["repo::{owner}/*", "global"],
|
|
43
|
+
limit: 10
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 4. Apply as considerations, not commands
|
|
48
|
+
|
|
49
|
+
Lessons are observations from past runs ("last time, X went wrong when Y").
|
|
50
|
+
They inform your approach and can bias decisions — but they are not rules and
|
|
51
|
+
they can be stale.
|
|
52
|
+
If a lesson contradicts what you observe in the current code, trust the code
|
|
53
|
+
and consider writing a corrective lesson on the way out (see
|
|
54
|
+
[retrospective.md](./retrospective.md)).
|
|
55
|
+
|
|
56
|
+
## 5. Report briefly
|
|
57
|
+
|
|
58
|
+
If lessons matched, note them in one or two lines before proceeding
|
|
59
|
+
("LoreKit: 2 relevant lessons — worktree naming, migration order").
|
|
60
|
+
If nothing matched, say nothing and continue.
|
|
61
|
+
If the MCP tools are not connected, note it once and continue without them.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Retrospective — writing a lesson when something goes wrong
|
|
2
|
+
|
|
3
|
+
Run this after friction: a stuck loop, a repeated command failure, a
|
|
4
|
+
surprising gotcha, a near-miss, a wrong assumption that cost time, or a guess
|
|
5
|
+
that paid off and should be reused.
|
|
6
|
+
Do **not** run it on smooth successes — there is nothing durable to record.
|
|
7
|
+
|
|
8
|
+
## 1. Decide if there is a lesson
|
|
9
|
+
|
|
10
|
+
Ask the 30-second question:
|
|
11
|
+
|
|
12
|
+
> Would a future run in this repo (or anywhere) do better if it knew this?
|
|
13
|
+
|
|
14
|
+
If no, stop — write nothing. Empty retrospectives are skipped.
|
|
15
|
+
If yes, continue.
|
|
16
|
+
|
|
17
|
+
## 2. Phrase it as an observation
|
|
18
|
+
|
|
19
|
+
Write what happened and what worked, not a commandment.
|
|
20
|
+
|
|
21
|
+
- Good: "Running `pnpm nx test mcp-core` without `supabase start` fails with a
|
|
22
|
+
connection refused error; start Supabase first."
|
|
23
|
+
- Avoid: "ALWAYS start Supabase." (rules rot; observations age gracefully)
|
|
24
|
+
|
|
25
|
+
Keep the body tight markdown. A sentence or three. Include the concrete
|
|
26
|
+
signal (the error text, the command, the file) so future search finds it.
|
|
27
|
+
|
|
28
|
+
## 3. Pick the narrowest scope that fits
|
|
29
|
+
|
|
30
|
+
| The lesson is about… | Scope |
|
|
31
|
+
|----------------------|-------|
|
|
32
|
+
| A universal principle, true in any repo | `global` |
|
|
33
|
+
| This repository's codebase or tooling | `repo::{owner}/{repo}` |
|
|
34
|
+
| A monorepo-wide convention | `project::{name}` |
|
|
35
|
+
| A throwaway detail of this branch only | `branch::{owner}/{repo}::{branch}` |
|
|
36
|
+
|
|
37
|
+
When in doubt for a "stuff went bad" lesson, default to **repo** scope.
|
|
38
|
+
Reserve `global` for things that are genuinely true everywhere.
|
|
39
|
+
|
|
40
|
+
## 4. Choose a stable key
|
|
41
|
+
|
|
42
|
+
Use a short, kebab-case, namespaced key so related lessons cluster and
|
|
43
|
+
re-writes update in place instead of duplicating:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
lorekit-memory::<short-slug>
|
|
47
|
+
# e.g. lorekit-memory::supabase-start-before-test
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## 5. Deduplicate before writing
|
|
51
|
+
|
|
52
|
+
Check for a near-duplicate first, so you update rather than pile up:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
memory.search { q: "<key words of the lesson>", scopes: ["repo::{owner}/{repo}", "global"] }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- Found the same situation under a key → reuse that **exact scope + key** and
|
|
59
|
+
`memory.write` an updated body (writing the same scope+key updates in place).
|
|
60
|
+
Optionally note "(seen again <short-context>)" so the recurrence is visible.
|
|
61
|
+
- Nothing similar → write a new lesson with a fresh key.
|
|
62
|
+
|
|
63
|
+
## 6. Write
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
memory.write {
|
|
67
|
+
scope: "repo::{owner}/{repo}",
|
|
68
|
+
key: "lorekit-memory::supabase-start-before-test",
|
|
69
|
+
value: "<observation in markdown>",
|
|
70
|
+
tags: ["skill::lorekit-memory", "source::stuck-loop"],
|
|
71
|
+
source_agent: "<your agent name, if known>",
|
|
72
|
+
trigger: "stuck-loop"
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Pick `trigger` / `source::*` from what actually happened:
|
|
77
|
+
`stuck-loop`, `command-failure`, `gotcha`, `near-miss`, `assumption-wrong`,
|
|
78
|
+
`paid-off`, or `manual`.
|
|
79
|
+
|
|
80
|
+
## 7. Confirm
|
|
81
|
+
|
|
82
|
+
State in one line what you recorded and where
|
|
83
|
+
("LoreKit: wrote repo lesson `supabase-start-before-test`").
|
|
84
|
+
If the write fails because the token is read-only or missing, say so once and
|
|
85
|
+
continue — never retry a write that failed on authorization.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Adapter for Claude Code hooks.
|
|
2
|
+
// Contract: stdin JSON with snake_case fields; stdout injects context via
|
|
3
|
+
// hookSpecificOutput.additionalContext. Non-blocking (we never set decision).
|
|
4
|
+
export const claude = {
|
|
5
|
+
name: 'claude',
|
|
6
|
+
|
|
7
|
+
intentFor(event) {
|
|
8
|
+
switch (event) {
|
|
9
|
+
case 'SessionStart':
|
|
10
|
+
return 'read';
|
|
11
|
+
case 'PostToolUse':
|
|
12
|
+
case 'PostToolUseFailure':
|
|
13
|
+
return 'failure';
|
|
14
|
+
case 'Stop':
|
|
15
|
+
return 'retrospective';
|
|
16
|
+
default:
|
|
17
|
+
return 'noop';
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
// PostToolUseFailure fires only when a tool failed — no heuristic needed.
|
|
22
|
+
guaranteedFailure(event) {
|
|
23
|
+
return event === 'PostToolUseFailure';
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
parse(input) {
|
|
27
|
+
return {
|
|
28
|
+
cwd: input.cwd || null,
|
|
29
|
+
sessionId: input.session_id || null,
|
|
30
|
+
toolName: input.tool_name || 'tool',
|
|
31
|
+
toolResponse: input.tool_response || null,
|
|
32
|
+
event: input.hook_event_name || null,
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
emit(event, text) {
|
|
37
|
+
return JSON.stringify({
|
|
38
|
+
hookSpecificOutput: { hookEventName: event, additionalContext: text },
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Adapter for OpenAI Codex CLI hooks (experimental; `codex_hooks` feature flag).
|
|
2
|
+
// Codex mirrors Claude Code's event model and JSON shape closely, so we reuse
|
|
3
|
+
// the same snake_case input fields and hookSpecificOutput.additionalContext
|
|
4
|
+
// output. If a future Codex build diverges, only this file needs to change.
|
|
5
|
+
export const codex = {
|
|
6
|
+
name: 'codex',
|
|
7
|
+
|
|
8
|
+
intentFor(event) {
|
|
9
|
+
switch (event) {
|
|
10
|
+
case 'SessionStart':
|
|
11
|
+
return 'read';
|
|
12
|
+
case 'PostToolUse':
|
|
13
|
+
return 'failure';
|
|
14
|
+
case 'Stop':
|
|
15
|
+
return 'retrospective';
|
|
16
|
+
default:
|
|
17
|
+
return 'noop';
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
parse(input) {
|
|
22
|
+
return {
|
|
23
|
+
cwd: input.cwd || (Array.isArray(input.workspace_roots) ? input.workspace_roots[0] : null),
|
|
24
|
+
sessionId: input.session_id || input.thread_id || null,
|
|
25
|
+
toolName: input.tool_name || 'tool',
|
|
26
|
+
toolResponse: input.tool_response || null,
|
|
27
|
+
event: input.hook_event_name || null,
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
emit(event, text) {
|
|
32
|
+
return JSON.stringify({
|
|
33
|
+
hookSpecificOutput: { hookEventName: event, additionalContext: text },
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Adapter for Cursor hooks (.cursor/hooks.json, "version": 1).
|
|
2
|
+
//
|
|
3
|
+
// Cursor's hook surface is narrower than Claude/Codex:
|
|
4
|
+
// - there is no SessionStart, so the read-on-start path is handled by the
|
|
5
|
+
// bundled Cursor *rule* (the agent calls the MCP); the `beforeSubmitPrompt`
|
|
6
|
+
// hook is best-effort and injects via `followup_message` where supported.
|
|
7
|
+
// - `beforeShellExecution` fires *before* a command runs, so there is no exit
|
|
8
|
+
// code to inspect — reliable failure detection is not possible here.
|
|
9
|
+
// - the confirmed injection channel is `stop` → { followup_message }.
|
|
10
|
+
//
|
|
11
|
+
// Confirmed stdin fields: `command`, `file_path`, `edits`, `generation_id`.
|
|
12
|
+
export const cursor = {
|
|
13
|
+
name: 'cursor',
|
|
14
|
+
|
|
15
|
+
intentFor(event) {
|
|
16
|
+
switch (event) {
|
|
17
|
+
case 'beforeSubmitPrompt':
|
|
18
|
+
return 'read';
|
|
19
|
+
case 'stop':
|
|
20
|
+
return 'retrospective';
|
|
21
|
+
default:
|
|
22
|
+
return 'noop';
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
parse(input) {
|
|
27
|
+
return {
|
|
28
|
+
cwd:
|
|
29
|
+
input.cwd ||
|
|
30
|
+
(Array.isArray(input.workspace_roots) ? input.workspace_roots[0] : null),
|
|
31
|
+
sessionId: input.generation_id || input.conversation_id || null,
|
|
32
|
+
toolName: 'command',
|
|
33
|
+
toolResponse: null,
|
|
34
|
+
event: input.hook_event_name || null,
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// Cursor injects an agent-visible message via `followup_message`.
|
|
39
|
+
emit(_event, text) {
|
|
40
|
+
return JSON.stringify({ followup_message: text });
|
|
41
|
+
},
|
|
42
|
+
};
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Project layout + .mcp.json read/merge helpers.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
// packages/cli/ — the installable package root (this file lives in src/).
|
|
7
|
+
export const PKG_ROOT = fileURLToPath(new URL('../', import.meta.url));
|
|
8
|
+
export const SKILL_SOURCE = path.join(PKG_ROOT, 'skill', 'lorekit-memory');
|
|
9
|
+
export const SKILL_NAME = 'lorekit-memory';
|
|
10
|
+
|
|
11
|
+
export function resolveProjectRoot(dir) {
|
|
12
|
+
return path.resolve(dir || process.cwd());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function mcpJsonPath(root) {
|
|
16
|
+
return path.join(root, '.mcp.json');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function skillInstallDir(root) {
|
|
20
|
+
return path.join(root, '.claude', 'skills', SKILL_NAME);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Throwing read — used by `install` so a corrupt .mcp.json aborts the write
|
|
24
|
+
// instead of silently clobbering the user's file.
|
|
25
|
+
export function readJsonIfExists(file) {
|
|
26
|
+
if (!fs.existsSync(file)) return null;
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
29
|
+
} catch (e) {
|
|
30
|
+
throw new Error(`Failed to parse ${file}: ${e.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Non-throwing read — used by the diagnostic (doctor) and hook read paths,
|
|
35
|
+
// which must degrade gracefully rather than crash on a malformed file.
|
|
36
|
+
// Distinguishes absent from present-but-invalid so callers can report it.
|
|
37
|
+
export function readMcpConfig(root) {
|
|
38
|
+
const file = mcpJsonPath(root);
|
|
39
|
+
if (!fs.existsSync(file)) return { present: false, valid: false, config: null };
|
|
40
|
+
try {
|
|
41
|
+
return { present: true, valid: true, config: JSON.parse(fs.readFileSync(file, 'utf8')) };
|
|
42
|
+
} catch {
|
|
43
|
+
return { present: true, valid: false, config: null };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Merge a lorekit server entry into .mcp.json, preserving any other servers.
|
|
48
|
+
export function upsertMcpServer(root, remoteUrl) {
|
|
49
|
+
const file = mcpJsonPath(root);
|
|
50
|
+
const config = readJsonIfExists(file) || {};
|
|
51
|
+
if (!config.mcpServers || typeof config.mcpServers !== 'object') {
|
|
52
|
+
config.mcpServers = {};
|
|
53
|
+
}
|
|
54
|
+
const existed = Boolean(config.mcpServers.lorekit);
|
|
55
|
+
config.mcpServers.lorekit = {
|
|
56
|
+
command: 'npx',
|
|
57
|
+
args: ['-y', 'mcp-remote', remoteUrl],
|
|
58
|
+
};
|
|
59
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
|
|
60
|
+
return { file, existed };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Pull the configured lorekit remote URL out of .mcp.json, if present.
|
|
64
|
+
// Non-throwing: returns null when the file is absent, invalid, or has no
|
|
65
|
+
// lorekit server. Callers that need to distinguish those use readMcpConfig.
|
|
66
|
+
export function readLorekitServer(root) {
|
|
67
|
+
const { config } = readMcpConfig(root);
|
|
68
|
+
const server = config && config.mcpServers && config.mcpServers.lorekit;
|
|
69
|
+
if (!server) return null;
|
|
70
|
+
const args = Array.isArray(server.args) ? server.args : [];
|
|
71
|
+
const url = args.find((a) => typeof a === 'string' && /^https?:\/\//.test(a));
|
|
72
|
+
return { server, url: url || null };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Recursively copy the skill source into the target, skipping files that
|
|
76
|
+
// already exist unless `force` is set. Returns the number of files actually
|
|
77
|
+
// written, so the caller can report "installed" / "updated" / "unchanged"
|
|
78
|
+
// honestly instead of guessing from whether the directory pre-existed.
|
|
79
|
+
export function copyDir(src, dest, { force = false } = {}) {
|
|
80
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
81
|
+
let written = 0;
|
|
82
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
83
|
+
const from = path.join(src, entry.name);
|
|
84
|
+
const to = path.join(dest, entry.name);
|
|
85
|
+
if (entry.isDirectory()) {
|
|
86
|
+
written += copyDir(from, to, { force });
|
|
87
|
+
} else {
|
|
88
|
+
if (fs.existsSync(to) && !force) continue;
|
|
89
|
+
fs.copyFileSync(from, to);
|
|
90
|
+
written++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return written;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Resolve endpoint + token from flags, then env, in that order.
|
|
97
|
+
export function resolveConnection(args) {
|
|
98
|
+
const endpoint =
|
|
99
|
+
args.endpoint ||
|
|
100
|
+
process.env.LOREKIT_MCP_URL ||
|
|
101
|
+
process.env.LOREKIT_ENDPOINT ||
|
|
102
|
+
null;
|
|
103
|
+
const token = args.token || process.env.LOREKIT_TOKEN || null;
|
|
104
|
+
return {
|
|
105
|
+
endpoint: typeof endpoint === 'string' ? endpoint.trim() : null,
|
|
106
|
+
token: typeof token === 'string' ? token.trim() : null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function tokenKind(token) {
|
|
111
|
+
if (!token) return 'none';
|
|
112
|
+
if (token.startsWith('lk_rw_')) return 'read-write';
|
|
113
|
+
if (token.startsWith('lk_ro_')) return 'read-only';
|
|
114
|
+
return 'unknown';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// For hooks: resolve the connection from the project's .mcp.json first
|
|
118
|
+
// (that is where `lorekit install` wrote the token), then fall back to env.
|
|
119
|
+
// `splitEndpoint` is passed in to avoid a circular import with mcp.mjs.
|
|
120
|
+
export function resolveProjectConnection(root, splitEndpoint) {
|
|
121
|
+
const configured = readLorekitServer(root);
|
|
122
|
+
if (configured && configured.url) {
|
|
123
|
+
const { endpoint, token } = splitEndpoint(configured.url);
|
|
124
|
+
if (endpoint && !endpoint.includes('<project-ref>')) {
|
|
125
|
+
return {
|
|
126
|
+
endpoint,
|
|
127
|
+
token: token || process.env.LOREKIT_TOKEN || null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return resolveConnection({});
|
|
132
|
+
}
|
package/src/control.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The control model: decide the memory mode (off | local | remote), the store
|
|
2
|
+
// target, who decided, and which deny constraints are active.
|
|
3
|
+
//
|
|
4
|
+
// Two layers of config, two kinds of statement:
|
|
5
|
+
// - a SELECT (`mode`) chooses a mode within what is allowed;
|
|
6
|
+
// - a DENY forbids a mode outright and can never be overridden.
|
|
7
|
+
//
|
|
8
|
+
// Deny always wins. Denies are a UNION across every source and only ever
|
|
9
|
+
// accumulate — so a user-level "never remote" (privacy/compliance) is a ceiling
|
|
10
|
+
// no repo config or default can lift, and "never local" (no `.lorekit/` in the
|
|
11
|
+
// tree / CI) is enforceable the same way. `off` is always allowed (you cannot
|
|
12
|
+
// deny "disabled"), so it is the terminal fallback.
|
|
13
|
+
//
|
|
14
|
+
// Local mode is two-tier (mirroring the persistent-memory home + project-shared
|
|
15
|
+
// model): a per-user `home` tier at $LOREKIT_HOME (default `~/.lorekit/`) and an
|
|
16
|
+
// opt-in per-repo `project` tier at `<repo>/.lorekit/` (overridable with
|
|
17
|
+
// $LOREKIT_STORE). The resolved local `storeTarget` is `{ home, project }`.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { resolveProjectConnection } from './config.mjs';
|
|
22
|
+
import { splitEndpoint } from './mcp.mjs';
|
|
23
|
+
|
|
24
|
+
export const MODES = ['off', 'local', 'remote'];
|
|
25
|
+
|
|
26
|
+
// Accept a few friendly spellings, incl. persistent-memory's `backend` values.
|
|
27
|
+
export function normalizeMode(v) {
|
|
28
|
+
if (typeof v !== 'string') return null;
|
|
29
|
+
const s = v.trim().toLowerCase();
|
|
30
|
+
if (['off', 'disabled', 'none', 'false'].includes(s)) return 'off';
|
|
31
|
+
if (['local', 'markdown', 'file', 'files'].includes(s)) return 'local';
|
|
32
|
+
if (['remote', 'lorekit', 'mcp', 'hosted'].includes(s)) return 'remote';
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function asList(v) {
|
|
37
|
+
if (Array.isArray(v)) return v;
|
|
38
|
+
if (typeof v === 'string') return v.split(',').map((s) => s.trim()).filter(Boolean);
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Pure resolver — no IO. Given the already-loaded config objects, decide.
|
|
43
|
+
// Returns { mode, storeTarget, decidedBy, denies, connection }.
|
|
44
|
+
export function resolveControl({
|
|
45
|
+
env = {},
|
|
46
|
+
userConfig = {},
|
|
47
|
+
repoConfig = {},
|
|
48
|
+
connection = {},
|
|
49
|
+
root = process.cwd(),
|
|
50
|
+
home = env.LOREKIT_HOME || null,
|
|
51
|
+
} = {}) {
|
|
52
|
+
// 1. Denies — union, deny-wins, accumulate (never removable).
|
|
53
|
+
const denies = [];
|
|
54
|
+
const addDeny = (mode, source) => {
|
|
55
|
+
const m = normalizeMode(mode);
|
|
56
|
+
if ((m === 'remote' || m === 'local') && !denies.some((d) => d.mode === m)) {
|
|
57
|
+
denies.push({ mode: m, source });
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
for (const m of asList(env.LOREKIT_DENY)) addDeny(m, 'env LOREKIT_DENY');
|
|
61
|
+
for (const m of asList(userConfig.deny)) addDeny(m, 'user config (~/.lorekit/config.json)');
|
|
62
|
+
for (const m of asList(repoConfig.deny)) addDeny(m, 'repo (.lorekit.json)');
|
|
63
|
+
const denied = new Set(denies.map((d) => d.mode));
|
|
64
|
+
|
|
65
|
+
// 2. Candidate selections, highest precedence first. An explicit env/flag
|
|
66
|
+
// outranks a user preference, which outranks the repo default, which
|
|
67
|
+
// outranks the built-in default.
|
|
68
|
+
const candidates = [];
|
|
69
|
+
const push = (mode, source) => {
|
|
70
|
+
const m = normalizeMode(mode);
|
|
71
|
+
if (m) candidates.push({ mode: m, source });
|
|
72
|
+
};
|
|
73
|
+
push(env.LOREKIT_MODE, 'env LOREKIT_MODE');
|
|
74
|
+
push(userConfig.mode ?? userConfig.backend, 'user config (~/.lorekit/config.json)');
|
|
75
|
+
push(repoConfig.mode ?? repoConfig.backend, 'repo (.lorekit.json)');
|
|
76
|
+
// Built-in default is `remote`: it preserves the pre-control behaviour where
|
|
77
|
+
// reads stay silent until a connection is configured while the retrospective
|
|
78
|
+
// / failure nudges still fire (they are backend-agnostic reminders). `off`
|
|
79
|
+
// is reached only by an explicit selection, or when `remote` is denied.
|
|
80
|
+
push(
|
|
81
|
+
'remote',
|
|
82
|
+
connection.usable
|
|
83
|
+
? 'default (remote connection configured)'
|
|
84
|
+
: 'default (remote — not yet configured)',
|
|
85
|
+
);
|
|
86
|
+
push('off', 'terminal fallback (all selections denied)');
|
|
87
|
+
|
|
88
|
+
// 3. First candidate that is allowed. `off` is never denied, so this always
|
|
89
|
+
// resolves. A denied higher-precedence selection is silently capped.
|
|
90
|
+
const idx = candidates.findIndex((c) => c.mode === 'off' || !denied.has(c.mode));
|
|
91
|
+
const chosen = candidates[idx];
|
|
92
|
+
const cappedModes = [
|
|
93
|
+
...new Set(candidates.slice(0, idx).filter((c) => denied.has(c.mode)).map((c) => c.mode)),
|
|
94
|
+
];
|
|
95
|
+
const decidedBy = cappedModes.length
|
|
96
|
+
? `${chosen.source} (after deny: ${cappedModes.join(', ')})`
|
|
97
|
+
: chosen.source;
|
|
98
|
+
|
|
99
|
+
// 4. Store target. Local mode is two-tier: { home, project }.
|
|
100
|
+
let storeTarget = null;
|
|
101
|
+
if (chosen.mode === 'local') {
|
|
102
|
+
storeTarget = { home: home || null, project: projectDirFrom({ env, userConfig, repoConfig, root }) };
|
|
103
|
+
} else if (chosen.mode === 'remote') {
|
|
104
|
+
storeTarget = connection.endpoint || null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { mode: chosen.mode, storeTarget, decidedBy, denies, connection };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The per-repo project-tier directory: $LOREKIT_STORE, else a `store` override
|
|
111
|
+
// in either config layer, else the default `.lorekit/` at the repo root.
|
|
112
|
+
function projectDirFrom({ env, userConfig, repoConfig, root }) {
|
|
113
|
+
const raw = env.LOREKIT_STORE || userConfig.store || repoConfig.store || '.lorekit';
|
|
114
|
+
return path.isAbsolute(raw) ? raw : path.join(root, raw);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Resolve both local-tier directories with IO (reads the config files for a
|
|
118
|
+
// `store` override). Used by `migrate` so it works regardless of the active
|
|
119
|
+
// mode. `home` is the per-user tier root; `project` is the opt-in repo tier.
|
|
120
|
+
export function localStoreDirs(root = process.cwd(), env = process.env) {
|
|
121
|
+
const home = userConfigDir(env);
|
|
122
|
+
const userConfig = readJson(path.join(home, 'config.json'));
|
|
123
|
+
const repoConfig = readJson(path.join(root, '.lorekit.json'));
|
|
124
|
+
return { home, project: projectDirFrom({ env, userConfig, repoConfig, root }) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// IO wrapper — load env + config files, derive the connection, then resolve.
|
|
128
|
+
export function loadControl(root, { env = process.env } = {}) {
|
|
129
|
+
const home = userConfigDir(env);
|
|
130
|
+
const userConfig = readJson(path.join(home, 'config.json'));
|
|
131
|
+
const repoConfig = readJson(path.join(root, '.lorekit.json'));
|
|
132
|
+
const conn = resolveProjectConnection(root, splitEndpoint);
|
|
133
|
+
const usable = Boolean(
|
|
134
|
+
conn.endpoint && conn.token && !String(conn.endpoint).includes('<project-ref>'),
|
|
135
|
+
);
|
|
136
|
+
const connection = { endpoint: conn.endpoint, token: conn.token, usable };
|
|
137
|
+
return resolveControl({ env, userConfig, repoConfig, connection, root, home });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The per-user home tier root (also holds config.json): $LOREKIT_HOME, default
|
|
141
|
+
// `~/.lorekit`. Moved from the old `~/.agent-memory` location.
|
|
142
|
+
function userConfigDir(env) {
|
|
143
|
+
return env.LOREKIT_HOME || path.join(os.homedir(), '.lorekit');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readJson(file) {
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(fs.readFileSync(file, 'utf8')) || {};
|
|
149
|
+
} catch {
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Heuristic: did a completed tool call fail? Framework-agnostic.
|
|
2
|
+
// Conservative on purpose — a false "failed" nudges the agent needlessly.
|
|
3
|
+
|
|
4
|
+
function num(v) {
|
|
5
|
+
return typeof v === 'number' ? v : typeof v === 'string' && /^-?\d+$/.test(v) ? Number(v) : null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// `response` is the tool_response object (shape varies by framework/tool).
|
|
9
|
+
export function isFailure(toolName, response) {
|
|
10
|
+
if (!response || typeof response !== 'object') return false;
|
|
11
|
+
|
|
12
|
+
// Explicit error flags set by the harness.
|
|
13
|
+
if (response.is_error === true || response.isError === true) return true;
|
|
14
|
+
if (response.interrupted === true) return false; // user abort, not a lesson
|
|
15
|
+
|
|
16
|
+
// Exit codes from shell-style tools.
|
|
17
|
+
for (const field of ['exit_code', 'exitCode', 'code', 'returnCode']) {
|
|
18
|
+
const n = num(response[field]);
|
|
19
|
+
if (n !== null) return n !== 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Some tools report status directly.
|
|
23
|
+
if (typeof response.status === 'string') {
|
|
24
|
+
return /^(error|fail(ed)?)$/i.test(response.status);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Shared hook logic: fetch and format lessons; build the nudge text.
|
|
2
|
+
// Framework-agnostic — adapters shape these strings into each tool's contract.
|
|
3
|
+
// Storage is reached through the resolved store (local | remote), never a
|
|
4
|
+
// backend directly, so the same read path serves every mode.
|
|
5
|
+
import { deriveScope } from '../scope.mjs';
|
|
6
|
+
|
|
7
|
+
const MAX_LESSONS = 15;
|
|
8
|
+
|
|
9
|
+
// Read lessons narrow-to-broad through the store and merge; more specific
|
|
10
|
+
// scope wins on key. Any per-scope failure is skipped (memory is best-effort).
|
|
11
|
+
export async function fetchLessons(store, cwd) {
|
|
12
|
+
const scope = deriveScope(cwd);
|
|
13
|
+
const byKey = new Map();
|
|
14
|
+
for (const s of scope.readOrder) {
|
|
15
|
+
const res = await store.list({ scope: s, limit: 25 });
|
|
16
|
+
if (!res || !res.ok) continue;
|
|
17
|
+
const entries = Array.isArray(res.entries) ? res.entries : [];
|
|
18
|
+
for (const e of entries) {
|
|
19
|
+
if (e && e.key && !byKey.has(e.key)) byKey.set(e.key, { ...e, scope: s });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return { scope, lessons: [...byKey.values()].slice(0, MAX_LESSONS) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Render lessons as a compact markdown block, or null when there are none.
|
|
26
|
+
export function formatLessons(lessons, scope) {
|
|
27
|
+
if (!lessons || lessons.length === 0) return null;
|
|
28
|
+
const header =
|
|
29
|
+
`LoreKit — ${lessons.length} shared lesson(s) for ${scope.repoScope || 'this workspace'}. ` +
|
|
30
|
+
`Treat as considerations, not rules; trust the current code if they conflict.`;
|
|
31
|
+
const body = lessons
|
|
32
|
+
.map((l) => {
|
|
33
|
+
const first = String(l.value || '').split('\n')[0].slice(0, 300);
|
|
34
|
+
return `- (${l.scope}) ${l.key}: ${first}`;
|
|
35
|
+
})
|
|
36
|
+
.join('\n');
|
|
37
|
+
return `${header}\n${body}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The retrospective nudge emitted at end-of-turn (one-shot per session).
|
|
41
|
+
export function retrospectiveNudge(scope) {
|
|
42
|
+
const writeScope = scope.repoScope || 'global';
|
|
43
|
+
return (
|
|
44
|
+
'LoreKit retrospective: if this session hit a stuck loop, a repeated ' +
|
|
45
|
+
'command failure, a surprising gotcha, a near-miss, or a wrong assumption ' +
|
|
46
|
+
'that cost time, record it now via the lorekit-memory skill ' +
|
|
47
|
+
`(memory.write to ${writeScope}, phrased as an observation). ` +
|
|
48
|
+
'If nothing was durable, do nothing.'
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The nudge emitted when a tool failure is detected.
|
|
53
|
+
export function failureNudge(toolName, scope) {
|
|
54
|
+
const writeScope = scope.repoScope || 'global';
|
|
55
|
+
return (
|
|
56
|
+
`LoreKit: the last ${toolName} call failed. If this is a recurring or ` +
|
|
57
|
+
'non-obvious failure, consider recording the fix as a lesson via ' +
|
|
58
|
+
`lorekit-memory (memory.write to ${writeScope}), so the next run avoids it.`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Fixture recorder. When LOREKIT_HOOK_RECORD points at a directory, every hook
|
|
2
|
+
// invocation writes the exact payload the host sent to
|
|
3
|
+
// <dir>/<adapter>-<event>.json. Run each framework ONCE with this env set to
|
|
4
|
+
// harvest real fixtures; the replay tests then run offline forever.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export function recordFixture(adapter, event, raw) {
|
|
9
|
+
const dir = process.env.LOREKIT_HOOK_RECORD;
|
|
10
|
+
if (!dir) return;
|
|
11
|
+
try {
|
|
12
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
13
|
+
let stdin;
|
|
14
|
+
try {
|
|
15
|
+
stdin = JSON.parse(raw);
|
|
16
|
+
} catch {
|
|
17
|
+
stdin = raw; // keep the raw string if it was not valid JSON
|
|
18
|
+
}
|
|
19
|
+
const name = `${adapter}-${event || 'unknown'}.json`;
|
|
20
|
+
fs.writeFileSync(
|
|
21
|
+
path.join(dir, name),
|
|
22
|
+
JSON.stringify({ adapter, event: event || null, stdin }, null, 2) + '\n',
|
|
23
|
+
);
|
|
24
|
+
} catch {
|
|
25
|
+
// Recording must never affect the host — swallow any error.
|
|
26
|
+
}
|
|
27
|
+
}
|