@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,233 @@
|
|
|
1
|
+
// `lorekit mcp` — a zero-dependency local MCP stdio server.
|
|
2
|
+
//
|
|
3
|
+
// Speaks JSON-RPC 2.0 over newline-delimited stdin/stdout (the MCP stdio
|
|
4
|
+
// transport) and serves LoreKit's memory.* tools from the store the control
|
|
5
|
+
// model resolves. This makes `lorekit mcp` a uniform local entrypoint for
|
|
6
|
+
// every mode, so an agent's `.mcp.json` can point at the local CLI instead of
|
|
7
|
+
// `mcp-remote <url>`:
|
|
8
|
+
//
|
|
9
|
+
// local → serve the `.lorekit/` file store directly (offline, no network)
|
|
10
|
+
// remote → pass tool calls through to the hosted HTTP endpoint
|
|
11
|
+
// off → advertise no tools; a call reports "disabled"
|
|
12
|
+
//
|
|
13
|
+
// Machine-facing: ONLY JSON-RPC frames go to stdout — any diagnostics go to
|
|
14
|
+
// stderr. The server never throws on malformed or partial input; a bad frame
|
|
15
|
+
// yields a JSON-RPC parse error and the loop keeps serving.
|
|
16
|
+
//
|
|
17
|
+
// The transport is hand-rolled (no MCP SDK) to keep the CLI dependency-free.
|
|
18
|
+
import process from 'node:process';
|
|
19
|
+
import { resolveProjectRoot } from './config.mjs';
|
|
20
|
+
import { loadControl } from './control.mjs';
|
|
21
|
+
import { createStore } from './store/index.mjs';
|
|
22
|
+
|
|
23
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
24
|
+
const SERVER_INFO = { name: 'lorekit-local', version: '1.0.0' };
|
|
25
|
+
|
|
26
|
+
// Tool advertisements — names + input schemas mirror the production MCP server
|
|
27
|
+
// (supabase/functions/mcp/mcp-handler.ts) so a client sees the same contract
|
|
28
|
+
// whether it points at the hosted endpoint or this local server.
|
|
29
|
+
export const TOOL_DEFS = [
|
|
30
|
+
{
|
|
31
|
+
name: 'memory.write',
|
|
32
|
+
description: 'Store or update a lesson',
|
|
33
|
+
inputSchema: { type: 'object', required: ['scope', 'key', 'value'] },
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'memory.read',
|
|
37
|
+
description: 'Read a lesson by scope and key',
|
|
38
|
+
inputSchema: { type: 'object', required: ['scope', 'key'] },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'memory.list',
|
|
42
|
+
description: 'List lessons for a scope',
|
|
43
|
+
inputSchema: { type: 'object', required: ['scope'] },
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'memory.search',
|
|
47
|
+
description: 'Keyword search across lessons',
|
|
48
|
+
inputSchema: { type: 'object', required: ['q'] },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'memory.delete',
|
|
52
|
+
description:
|
|
53
|
+
'Soft-archive a lesson (default) or hard-delete it (force: true). ' +
|
|
54
|
+
'Archived lessons are hidden from reads but can be restored.',
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
required: ['scope', 'key'],
|
|
58
|
+
properties: {
|
|
59
|
+
scope: { type: 'string' },
|
|
60
|
+
key: { type: 'string' },
|
|
61
|
+
force: { type: 'boolean' },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'memory.archive',
|
|
67
|
+
description: 'Soft-archive a lesson. Hidden from reads but restorable.',
|
|
68
|
+
inputSchema: { type: 'object', required: ['scope', 'key'] },
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
// tool name → (store, args) → store result. The store destructures the args it
|
|
73
|
+
// needs, so the raw `arguments` object is passed straight through.
|
|
74
|
+
const DISPATCH = {
|
|
75
|
+
'memory.write': (store, a) => store.write(a),
|
|
76
|
+
'memory.read': (store, a) => store.read(a),
|
|
77
|
+
'memory.list': (store, a) => store.list(a),
|
|
78
|
+
'memory.search': (store, a) => store.search(a),
|
|
79
|
+
'memory.delete': (store, a) => store.delete(a),
|
|
80
|
+
'memory.archive': (store, a) => store.archive(a),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function reply(id, result) {
|
|
84
|
+
return { jsonrpc: '2.0', id, result };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function errorReply(id, code, message) {
|
|
88
|
+
return { jsonrpc: '2.0', id, error: { code, message } };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Wrap a store result in the MCP tools/call result shape. `ok: false` from the
|
|
92
|
+
// store surfaces as a tool-level error (isError) rather than a protocol error,
|
|
93
|
+
// so the model sees the failure payload instead of a broken transport.
|
|
94
|
+
function toolResult(id, payload) {
|
|
95
|
+
const isError = payload && payload.ok === false;
|
|
96
|
+
return reply(id, {
|
|
97
|
+
content: [{ type: 'text', text: JSON.stringify(payload) }],
|
|
98
|
+
...(isError ? { isError: true } : {}),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Build the per-message handler over a resolved control model. `store` is null
|
|
103
|
+
// when mode is `off`, in which case no tools are advertised.
|
|
104
|
+
export function createHandler(control) {
|
|
105
|
+
const store = createStore(control);
|
|
106
|
+
const tools = store ? TOOL_DEFS : [];
|
|
107
|
+
|
|
108
|
+
// Returns a JSON-RPC response object, or null for a notification (no reply).
|
|
109
|
+
return async function handle(msg) {
|
|
110
|
+
const id = msg && Object.prototype.hasOwnProperty.call(msg, 'id') ? msg.id : null;
|
|
111
|
+
const isNotification = id === null || id === undefined;
|
|
112
|
+
const method = msg && msg.method;
|
|
113
|
+
|
|
114
|
+
if (method === 'initialize') {
|
|
115
|
+
return reply(id, {
|
|
116
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
117
|
+
capabilities: { tools: {} },
|
|
118
|
+
serverInfo: SERVER_INFO,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Notifications (initialized, cancelled, …) never get a response.
|
|
123
|
+
if (typeof method === 'string' && method.startsWith('notifications/')) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (method === 'tools/list') {
|
|
128
|
+
return reply(id, { tools });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (method === 'tools/call') {
|
|
132
|
+
const params = (msg && msg.params) || {};
|
|
133
|
+
const name = params.name;
|
|
134
|
+
const args = params.arguments || {};
|
|
135
|
+
|
|
136
|
+
if (!store) {
|
|
137
|
+
return toolResult(id, { ok: false, error: `memory is disabled (mode: ${control.mode})` });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const fn = DISPATCH[name];
|
|
141
|
+
if (!fn) return errorReply(id, -32601, `Unknown tool: ${name}`);
|
|
142
|
+
|
|
143
|
+
const result = await fn(store, args);
|
|
144
|
+
return toolResult(id, result);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Unknown or missing method. A notification gets silence; a request gets
|
|
148
|
+
// a proper JSON-RPC "method not found".
|
|
149
|
+
if (isNotification) return null;
|
|
150
|
+
return errorReply(id, -32601, `Method not found: ${method}`);
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The stdio read/write loop. Split out from the process streams so it can be
|
|
155
|
+
// driven by any duplex-ish pair in tests. Frames are newline-delimited JSON;
|
|
156
|
+
// responses are serialized single-line + '\n' and written in arrival order.
|
|
157
|
+
export function runStdio(handle, input, output) {
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
let buffer = '';
|
|
160
|
+
let chain = Promise.resolve();
|
|
161
|
+
|
|
162
|
+
const writeMsg = (obj) => {
|
|
163
|
+
if (obj != null) output.write(`${JSON.stringify(obj)}\n`);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const handleOne = (m) =>
|
|
167
|
+
Promise.resolve()
|
|
168
|
+
.then(() => handle(m))
|
|
169
|
+
.then(writeMsg)
|
|
170
|
+
.catch((e) => {
|
|
171
|
+
// A handler fault must not take the server down; report it and go on.
|
|
172
|
+
const id = m && Object.prototype.hasOwnProperty.call(m, 'id') ? m.id : null;
|
|
173
|
+
writeMsg(errorReply(id ?? null, -32603, String(e && e.message ? e.message : e)));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const processLine = (line) => {
|
|
177
|
+
const trimmed = line.trim();
|
|
178
|
+
if (!trimmed) return;
|
|
179
|
+
let msg;
|
|
180
|
+
try {
|
|
181
|
+
msg = JSON.parse(trimmed);
|
|
182
|
+
} catch {
|
|
183
|
+
// Malformed frame: we cannot know the id, so reply with null id and
|
|
184
|
+
// keep serving. This is the "never crash on bad input" guarantee.
|
|
185
|
+
writeMsg(errorReply(null, -32700, 'Parse error'));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
// Serialize so responses are written in the order frames arrived. A batch
|
|
189
|
+
// (JSON-RPC array) is handled element-wise rather than crashing.
|
|
190
|
+
chain = chain.then(() =>
|
|
191
|
+
Array.isArray(msg) ? Promise.all(msg.map(handleOne)) : handleOne(msg),
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
input.setEncoding('utf8');
|
|
196
|
+
input.on('data', (chunk) => {
|
|
197
|
+
buffer += chunk;
|
|
198
|
+
let idx;
|
|
199
|
+
while ((idx = buffer.indexOf('\n')) !== -1) {
|
|
200
|
+
const line = buffer.slice(0, idx);
|
|
201
|
+
buffer = buffer.slice(idx + 1);
|
|
202
|
+
processLine(line);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
input.on('end', () => {
|
|
206
|
+
if (buffer) processLine(buffer);
|
|
207
|
+
buffer = '';
|
|
208
|
+
chain.then(resolve, resolve);
|
|
209
|
+
});
|
|
210
|
+
input.on('error', () => {
|
|
211
|
+
chain.then(resolve, resolve);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function withOverrides(args, env) {
|
|
217
|
+
const out = { ...env };
|
|
218
|
+
if (args.store) out.LOREKIT_STORE = args.store;
|
|
219
|
+
if (args.mode) out.LOREKIT_MODE = args.mode;
|
|
220
|
+
if (args.endpoint) out.LOREKIT_MCP_URL = args.endpoint;
|
|
221
|
+
if (args.token) out.LOREKIT_TOKEN = args.token;
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Entrypoint for `lorekit mcp`. Resolves the store once, then serves stdio
|
|
226
|
+
// until the client closes stdin. Always resolves to exit code 0.
|
|
227
|
+
export async function mcpServer(args = {}, { env = process.env, input = process.stdin, output = process.stdout } = {}) {
|
|
228
|
+
const root = resolveProjectRoot(args.dir);
|
|
229
|
+
const control = loadControl(root, { env: withOverrides(args, env) });
|
|
230
|
+
const handle = createHandler(control);
|
|
231
|
+
await runStdio(handle, input, output);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Minimal MCP-over-HTTP (JSON-RPC 2.0) client for the LoreKit endpoint.
|
|
2
|
+
// Zero dependencies — uses the global fetch (Node 18+).
|
|
3
|
+
|
|
4
|
+
// Split a configured server URL like ".../mcp?token=lk_rw_x" into
|
|
5
|
+
// { endpoint: ".../mcp", token: "lk_rw_x" }.
|
|
6
|
+
export function splitEndpoint(url) {
|
|
7
|
+
if (!url) return { endpoint: null, token: null };
|
|
8
|
+
try {
|
|
9
|
+
const u = new URL(url);
|
|
10
|
+
const token = u.searchParams.get('token');
|
|
11
|
+
u.searchParams.delete('token');
|
|
12
|
+
const endpoint = u.origin + u.pathname + (u.search || '');
|
|
13
|
+
return { endpoint, token: token || null };
|
|
14
|
+
} catch {
|
|
15
|
+
return { endpoint: url, token: null };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Build the mcp-remote URL that goes into .mcp.json args.
|
|
20
|
+
export function buildRemoteUrl(endpoint, token) {
|
|
21
|
+
if (!token) return endpoint;
|
|
22
|
+
const u = new URL(endpoint);
|
|
23
|
+
u.searchParams.set('token', token);
|
|
24
|
+
return u.toString();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let idCounter = 0;
|
|
28
|
+
|
|
29
|
+
// Returns { ok, httpStatus, result, error, networkError }.
|
|
30
|
+
export async function mcpCall(endpoint, token, method, params = {}, { timeoutMs = 10000 } = {}) {
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
33
|
+
try {
|
|
34
|
+
// Inside the try so a malformed endpoint yields the documented
|
|
35
|
+
// { ok:false, networkError } shape instead of throwing at the call site.
|
|
36
|
+
const url = buildRemoteUrl(endpoint, token);
|
|
37
|
+
const res = await fetch(url, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: {
|
|
40
|
+
'content-type': 'application/json',
|
|
41
|
+
accept: 'application/json, text/event-stream',
|
|
42
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: ++idCounter, method, params }),
|
|
45
|
+
signal: controller.signal,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const text = await res.text();
|
|
49
|
+
const json = parseBody(text);
|
|
50
|
+
|
|
51
|
+
if (json && json.error) {
|
|
52
|
+
return { ok: false, httpStatus: res.status, error: json.error };
|
|
53
|
+
}
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
httpStatus: res.status,
|
|
58
|
+
error: { code: res.status, message: text.slice(0, 200) || res.statusText },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { ok: true, httpStatus: res.status, result: json ? json.result : undefined };
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return { ok: false, networkError: String(e && e.message ? e.message : e) };
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The endpoint may answer as plain JSON or as an SSE frame ("data: {...}").
|
|
70
|
+
function parseBody(text) {
|
|
71
|
+
if (!text) return null;
|
|
72
|
+
const trimmed = text.trim();
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(trimmed);
|
|
75
|
+
} catch {
|
|
76
|
+
const line = trimmed
|
|
77
|
+
.split('\n')
|
|
78
|
+
.map((l) => l.trim())
|
|
79
|
+
.find((l) => l.startsWith('data:'));
|
|
80
|
+
if (line) {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(line.slice('data:'.length).trim());
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/migrate.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// `lorekit migrate --from <path> [--to home|project] [--yes|--apply]`
|
|
2
|
+
//
|
|
3
|
+
// Relocation / rename tool — NOT a persistent-memory importer. It reads a
|
|
4
|
+
// LoreKit-format local store at <path> (e.g. an old `.lore/` directory, or a
|
|
5
|
+
// store that was moved elsewhere) and re-writes its entries into the resolved
|
|
6
|
+
// current-layout store(s), so lessons are never stranded by a rename or a move.
|
|
7
|
+
//
|
|
8
|
+
// Dry-run (preview) by default: it prints what would move, per scope, and
|
|
9
|
+
// changes nothing. Only `--yes` (or `--apply`) mutates. Idempotent: entries are
|
|
10
|
+
// upserted verbatim by scope+key, so a re-run is all NOOP.
|
|
11
|
+
//
|
|
12
|
+
// Out of scope: reading persistent-memory's `~/.agent-memory/<bucket>/INDEX.md`
|
|
13
|
+
// + `entries/` format. This tool only understands LoreKit's own on-disk format.
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { resolveProjectRoot } from './config.mjs';
|
|
17
|
+
import { localStoreDirs } from './control.mjs';
|
|
18
|
+
import { createLocalStore, createTwoTierStore } from './store/index.mjs';
|
|
19
|
+
import { parseEntry } from './store/format.mjs';
|
|
20
|
+
import { log, heading, status, err, c } from './util.mjs';
|
|
21
|
+
|
|
22
|
+
// Recursively collect every parseable LoreKit entry under a base dir. The
|
|
23
|
+
// canonical scope comes from each file's frontmatter, so the source layout does
|
|
24
|
+
// not have to match the destination layout.
|
|
25
|
+
function collectEntries(base) {
|
|
26
|
+
const out = [];
|
|
27
|
+
const walk = (dir) => {
|
|
28
|
+
let names;
|
|
29
|
+
try {
|
|
30
|
+
names = fs.readdirSync(dir, { withFileTypes: true });
|
|
31
|
+
} catch {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
for (const e of names) {
|
|
35
|
+
const p = path.join(dir, e.name);
|
|
36
|
+
if (e.isDirectory()) {
|
|
37
|
+
walk(p);
|
|
38
|
+
} else if (e.name.endsWith('.md')) {
|
|
39
|
+
try {
|
|
40
|
+
const entry = parseEntry(fs.readFileSync(p, 'utf8'));
|
|
41
|
+
if (entry && entry.scope && entry.key) out.push(entry);
|
|
42
|
+
} catch {
|
|
43
|
+
// Skip an unreadable / non-entry file rather than abort the migration.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
walk(base);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Full-field equality (ignoring nothing) so a re-run after apply is NOOP.
|
|
53
|
+
function sameEntry(a, b) {
|
|
54
|
+
if (!a || !b) return false;
|
|
55
|
+
const norm = (e) =>
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
tags: [...(e.tags || [])].sort(),
|
|
58
|
+
source_agent: e.source_agent ?? null,
|
|
59
|
+
trigger: e.trigger ?? null,
|
|
60
|
+
created: e.created ?? null,
|
|
61
|
+
updated: e.updated ?? null,
|
|
62
|
+
archived_at: e.archived_at ?? null,
|
|
63
|
+
value: e.value == null ? '' : String(e.value),
|
|
64
|
+
});
|
|
65
|
+
return norm(a) === norm(b);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function migrate(args) {
|
|
69
|
+
const root = resolveProjectRoot(args.dir);
|
|
70
|
+
|
|
71
|
+
const from = typeof args.from === 'string' ? args.from : null;
|
|
72
|
+
if (!from) {
|
|
73
|
+
err(`${c.red('migrate:')} --from <path> is required (the store to migrate from)`);
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
const src = path.isAbsolute(from) ? from : path.join(root, from);
|
|
77
|
+
if (!fs.existsSync(src)) {
|
|
78
|
+
err(`${c.red('migrate:')} source not found: ${src}`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const to = typeof args.to === 'string' ? args.to.toLowerCase() : null;
|
|
83
|
+
if (to && to !== 'home' && to !== 'project') {
|
|
84
|
+
err(`${c.red('migrate:')} --to must be "home" or "project"`);
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
const apply = Boolean(args.apply || args.yes);
|
|
88
|
+
|
|
89
|
+
const dirs = localStoreDirs(root);
|
|
90
|
+
|
|
91
|
+
// Resolve where each entry goes. `--to` forces a single tier; the default
|
|
92
|
+
// routes each entry by scope through the two-tier store (global → home,
|
|
93
|
+
// repo/branch → project when opted-in, else home).
|
|
94
|
+
let targetFor;
|
|
95
|
+
let destLabel;
|
|
96
|
+
if (to === 'home') {
|
|
97
|
+
const store = createLocalStore(dirs.home);
|
|
98
|
+
targetFor = () => store;
|
|
99
|
+
destLabel = `home (${dirs.home})`;
|
|
100
|
+
} else if (to === 'project') {
|
|
101
|
+
if (apply) fs.mkdirSync(dirs.project, { recursive: true }); // opt-in on apply
|
|
102
|
+
const store = createLocalStore(dirs.project);
|
|
103
|
+
targetFor = () => store;
|
|
104
|
+
destLabel = `project (${dirs.project})`;
|
|
105
|
+
} else {
|
|
106
|
+
const two = createTwoTierStore(dirs);
|
|
107
|
+
targetFor = (scope) => two.tierFor(scope);
|
|
108
|
+
destLabel = 'resolved layout (global→home, repo/branch→project-if-opted-in)';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const entries = collectEntries(src);
|
|
112
|
+
|
|
113
|
+
heading('LoreKit migrate');
|
|
114
|
+
log(` from: ${c.dim(src)}`);
|
|
115
|
+
log(` to: ${c.dim(destLabel)}`);
|
|
116
|
+
log(` mode: ${apply ? c.bold('apply') : 'dry-run — pass --yes to apply'}`);
|
|
117
|
+
log(` found: ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}\n`);
|
|
118
|
+
|
|
119
|
+
const totals = { add: 0, update: 0, noop: 0 };
|
|
120
|
+
const byScope = new Map();
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const store = targetFor(entry.scope);
|
|
123
|
+
const current = store.getEntry({ scope: entry.scope, key: entry.key });
|
|
124
|
+
let verdict;
|
|
125
|
+
if (!current) verdict = 'add';
|
|
126
|
+
else if (sameEntry(current, entry)) verdict = 'noop';
|
|
127
|
+
else verdict = 'update';
|
|
128
|
+
|
|
129
|
+
totals[verdict]++;
|
|
130
|
+
const s = byScope.get(entry.scope) || { add: 0, update: 0, noop: 0 };
|
|
131
|
+
s[verdict]++;
|
|
132
|
+
byScope.set(entry.scope, s);
|
|
133
|
+
|
|
134
|
+
if (apply && verdict !== 'noop') await store.putEntry(entry);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const [scope, s] of byScope) {
|
|
138
|
+
status('info', scope, `${s.add} add, ${s.update} update, ${s.noop} unchanged`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const moved = totals.add + totals.update;
|
|
142
|
+
heading('Summary');
|
|
143
|
+
log(
|
|
144
|
+
` ${apply ? 'migrated' : 'would migrate'} ${moved} entr${moved === 1 ? 'y' : 'ies'} ` +
|
|
145
|
+
`(${totals.add} new, ${totals.update} updated), ${totals.noop} unchanged.`,
|
|
146
|
+
);
|
|
147
|
+
if (!apply && moved > 0) log(` ${c.dim('Re-run with --yes to apply.')}`);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
package/src/scope.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Derive LoreKit scope strings from the git working directory.
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
function git(args, cwd) {
|
|
6
|
+
try {
|
|
7
|
+
return execFileSync('git', args, {
|
|
8
|
+
cwd,
|
|
9
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
}).trim();
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// git@github.com:Owner/Repo.git → owner/repo
|
|
18
|
+
// https://github.com/Owner/Repo → owner/repo
|
|
19
|
+
export function ownerRepoFromRemote(url) {
|
|
20
|
+
if (!url) return null;
|
|
21
|
+
let s = url.trim().replace(/\.git$/i, '');
|
|
22
|
+
s = s.replace(/^git@[^:]+:/, ''); // scp-style
|
|
23
|
+
s = s.replace(/^ssh:\/\/git@[^/]+\//, '');
|
|
24
|
+
s = s.replace(/^https?:\/\/[^/]+\//, '');
|
|
25
|
+
const parts = s.split('/').filter(Boolean);
|
|
26
|
+
if (parts.length < 2) return null;
|
|
27
|
+
const owner = parts[parts.length - 2];
|
|
28
|
+
const repo = parts[parts.length - 1];
|
|
29
|
+
return `${owner}/${repo}`.toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Returns { ownerRepo, branch, repoScope, branchScope, projectScope, readOrder }.
|
|
33
|
+
export function deriveScope(cwd = process.cwd()) {
|
|
34
|
+
const remote = git(['config', '--get', 'remote.origin.url'], cwd);
|
|
35
|
+
const ownerRepo = ownerRepoFromRemote(remote);
|
|
36
|
+
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
37
|
+
const root = git(['rev-parse', '--show-toplevel'], cwd) || cwd;
|
|
38
|
+
const projectName = path.basename(root).toLowerCase();
|
|
39
|
+
|
|
40
|
+
const repoScope = ownerRepo ? `repo::${ownerRepo}` : null;
|
|
41
|
+
const branchScope =
|
|
42
|
+
ownerRepo && branch && branch !== 'HEAD'
|
|
43
|
+
? `branch::${ownerRepo}::${branch.toLowerCase()}`
|
|
44
|
+
: null;
|
|
45
|
+
const projectScope = `project::${projectName}`;
|
|
46
|
+
|
|
47
|
+
const readOrder = [branchScope, repoScope, 'global'].filter(Boolean);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
ownerRepo,
|
|
51
|
+
branch,
|
|
52
|
+
projectName,
|
|
53
|
+
repoScope,
|
|
54
|
+
branchScope,
|
|
55
|
+
projectScope,
|
|
56
|
+
readOrder,
|
|
57
|
+
hasRemote: Boolean(ownerRepo),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// On-disk entry format for the local file store.
|
|
2
|
+
// Zero-dependency. An entry is a markdown file: a YAML frontmatter block whose
|
|
3
|
+
// scalars are JSON-encoded (a strict subset of YAML, so the file stays valid
|
|
4
|
+
// YAML and greppable) followed by the lesson body. This converges with the
|
|
5
|
+
// persistent-memory entry format (frontmatter + body) so a `.lorekit/` directory
|
|
6
|
+
// is interoperable — the frontmatter mirrors a LoreKit row's columns and the
|
|
7
|
+
// body is the row's `value`.
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
// The frontmatter columns, in a stable order. `value` is the body, not a column.
|
|
11
|
+
export const FIELDS = [
|
|
12
|
+
'scope',
|
|
13
|
+
'key',
|
|
14
|
+
'tags',
|
|
15
|
+
'source_agent',
|
|
16
|
+
'trigger',
|
|
17
|
+
'created',
|
|
18
|
+
'updated',
|
|
19
|
+
'archived_at',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
// Serialize an entry ({ ...columns, value }) into file text.
|
|
23
|
+
export function serializeEntry(entry) {
|
|
24
|
+
const fm = FIELDS.map((k) => `${k}: ${JSON.stringify(entry[k] ?? null)}`).join('\n');
|
|
25
|
+
const body = entry.value == null ? '' : String(entry.value);
|
|
26
|
+
return `---\n${fm}\n---\n${body}\n`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Parse file text back into an entry, or null when it has no frontmatter.
|
|
30
|
+
// Values are JSON-decoded; a hand-edited non-JSON scalar falls back to raw text.
|
|
31
|
+
export function parseEntry(text) {
|
|
32
|
+
const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text);
|
|
33
|
+
if (!m) return null;
|
|
34
|
+
const meta = {};
|
|
35
|
+
for (const line of m[1].split('\n')) {
|
|
36
|
+
const idx = line.indexOf(':');
|
|
37
|
+
if (idx === -1) continue;
|
|
38
|
+
const key = line.slice(0, idx).trim();
|
|
39
|
+
if (!key) continue;
|
|
40
|
+
meta[key] = decode(line.slice(idx + 1).trim());
|
|
41
|
+
}
|
|
42
|
+
return { ...meta, value: m[2].replace(/\n$/, '') };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function decode(raw) {
|
|
46
|
+
if (raw === '') return null;
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(raw);
|
|
49
|
+
} catch {
|
|
50
|
+
return raw;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Filesystem-safe slug for a lesson key. The key stays authoritative in the
|
|
55
|
+
// frontmatter, so the slug only needs to be safe and readable, not reversible.
|
|
56
|
+
export function slugify(key) {
|
|
57
|
+
const s = String(key)
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
60
|
+
.replace(/^-+|-+$/g, '')
|
|
61
|
+
.slice(0, 80);
|
|
62
|
+
return s || 'entry';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Sanitize a single path segment (owner, repo, branch part).
|
|
66
|
+
function safeSeg(s) {
|
|
67
|
+
const cleaned = String(s == null ? '' : s)
|
|
68
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
69
|
+
.replace(/^-+|-+$/g, '');
|
|
70
|
+
// Neutralize dot-only segments ('.', '..', …): the char class above keeps
|
|
71
|
+
// dots (so `my.repo` survives), but a bare `..` segment would let a crafted
|
|
72
|
+
// scope traverse out of the store dir via path.join. Collapse those to '_'.
|
|
73
|
+
if (/^\.+$/.test(cleaned)) return '_';
|
|
74
|
+
return cleaned || '_';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Map a canonical scope string to its directory under the store base.
|
|
78
|
+
// global → <base>/global
|
|
79
|
+
// project::{name} → <base>/project/{name}
|
|
80
|
+
// repo::{owner}/{repo} → <base>/repo/{owner}/{repo}
|
|
81
|
+
// branch::{owner}/{repo}::{branch} → <base>/branch/{owner}/{repo}/{branch...}
|
|
82
|
+
export function scopeToDir(baseDir, scope) {
|
|
83
|
+
const parts = String(scope).split('::');
|
|
84
|
+
const type = parts[0];
|
|
85
|
+
if (type === 'global') return path.join(baseDir, 'global');
|
|
86
|
+
if (type === 'project') return path.join(baseDir, 'project', safeSeg(parts[1]));
|
|
87
|
+
if (type === 'repo') {
|
|
88
|
+
const [owner, repo] = String(parts[1] || '').split('/');
|
|
89
|
+
return path.join(baseDir, 'repo', safeSeg(owner), safeSeg(repo));
|
|
90
|
+
}
|
|
91
|
+
if (type === 'branch') {
|
|
92
|
+
const [owner, repo] = String(parts[1] || '').split('/');
|
|
93
|
+
const branch = String(parts[2] || '')
|
|
94
|
+
.split('/')
|
|
95
|
+
.map(safeSeg);
|
|
96
|
+
return path.join(baseDir, 'branch', safeSeg(owner), safeSeg(repo), ...branch);
|
|
97
|
+
}
|
|
98
|
+
return path.join(baseDir, '_other', safeSeg(String(scope).replace(/::/g, '-')));
|
|
99
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Store factory: build the store the resolved control model selects.
|
|
2
|
+
// Returns null for `off` — callers short-circuit `off` before reaching here,
|
|
3
|
+
// so a no-op store object is unnecessary.
|
|
4
|
+
import { createLocalStore, createTwoTierStore } from './local.mjs';
|
|
5
|
+
import { createRemoteStore } from './remote.mjs';
|
|
6
|
+
|
|
7
|
+
export function createStore(control) {
|
|
8
|
+
if (!control || control.mode === 'off') return null;
|
|
9
|
+
// Local mode is two-tier: control.storeTarget is { home, project }.
|
|
10
|
+
if (control.mode === 'local') return createTwoTierStore(control.storeTarget);
|
|
11
|
+
if (control.mode === 'remote') {
|
|
12
|
+
const conn = control.connection || {};
|
|
13
|
+
return createRemoteStore({ endpoint: conn.endpoint, token: conn.token });
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { createLocalStore, createTwoTierStore, createRemoteStore };
|