@lorekit/cli 1.25.1 → 1.26.1
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/bin/lorekit.mjs +10 -3
- package/package.json +1 -1
- package/src/mcp-server.mjs +61 -6
- package/src/origin.mjs +186 -0
- package/src/store/format.mjs +11 -0
- package/src/store/local.mjs +33 -11
- package/src/store/remote.mjs +11 -1
- package/src/store/ttl.mjs +69 -0
- package/src/write.mjs +65 -1
package/bin/lorekit.mjs
CHANGED
|
@@ -288,8 +288,14 @@ ${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)
|
|
294
|
+
--origin-repo <o/n> Override the derived provenance repository
|
|
295
|
+
--origin-branch <b> Override the derived provenance branch
|
|
296
|
+
--origin-commit <s> Override the derived provenance commit SHA
|
|
297
|
+
--origin-pr <n> The pull request this lesson came out of
|
|
298
|
+
--no-origin Record no provenance at all
|
|
293
299
|
--remote Force write to the remote store
|
|
294
300
|
--local Force write to the local offline store
|
|
295
301
|
--json Machine-readable output
|
|
@@ -569,8 +575,9 @@ const KNOWN_FLAGS = [
|
|
|
569
575
|
'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
|
|
570
576
|
'from', 'to', 'apply', 'yes', 'no-hooks', 'force', 'deep', 'adapter',
|
|
571
577
|
'event', 'json', 'scope', 'threshold', 'help', 'version',
|
|
572
|
-
'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'org', 'remote', 'local',
|
|
578
|
+
'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
|
|
573
579
|
'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
|
|
580
|
+
'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
|
|
574
581
|
];
|
|
575
582
|
|
|
576
583
|
// Commands that write to disk / talk to the network on a human's behalf. These
|
|
@@ -595,7 +602,7 @@ async function main() {
|
|
|
595
602
|
const argv = process.argv.slice(2);
|
|
596
603
|
const args = parseArgs(argv, {
|
|
597
604
|
aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
|
|
598
|
-
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', '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'],
|
|
599
606
|
known: KNOWN_FLAGS,
|
|
600
607
|
});
|
|
601
608
|
|
package/package.json
CHANGED
package/src/mcp-server.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { resolveProjectRoot } from './config.mjs';
|
|
|
28
28
|
import { loadControl } from './control.mjs';
|
|
29
29
|
import { createStore } from './store/index.mjs';
|
|
30
30
|
import { createRemoteStore } from './store/remote.mjs';
|
|
31
|
+
import { deriveOrigin, mergeOrigin } from './origin.mjs';
|
|
31
32
|
|
|
32
33
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
33
34
|
const SERVER_INFO = { name: 'lorekit-local', version: '1.0.0' };
|
|
@@ -55,6 +56,39 @@ export const MEMORY_TOOL_DEFS = [
|
|
|
55
56
|
description:
|
|
56
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.',
|
|
57
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
|
+
},
|
|
71
|
+
origin_repo: {
|
|
72
|
+
type: 'string',
|
|
73
|
+
description:
|
|
74
|
+
'Provenance: the owner/name of the repository this memory was recorded from. Derived from the working directory when omitted.',
|
|
75
|
+
},
|
|
76
|
+
origin_branch: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
description:
|
|
79
|
+
'Provenance: the git branch this memory was recorded from. Derived from the working directory when omitted.',
|
|
80
|
+
},
|
|
81
|
+
origin_commit: {
|
|
82
|
+
type: 'string',
|
|
83
|
+
description:
|
|
84
|
+
'Provenance: the commit SHA checked out when this memory was recorded. Derived from the working directory when omitted.',
|
|
85
|
+
},
|
|
86
|
+
origin_pr: {
|
|
87
|
+
type: 'integer',
|
|
88
|
+
minimum: 1,
|
|
89
|
+
description:
|
|
90
|
+
'Provenance: the pull request number this memory came out of. Pass it when you know it — the server can only infer it from CI environment variables.',
|
|
91
|
+
},
|
|
58
92
|
},
|
|
59
93
|
},
|
|
60
94
|
},
|
|
@@ -148,10 +182,15 @@ export const ORG_TOOL_DEFS = [
|
|
|
148
182
|
// Legacy alias kept so existing code that imports TOOL_DEFS still compiles.
|
|
149
183
|
export const TOOL_DEFS = [...MEMORY_TOOL_DEFS, ...ORG_TOOL_DEFS];
|
|
150
184
|
|
|
151
|
-
// tool name → (store, args) → store result. The store destructures the
|
|
152
|
-
// needs, so the raw `arguments` object is passed straight through.
|
|
185
|
+
// tool name → (store, args, ctx) → store result. The store destructures the
|
|
186
|
+
// args it needs, so the raw `arguments` object is passed straight through.
|
|
187
|
+
// `ctx.root` is the resolved project root (`--dir`), NOT the process cwd — an
|
|
188
|
+
// MCP client launched from elsewhere would otherwise stamp the wrong origin.
|
|
153
189
|
const MEMORY_DISPATCH = {
|
|
154
|
-
|
|
190
|
+
// An agent calling memory.write knows the lesson, not the working directory
|
|
191
|
+
// it is running in. Fill in whatever provenance the environment can supply,
|
|
192
|
+
// with anything the caller DID pass taking precedence.
|
|
193
|
+
'memory.write': (store, a, ctx) => store.write({ ...a, ...withDerivedOrigin(a, ctx) }),
|
|
155
194
|
'memory.read': (store, a) => store.read(a),
|
|
156
195
|
'memory.list': (store, a) => store.list(a),
|
|
157
196
|
'memory.search': (store, a) => store.search(a),
|
|
@@ -159,6 +198,22 @@ const MEMORY_DISPATCH = {
|
|
|
159
198
|
'memory.archive': (store, a) => store.archive(a),
|
|
160
199
|
};
|
|
161
200
|
|
|
201
|
+
// Provenance for a tool call: the caller's explicit values win, the working
|
|
202
|
+
// directory and CI environment fill the rest. Best-effort — a failure to shell
|
|
203
|
+
// out to git must never fail the write, so it degrades to no origin at all.
|
|
204
|
+
function withDerivedOrigin(args = {}, { root } = {}) {
|
|
205
|
+
try {
|
|
206
|
+
return mergeOrigin(deriveOrigin({ cwd: root }), {
|
|
207
|
+
origin_repo: args.origin_repo ?? null,
|
|
208
|
+
origin_branch: args.origin_branch ?? null,
|
|
209
|
+
origin_commit: args.origin_commit ?? null,
|
|
210
|
+
origin_pr: args.origin_pr ?? null,
|
|
211
|
+
});
|
|
212
|
+
} catch {
|
|
213
|
+
return {};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
162
217
|
// org.* dispatch — always routed to the remote store.
|
|
163
218
|
const ORG_DISPATCH = {
|
|
164
219
|
'org.create': (remote, a) => remote.orgCreate(a),
|
|
@@ -188,7 +243,7 @@ function toolResult(id, payload) {
|
|
|
188
243
|
|
|
189
244
|
// Build the per-message handler over a resolved control model. `store` is null
|
|
190
245
|
// when mode is `off`. Org tools are always advertised regardless of mode.
|
|
191
|
-
export function createHandler(control) {
|
|
246
|
+
export function createHandler(control, { root = process.cwd() } = {}) {
|
|
192
247
|
const store = createStore(control);
|
|
193
248
|
const memoryTools = store ? MEMORY_TOOL_DEFS : [];
|
|
194
249
|
|
|
@@ -257,7 +312,7 @@ export function createHandler(control) {
|
|
|
257
312
|
const fn = MEMORY_DISPATCH[name];
|
|
258
313
|
if (!fn) return errorReply(id, -32601, `Unknown tool: ${name}`);
|
|
259
314
|
|
|
260
|
-
const result = await fn(store, args);
|
|
315
|
+
const result = await fn(store, args, { root });
|
|
261
316
|
return toolResult(id, result);
|
|
262
317
|
}
|
|
263
318
|
|
|
@@ -359,7 +414,7 @@ export async function mcpServer(
|
|
|
359
414
|
) {
|
|
360
415
|
const root = resolveProjectRoot(args.dir);
|
|
361
416
|
const control = loadControl(root, { env: withOverrides(args, env) });
|
|
362
|
-
const handle = createHandler(control);
|
|
417
|
+
const handle = createHandler(control, { root });
|
|
363
418
|
// A human who runs `lorekit mcp` in a terminal would otherwise see a silent
|
|
364
419
|
// hang with no sign it is alive. Reassure them on stderr — but only when
|
|
365
420
|
// stdin is a TTY, so a piped MCP client never sees the banner on either channel.
|
package/src/origin.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Derive a memory's ORIGIN — where a lesson is being recorded FROM — from the
|
|
2
|
+
// git working directory and the ambient CI environment.
|
|
3
|
+
//
|
|
4
|
+
// This is the counterpart to `scope.mjs`'s `deriveScope()`. A scope says where
|
|
5
|
+
// a lesson APPLIES; an origin says where it was written: the repository, the
|
|
6
|
+
// branch, the checked-out commit, and the pull request the work belonged to.
|
|
7
|
+
// The dashboard renders it as a "recorded from" block with links straight back
|
|
8
|
+
// to the PR, branch, and commit (migration 00048).
|
|
9
|
+
//
|
|
10
|
+
// Nothing here is required: every field independently degrades to `null` when
|
|
11
|
+
// it cannot be determined (no git, detached HEAD, no PR context), and a write
|
|
12
|
+
// with no origin at all behaves exactly as it did before this existed.
|
|
13
|
+
//
|
|
14
|
+
// Zero-dependency, and deliberately injectable (`{ cwd, env, run }`) so the
|
|
15
|
+
// whole derivation is testable without a real repository or a real CI runner.
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import { ownerRepoFromRemote } from './scope.mjs';
|
|
18
|
+
|
|
19
|
+
function gitRunner(args, cwd) {
|
|
20
|
+
try {
|
|
21
|
+
return execFileSync('git', args, {
|
|
22
|
+
cwd,
|
|
23
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
}).trim();
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Extract a pull request number from the ambient environment.
|
|
33
|
+
*
|
|
34
|
+
* Precedence, first match wins:
|
|
35
|
+
* 1. LOREKIT_PR — the explicit escape hatch, any CI or none.
|
|
36
|
+
* 2. GITHUB_REF — `refs/pull/<n>/merge` on a GitHub Actions
|
|
37
|
+
* `pull_request` run.
|
|
38
|
+
* 3. GITHUB_PR_NUMBER — set by several popular actions.
|
|
39
|
+
*
|
|
40
|
+
* Returns a positive integer, or `null` when no PR context is present.
|
|
41
|
+
*/
|
|
42
|
+
export function prNumberFromEnv(env = process.env) {
|
|
43
|
+
const explicit = toPositiveInt(env.LOREKIT_PR);
|
|
44
|
+
if (explicit !== null) return explicit;
|
|
45
|
+
|
|
46
|
+
const ref = typeof env.GITHUB_REF === 'string' ? env.GITHUB_REF : '';
|
|
47
|
+
const m = /^refs\/pull\/(\d+)\//.exec(ref);
|
|
48
|
+
if (m) {
|
|
49
|
+
const n = toPositiveInt(m[1]);
|
|
50
|
+
if (n !== null) return n;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return toPositiveInt(env.GITHUB_PR_NUMBER);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toPositiveInt(raw) {
|
|
57
|
+
if (raw === undefined || raw === null || raw === '') return null;
|
|
58
|
+
const n = Number(raw);
|
|
59
|
+
if (!Number.isInteger(n) || n < 1) return null;
|
|
60
|
+
return n;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether a string is a usable git ref name, per git-check-ref-format.
|
|
65
|
+
*
|
|
66
|
+
* A deny list, deliberately: a branch is whatever a contributor named it, and
|
|
67
|
+
* `feat/add+x` / `fix/issue#123` / `feat/café` are all legal. The canonical
|
|
68
|
+
* rule lives in `packages/mcp-core/src/origin.ts` (`parseOriginBranch`); this
|
|
69
|
+
* is the zero-dependency CLI copy, kept behaviourally identical so a branch the
|
|
70
|
+
* CLI derives is never one the server would reject.
|
|
71
|
+
*/
|
|
72
|
+
export function isValidRef(value) {
|
|
73
|
+
if (typeof value !== 'string' || value === '') return false;
|
|
74
|
+
// eslint-disable-next-line no-control-regex
|
|
75
|
+
if (/[\u0000-\u001f\u007f ~^:?*[\\]/.test(value)) return false;
|
|
76
|
+
if (value.startsWith('/') || value.endsWith('/')) return false;
|
|
77
|
+
if (value.startsWith('.') || value.endsWith('.')) return false;
|
|
78
|
+
if (value.endsWith('.lock')) return false;
|
|
79
|
+
if (value.includes('..') || value.includes('//') || value.includes('@{')) return false;
|
|
80
|
+
return value.length <= 255;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function firstNonEmpty(...values) {
|
|
84
|
+
for (const v of values) {
|
|
85
|
+
if (typeof v === 'string' && v.trim() !== '') return v.trim();
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Derive `{ origin_repo, origin_branch, origin_commit, origin_pr }` for the
|
|
92
|
+
* current working directory.
|
|
93
|
+
*
|
|
94
|
+
* Branch resolution prefers `GITHUB_HEAD_REF` over `git rev-parse`: on a
|
|
95
|
+
* GitHub Actions `pull_request` run the checkout is a detached merge commit,
|
|
96
|
+
* so git reports `HEAD` while `GITHUB_HEAD_REF` holds the real source branch.
|
|
97
|
+
*
|
|
98
|
+
* The branch is NOT lowercased (unlike a `branch::` scope) so the GitHub
|
|
99
|
+
* `/tree/` link the dashboard builds from it resolves for a mixed-case branch.
|
|
100
|
+
*
|
|
101
|
+
* @returns an object whose four fields are each a value or `null`.
|
|
102
|
+
*/
|
|
103
|
+
export function deriveOrigin({ cwd = process.cwd(), env = process.env, run = gitRunner } = {}) {
|
|
104
|
+
const remote = run(['config', '--get', 'remote.origin.url'], cwd);
|
|
105
|
+
const gitBranch = run(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
106
|
+
|
|
107
|
+
// On a GitHub Actions `pull_request` run the checkout is a DETACHED MERGE
|
|
108
|
+
// COMMIT of the head branch into the base, so `HEAD` (and `GITHUB_SHA`) name
|
|
109
|
+
// a commit that exists on neither branch. Since the branch below deliberately
|
|
110
|
+
// reports `GITHUB_HEAD_REF` — the real source branch — taking the merge SHA
|
|
111
|
+
// here would have the two fields describe different refs.
|
|
112
|
+
//
|
|
113
|
+
// The head commit is the merge commit's SECOND parent. A shallow checkout
|
|
114
|
+
// (actions/checkout's default `fetch-depth: 1`) does not have it, in which
|
|
115
|
+
// case we record NO commit: an absent field is honest, a mismatched one is
|
|
116
|
+
// worse than nothing.
|
|
117
|
+
const onMergeCheckout = Boolean(firstNonEmpty(env.GITHUB_HEAD_REF));
|
|
118
|
+
const gitCommit = onMergeCheckout
|
|
119
|
+
? run(['rev-parse', 'HEAD^2'], cwd)
|
|
120
|
+
: run(['rev-parse', 'HEAD'], cwd);
|
|
121
|
+
|
|
122
|
+
// Precedence matches every other field: the explicit `LOREKIT_*` override
|
|
123
|
+
// first, then what the environment can work out. `LOREKIT_REPO` losing to the
|
|
124
|
+
// git remote would make it the one override that does not override.
|
|
125
|
+
//
|
|
126
|
+
// Each candidate goes through `isValidRepo` individually rather than only the
|
|
127
|
+
// winner, so an odd remote FALLS THROUGH to `GITHUB_REPOSITORY` instead of
|
|
128
|
+
// shadowing it with a value the server's strict `parseOriginRepo` rejects —
|
|
129
|
+
// which would 400 the write this provenance only decorates.
|
|
130
|
+
const repo =
|
|
131
|
+
isValidRepo(env.LOREKIT_REPO) ??
|
|
132
|
+
isValidRepo(ownerRepoFromRemote(remote)) ??
|
|
133
|
+
isValidRepo(env.GITHUB_REPOSITORY);
|
|
134
|
+
const branchRaw = firstNonEmpty(env.LOREKIT_BRANCH, env.GITHUB_HEAD_REF, gitBranch);
|
|
135
|
+
// A derived branch that the server would reject is dropped here rather than
|
|
136
|
+
// sent: provenance is decoration, and it must never fail the write it is
|
|
137
|
+
// decorating.
|
|
138
|
+
const branch = branchRaw && branchRaw !== 'HEAD' && isValidRef(branchRaw) ? branchRaw : null;
|
|
139
|
+
// GITHUB_SHA is only a safe fallback OFF a merge checkout — on one it is the
|
|
140
|
+
// merge commit, the very value the branch above exists to avoid.
|
|
141
|
+
const commitRaw = onMergeCheckout
|
|
142
|
+
? firstNonEmpty(env.LOREKIT_COMMIT, gitCommit)
|
|
143
|
+
: firstNonEmpty(env.LOREKIT_COMMIT, gitCommit, env.GITHUB_SHA);
|
|
144
|
+
const commit = commitRaw && /^[0-9a-fA-F]{7,40}$/.test(commitRaw) ? commitRaw.toLowerCase() : null;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
origin_repo: repo,
|
|
148
|
+
origin_branch: branch,
|
|
149
|
+
origin_commit: commit,
|
|
150
|
+
origin_pr: prNumberFromEnv(env),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Normalise a candidate `owner/name`, or null when it is not one.
|
|
156
|
+
*
|
|
157
|
+
* The single repo rule for every derivation path, matching the server's
|
|
158
|
+
* `parseOriginRepo` (`packages/mcp-core/src/origin.ts`) — including its
|
|
159
|
+
* rejection of a dots-only segment, which `[\\w.-]+` alone admits and which
|
|
160
|
+
* would render as a link to a different repository.
|
|
161
|
+
*/
|
|
162
|
+
export function isValidRepo(value) {
|
|
163
|
+
if (typeof value !== 'string') return null;
|
|
164
|
+
const normalized = value.trim().toLowerCase();
|
|
165
|
+
if (normalized === '' || normalized.length > 140) return null;
|
|
166
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(normalized)) return null;
|
|
167
|
+
if (normalized.split('/').some((segment) => /^\.+$/.test(segment))) return null;
|
|
168
|
+
return normalized;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Merge a derived origin under caller-supplied overrides, dropping null fields.
|
|
173
|
+
*
|
|
174
|
+
* An explicitly supplied value always wins; a field neither supplied nor
|
|
175
|
+
* derivable is omitted entirely rather than sent as `null`, so the server's
|
|
176
|
+
* "keep the last KNOWN origin" upsert rule never erases a previously recorded
|
|
177
|
+
* value.
|
|
178
|
+
*/
|
|
179
|
+
export function mergeOrigin(derived = {}, overrides = {}) {
|
|
180
|
+
const out = {};
|
|
181
|
+
for (const field of ['origin_repo', 'origin_branch', 'origin_commit', 'origin_pr']) {
|
|
182
|
+
const value = overrides[field] ?? derived[field] ?? null;
|
|
183
|
+
if (value !== null && value !== undefined && value !== '') out[field] = value;
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
package/src/store/format.mjs
CHANGED
|
@@ -17,6 +17,17 @@ export const FIELDS = [
|
|
|
17
17
|
'created',
|
|
18
18
|
'updated',
|
|
19
19
|
'archived_at',
|
|
20
|
+
// Provenance — where the lesson was recorded FROM (see src/origin.mjs).
|
|
21
|
+
// Appended, never reordered: parseEntry is tolerant, so a file written before
|
|
22
|
+
// these existed simply decodes them as absent.
|
|
23
|
+
'origin_repo',
|
|
24
|
+
'origin_branch',
|
|
25
|
+
'origin_commit',
|
|
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',
|
|
20
31
|
];
|
|
21
32
|
|
|
22
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
|
|
@@ -91,17 +92,23 @@ class LocalStore {
|
|
|
91
92
|
// existing key (a creation date never moves). Returns { ok:false, error } on
|
|
92
93
|
// an invalid or future-dated value rather than throwing, matching the store
|
|
93
94
|
// contract's error surfacing.
|
|
94
|
-
async write({
|
|
95
|
-
|
|
95
|
+
async write({
|
|
96
|
+
scope, key, value, tags, source_agent, trigger, created_at, ttl_days, clear_ttl,
|
|
97
|
+
origin_repo, origin_branch, origin_commit, origin_pr,
|
|
98
|
+
} = {}) {
|
|
99
|
+
const now = new Date().toISOString();
|
|
100
|
+
const existing = this._findByKey(scope, key);
|
|
101
|
+
let override, expires_at;
|
|
96
102
|
try {
|
|
97
103
|
override = normalizeCreatedAt(created_at);
|
|
104
|
+
expires_at = resolveExpiresAt({
|
|
105
|
+
clearTtl: clear_ttl, ttlDays: ttl_days, now, current: existing?.entry.expires_at,
|
|
106
|
+
});
|
|
98
107
|
} catch (e) {
|
|
99
108
|
return { ok: false, error: e.message };
|
|
100
109
|
}
|
|
101
110
|
const dir = this._dir(scope);
|
|
102
111
|
fs.mkdirSync(dir, { recursive: true });
|
|
103
|
-
const now = new Date().toISOString();
|
|
104
|
-
const existing = this._findByKey(scope, key);
|
|
105
112
|
const created = existing ? existing.entry.created || now : override || now;
|
|
106
113
|
const entry = {
|
|
107
114
|
scope,
|
|
@@ -109,9 +116,17 @@ class LocalStore {
|
|
|
109
116
|
tags: Array.isArray(tags) ? tags : [],
|
|
110
117
|
source_agent: source_agent || null,
|
|
111
118
|
trigger: trigger || null,
|
|
119
|
+
// Provenance keeps the last KNOWN value per field, mirroring the hosted
|
|
120
|
+
// memory_write upsert: a write that does not know a field must not erase
|
|
121
|
+
// what a previous write recorded.
|
|
122
|
+
origin_repo: origin_repo ?? existing?.entry.origin_repo ?? null,
|
|
123
|
+
origin_branch: origin_branch ?? existing?.entry.origin_branch ?? null,
|
|
124
|
+
origin_commit: origin_commit ?? existing?.entry.origin_commit ?? null,
|
|
125
|
+
origin_pr: origin_pr ?? existing?.entry.origin_pr ?? null,
|
|
112
126
|
created,
|
|
113
127
|
updated: existing ? now : override || now,
|
|
114
128
|
archived_at: null,
|
|
129
|
+
expires_at,
|
|
115
130
|
value: value == null ? '' : String(value),
|
|
116
131
|
};
|
|
117
132
|
const file = existing ? existing.file : this._freshPath(dir, key);
|
|
@@ -135,9 +150,14 @@ class LocalStore {
|
|
|
135
150
|
tags: Array.isArray(entry.tags) ? entry.tags : [],
|
|
136
151
|
source_agent: entry.source_agent ?? null,
|
|
137
152
|
trigger: entry.trigger ?? null,
|
|
153
|
+
origin_repo: entry.origin_repo ?? null,
|
|
154
|
+
origin_branch: entry.origin_branch ?? null,
|
|
155
|
+
origin_commit: entry.origin_commit ?? null,
|
|
156
|
+
origin_pr: entry.origin_pr ?? null,
|
|
138
157
|
created: entry.created ?? now,
|
|
139
158
|
updated: entry.updated ?? now,
|
|
140
159
|
archived_at: entry.archived_at ?? null,
|
|
160
|
+
expires_at: entry.expires_at ?? null,
|
|
141
161
|
value: entry.value == null ? '' : String(entry.value),
|
|
142
162
|
};
|
|
143
163
|
const file = existing ? existing.file : this._freshPath(dir, entry.key);
|
|
@@ -244,9 +264,10 @@ class LocalStore {
|
|
|
244
264
|
// lossy for `project::{name}` (stored by basename only). Returns
|
|
245
265
|
// `[{ scope, count }]`, unsorted.
|
|
246
266
|
async listScopes() {
|
|
267
|
+
const now = new Date();
|
|
247
268
|
const counts = new Map();
|
|
248
269
|
for (const { entry } of this._walkEntries()) {
|
|
249
|
-
if (entry.
|
|
270
|
+
if (!entry.scope || !isLive(entry, now)) continue;
|
|
250
271
|
counts.set(entry.scope, (counts.get(entry.scope) || 0) + 1);
|
|
251
272
|
}
|
|
252
273
|
return [...counts.entries()].map(([scope, count]) => ({ scope, count }));
|
|
@@ -378,12 +399,13 @@ class TwoTierStore {
|
|
|
378
399
|
// a lesson present in both tiers is counted once — project shadows home, the
|
|
379
400
|
// same first-wins merge `list()` uses. Returns `[{ scope, count }]`, unsorted.
|
|
380
401
|
async listScopes() {
|
|
402
|
+
const now = new Date();
|
|
381
403
|
const seen = new Set(); // `${scope}\x00${key}` — dedup across tiers
|
|
382
404
|
const counts = new Map();
|
|
383
405
|
const tiers = this.projectActive() ? [this.project, this.home] : [this.home];
|
|
384
406
|
for (const tier of tiers) {
|
|
385
407
|
for (const { entry } of tier._walkEntries()) {
|
|
386
|
-
if (entry.
|
|
408
|
+
if (!entry.scope || !isLive(entry, now)) continue;
|
|
387
409
|
const id = `${entry.scope}\x00${entry.key ?? ''}`;
|
|
388
410
|
if (seen.has(id)) continue;
|
|
389
411
|
seen.add(id);
|
package/src/store/remote.mjs
CHANGED
|
@@ -91,7 +91,10 @@ class RemoteStore {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
async write(args = {}) {
|
|
94
|
-
const {
|
|
94
|
+
const {
|
|
95
|
+
scope, key, value, tags, source_agent, trigger, org, ttl_days, clear_ttl, created_at,
|
|
96
|
+
origin_repo, origin_branch, origin_commit, origin_pr,
|
|
97
|
+
} = args;
|
|
95
98
|
const body = { scope, key, value };
|
|
96
99
|
if (tags !== undefined) body.tags = tags;
|
|
97
100
|
if (source_agent !== undefined) body.source_agent = source_agent;
|
|
@@ -100,6 +103,13 @@ class RemoteStore {
|
|
|
100
103
|
if (ttl_days !== undefined) body.ttl_days = ttl_days;
|
|
101
104
|
if (clear_ttl !== undefined) body.clear_ttl = clear_ttl;
|
|
102
105
|
if (created_at !== undefined) body.created_at = created_at;
|
|
106
|
+
// Provenance — only sent when known. Omitting a field leaves whatever the
|
|
107
|
+
// row already recorded intact (the RPC coalesces), which is what makes a
|
|
108
|
+
// write from a machine with no git context non-destructive.
|
|
109
|
+
if (origin_repo !== undefined) body.origin_repo = origin_repo;
|
|
110
|
+
if (origin_branch !== undefined) body.origin_branch = origin_branch;
|
|
111
|
+
if (origin_commit !== undefined) body.origin_commit = origin_commit;
|
|
112
|
+
if (origin_pr !== undefined) body.origin_pr = origin_pr;
|
|
103
113
|
const res = await this._rest('/memories', { method: 'POST', body });
|
|
104
114
|
return { ok: res.ok, error: res.error, networkError: res.networkError };
|
|
105
115
|
}
|
|
@@ -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,8 +14,19 @@
|
|
|
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
|
//
|
|
20
|
+
// Provenance — where the lesson is being recorded FROM. Derived automatically
|
|
21
|
+
// from git + the CI environment (repo, branch, commit, and the pull request
|
|
22
|
+
// from LOREKIT_PR / GITHUB_REF); the dashboard turns it into links back to the
|
|
23
|
+
// PR, branch and commit. Each can be overridden, and the whole thing skipped:
|
|
24
|
+
// --origin-repo <owner/name> Override the derived repository
|
|
25
|
+
// --origin-branch <name> Override the derived branch
|
|
26
|
+
// --origin-commit <sha> Override the derived commit
|
|
27
|
+
// --origin-pr <n> The pull request this lesson came out of
|
|
28
|
+
// --no-origin Record no provenance at all
|
|
29
|
+
//
|
|
19
30
|
// Store targeting (default: remote if configured, else local):
|
|
20
31
|
// --remote Force write to the remote store
|
|
21
32
|
// --local Force write to the local offline store
|
|
@@ -32,6 +43,8 @@ import { resolveDenies } from './control.mjs';
|
|
|
32
43
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
33
44
|
import { log, err, heading, status, c } from './util.mjs';
|
|
34
45
|
import { parseScopeKey } from './lessons-view.mjs';
|
|
46
|
+
import { deriveOrigin, mergeOrigin } from './origin.mjs';
|
|
47
|
+
import { parseTtlDays } from './store/ttl.mjs';
|
|
35
48
|
|
|
36
49
|
// Read all of stdin to a string. Resolves to '' when stdin IS a TTY (no pipe).
|
|
37
50
|
function readStdin() {
|
|
@@ -97,9 +110,56 @@ export async function write(args) {
|
|
|
97
110
|
const tags = args.tags ? String(args.tags).split(',').map((t) => t.trim()).filter(Boolean) : [];
|
|
98
111
|
const sourceAgent = typeof args['source-agent'] === 'string' ? args['source-agent'] : undefined;
|
|
99
112
|
const trigger = typeof args.trigger === 'string' ? args.trigger : undefined;
|
|
100
|
-
|
|
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']);
|
|
101
135
|
const orgSlug = typeof args.org === 'string' ? args.org : undefined;
|
|
102
136
|
|
|
137
|
+
// ── Provenance ────────────────────────────────────────────────────────────
|
|
138
|
+
// Derived from git + CI unless --no-origin; explicit --origin-* flags win.
|
|
139
|
+
// A field that is neither supplied nor derivable is omitted, never sent as
|
|
140
|
+
// null — the server keeps the last KNOWN origin per field, so an omission
|
|
141
|
+
// must not erase what an earlier write recorded.
|
|
142
|
+
// An explicitly supplied PR number is a caller assertion, so a malformed one
|
|
143
|
+
// is a usage error — silently ignoring it would record no provenance while
|
|
144
|
+
// the user believes they set it. Derived values, by contrast, degrade quietly.
|
|
145
|
+
let originPr = null;
|
|
146
|
+
if (args['origin-pr'] !== undefined) {
|
|
147
|
+
originPr = Number(args['origin-pr']);
|
|
148
|
+
if (!Number.isInteger(originPr) || originPr < 1) {
|
|
149
|
+
err(`${c.red('Error:')} --origin-pr must be a positive integer (got ${args['origin-pr']})`);
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const origin = args['no-origin']
|
|
155
|
+
? {}
|
|
156
|
+
: mergeOrigin(deriveOrigin({ cwd: root, env }), {
|
|
157
|
+
origin_repo: typeof args['origin-repo'] === 'string' ? args['origin-repo'] : null,
|
|
158
|
+
origin_branch: typeof args['origin-branch'] === 'string' ? args['origin-branch'] : null,
|
|
159
|
+
origin_commit: typeof args['origin-commit'] === 'string' ? args['origin-commit'] : null,
|
|
160
|
+
origin_pr: originPr,
|
|
161
|
+
});
|
|
162
|
+
|
|
103
163
|
// ── Resolve deny constraints ───────────────────────────────────────────────
|
|
104
164
|
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
105
165
|
|
|
@@ -161,7 +221,9 @@ export async function write(args) {
|
|
|
161
221
|
...(sourceAgent ? { source_agent: sourceAgent } : {}),
|
|
162
222
|
...(trigger ? { trigger } : {}),
|
|
163
223
|
...(ttlDays ? { ttl_days: ttlDays } : {}),
|
|
224
|
+
...(clearTtl ? { clear_ttl: true } : {}),
|
|
164
225
|
...(orgSlug ? { org: orgSlug } : {}),
|
|
226
|
+
...origin,
|
|
165
227
|
};
|
|
166
228
|
|
|
167
229
|
let result;
|
|
@@ -192,6 +254,7 @@ export async function write(args) {
|
|
|
192
254
|
tags,
|
|
193
255
|
source_agent: sourceAgent || null,
|
|
194
256
|
trigger: trigger || null,
|
|
257
|
+
origin,
|
|
195
258
|
}, null, 2));
|
|
196
259
|
} else {
|
|
197
260
|
const verb = inserted === true ? 'Created' : inserted === false ? 'Updated' : 'Written';
|
|
@@ -210,5 +273,6 @@ export async function write(args) {
|
|
|
210
273
|
'lorekit.cli.write.inserted': inserted,
|
|
211
274
|
'lorekit.cli.write.has_tags': tags.length > 0,
|
|
212
275
|
'lorekit.cli.write.has_ttl': Boolean(ttlDays),
|
|
276
|
+
'lorekit.cli.write.clear_ttl': clearTtl,
|
|
213
277
|
};
|
|
214
278
|
}
|