@lorekit/cli 1.26.0 → 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 CHANGED
@@ -288,7 +288,8 @@ ${c.bold('Options')}
288
288
  --tags <a,b,c> Comma-separated tags (default: none)
289
289
  --source-agent <n> Source agent name to record (default: none)
290
290
  --trigger <slug> Trigger context slug (default: none)
291
- --ttl-days <n> Days until auto-expiry 1–365 (remote only)
291
+ --ttl-days <n> Days until auto-expiry 1–365 (local or remote)
292
+ --clear-ttl Remove any existing expiry (make it permanent)
292
293
  --org <slug> Write to this org's scope (remote only)
293
294
  --origin-repo <o/n> Override the derived provenance repository
294
295
  --origin-branch <b> Override the derived provenance branch
@@ -574,7 +575,7 @@ const KNOWN_FLAGS = [
574
575
  'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
575
576
  'from', 'to', 'apply', 'yes', 'no-hooks', 'force', 'deep', 'adapter',
576
577
  'event', 'json', 'scope', 'threshold', 'help', 'version',
577
- 'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'org', 'remote', 'local',
578
+ 'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
578
579
  'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
579
580
  'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
580
581
  ];
@@ -601,7 +602,7 @@ async function main() {
601
602
  const argv = process.argv.slice(2);
602
603
  const args = parseArgs(argv, {
603
604
  aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
604
- booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'no-origin', 'json', 'remote', 'local', 'link', 'archived'],
605
+ booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl'],
605
606
  known: KNOWN_FLAGS,
606
607
  });
607
608
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.26.0",
3
+ "version": "1.26.1",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -56,6 +56,18 @@ export const MEMORY_TOOL_DEFS = [
56
56
  description:
57
57
  'Optional ISO 8601 creation date for migrating a pre-existing memory. Rejected if invalid or in the future. Applies only when the memory is first created.',
58
58
  },
59
+ ttl_days: {
60
+ type: 'integer',
61
+ minimum: 1,
62
+ maximum: 365,
63
+ description:
64
+ 'Optional time-to-live in days (1–365). The memory auto-expires that many days after this write and is then hidden from reads.',
65
+ },
66
+ clear_ttl: {
67
+ type: 'boolean',
68
+ description:
69
+ 'Remove any existing expiry, making the memory permanent again. Takes precedence over ttl_days when both are supplied.',
70
+ },
59
71
  origin_repo: {
60
72
  type: 'string',
61
73
  description:
@@ -24,6 +24,10 @@ export const FIELDS = [
24
24
  'origin_branch',
25
25
  'origin_commit',
26
26
  'origin_pr',
27
+ // Expiry — the absolute ISO instant this lesson auto-expires (see src/store/
28
+ // ttl.mjs). Null / absent means it never expires. Appended like the origin
29
+ // columns: a file written before this existed simply decodes it as absent.
30
+ 'expires_at',
27
31
  ];
28
32
 
29
33
  // Serialize an entry ({ ...columns, value }) into file text.
@@ -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) => !e.archived_at);
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 archived.
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
- const entry = found && !found.entry.archived_at ? found.entry : null;
81
- return { ok: true, entry };
82
+ return { ok: true, entry: found && isLive(found.entry) ? found.entry : null };
82
83
  }
83
84
 
84
85
  // write(...) → { ok, entry } — upsert by scope+key. Preserves `created` and
@@ -92,19 +93,22 @@ class LocalStore {
92
93
  // an invalid or future-dated value rather than throwing, matching the store
93
94
  // contract's error surfacing.
94
95
  async write({
95
- scope, key, value, tags, source_agent, trigger, created_at,
96
+ scope, key, value, tags, source_agent, trigger, created_at, ttl_days, clear_ttl,
96
97
  origin_repo, origin_branch, origin_commit, origin_pr,
97
98
  } = {}) {
98
- let override;
99
+ const now = new Date().toISOString();
100
+ const existing = this._findByKey(scope, key);
101
+ let override, expires_at;
99
102
  try {
100
103
  override = normalizeCreatedAt(created_at);
104
+ expires_at = resolveExpiresAt({
105
+ clearTtl: clear_ttl, ttlDays: ttl_days, now, current: existing?.entry.expires_at,
106
+ });
101
107
  } catch (e) {
102
108
  return { ok: false, error: e.message };
103
109
  }
104
110
  const dir = this._dir(scope);
105
111
  fs.mkdirSync(dir, { recursive: true });
106
- const now = new Date().toISOString();
107
- const existing = this._findByKey(scope, key);
108
112
  const created = existing ? existing.entry.created || now : override || now;
109
113
  const entry = {
110
114
  scope,
@@ -122,6 +126,7 @@ class LocalStore {
122
126
  created,
123
127
  updated: existing ? now : override || now,
124
128
  archived_at: null,
129
+ expires_at,
125
130
  value: value == null ? '' : String(value),
126
131
  };
127
132
  const file = existing ? existing.file : this._freshPath(dir, key);
@@ -152,6 +157,7 @@ class LocalStore {
152
157
  created: entry.created ?? now,
153
158
  updated: entry.updated ?? now,
154
159
  archived_at: entry.archived_at ?? null,
160
+ expires_at: entry.expires_at ?? null,
155
161
  value: entry.value == null ? '' : String(entry.value),
156
162
  };
157
163
  const file = existing ? existing.file : this._freshPath(dir, entry.key);
@@ -258,9 +264,10 @@ class LocalStore {
258
264
  // lossy for `project::{name}` (stored by basename only). Returns
259
265
  // `[{ scope, count }]`, unsorted.
260
266
  async listScopes() {
267
+ const now = new Date();
261
268
  const counts = new Map();
262
269
  for (const { entry } of this._walkEntries()) {
263
- if (entry.archived_at || !entry.scope) continue;
270
+ if (!entry.scope || !isLive(entry, now)) continue;
264
271
  counts.set(entry.scope, (counts.get(entry.scope) || 0) + 1);
265
272
  }
266
273
  return [...counts.entries()].map(([scope, count]) => ({ scope, count }));
@@ -392,12 +399,13 @@ class TwoTierStore {
392
399
  // a lesson present in both tiers is counted once — project shadows home, the
393
400
  // same first-wins merge `list()` uses. Returns `[{ scope, count }]`, unsorted.
394
401
  async listScopes() {
402
+ const now = new Date();
395
403
  const seen = new Set(); // `${scope}\x00${key}` — dedup across tiers
396
404
  const counts = new Map();
397
405
  const tiers = this.projectActive() ? [this.project, this.home] : [this.home];
398
406
  for (const tier of tiers) {
399
407
  for (const { entry } of tier._walkEntries()) {
400
- if (entry.archived_at || !entry.scope) continue;
408
+ if (!entry.scope || !isLive(entry, now)) continue;
401
409
  const id = `${entry.scope}\x00${entry.key ?? ''}`;
402
410
  if (seen.has(id)) continue;
403
411
  seen.add(id);
@@ -0,0 +1,69 @@
1
+ // Zero-dependency mirror of the TTL (time-to-live) contract used by the hosted
2
+ // MCP server (packages/mcp-core/src/ttl.ts) and the `memory_write` RPC
3
+ // (migrations 00030/00031). Keeps the local file store's expiry semantics
4
+ // identical to the remote one, so a memory written offline expires the same way
5
+ // it would have online — and a local↔remote migration is lossless.
6
+ //
7
+ // - `ttl_days` (1–365) sets `expires_at = <write instant> + N days`, mirroring
8
+ // the RPC's `now() + interval` — NOT `created + N`, so a backdated migration
9
+ // (created_at override) still expires relative to when it was written.
10
+ // - `clear_ttl` removes the expiry, making the row permanent again; it beats
11
+ // `ttl_days` when both are supplied (the RPC's tri-state precedence).
12
+ // - a read filters an expired row out lazily (there is no purge daemon
13
+ // offline), exactly as the remote read paths do.
14
+
15
+ export const TTL_MIN_DAYS = 1;
16
+ export const TTL_MAX_DAYS = 365;
17
+ const DAY_MS = 24 * 60 * 60 * 1000;
18
+
19
+ // Validate and normalise an optional `ttl_days` write parameter.
20
+ // Returns the integer number of days, or null when no TTL was supplied.
21
+ // Throws Error on a present-but-invalid value (fractional, out of range, NaN).
22
+ export function parseTtlDays(input) {
23
+ if (input === undefined || input === null) return null;
24
+ const n = typeof input === 'number' ? input : Number(input);
25
+ if (!Number.isFinite(n)) throw new Error('ttl_days must be a finite number');
26
+ if (!Number.isInteger(n)) throw new Error('ttl_days must be an integer');
27
+ if (n < TTL_MIN_DAYS) throw new Error(`ttl_days must be >= ${TTL_MIN_DAYS}`);
28
+ if (n > TTL_MAX_DAYS) throw new Error(`ttl_days must be <= ${TTL_MAX_DAYS}`);
29
+ return n;
30
+ }
31
+
32
+ // The absolute ISO expiry instant for a memory: `from` (ISO string or Date)
33
+ // advanced by `ttlDays` whole days.
34
+ export function expiresAtFrom(ttlDays, from) {
35
+ const base = from instanceof Date ? from.getTime() : Date.parse(from);
36
+ return new Date(base + ttlDays * DAY_MS).toISOString();
37
+ }
38
+
39
+ // Whether a stored `expires_at` has elapsed at `now`. Absent → never expires.
40
+ // An unparseable value fails SAFE (treated as never-expiring) so a corrupt or
41
+ // hand-edited frontmatter field can never hide a lesson from every read.
42
+ export function isExpired(expiresAt, now = new Date()) {
43
+ if (!expiresAt) return false;
44
+ const ms = Date.parse(expiresAt);
45
+ if (Number.isNaN(ms)) return false;
46
+ return ms <= now.getTime();
47
+ }
48
+
49
+ // Whether a stored entry is currently visible to reads: not archived and not
50
+ // expired. The SINGLE definition of "live", shared by every read path (list /
51
+ // read / listScopes) so a future hidden dimension is added once, never
52
+ // re-spelled per call site. The raw primitives (getEntry / _findByKey for
53
+ // delete / archive) deliberately bypass this so they can still act on hidden rows.
54
+ export function isLive(entry, now = new Date()) {
55
+ return !entry.archived_at && !isExpired(entry.expires_at, now);
56
+ }
57
+
58
+ // Resolve a write's `expires_at` from the tri-state TTL inputs, mirroring
59
+ // memory_write (00030/00031): `clearTtl` wins (→ permanent, and `ttlDays` is
60
+ // never even validated); else a supplied `ttlDays` sets expiry from `now`; else
61
+ // the row keeps whatever `current` expiry it already had. Throws (via
62
+ // parseTtlDays) on an invalid `ttlDays` only when NOT clearing, so the caller
63
+ // can surface `{ ok:false }`.
64
+ export function resolveExpiresAt({ clearTtl, ttlDays, now, current } = {}) {
65
+ if (clearTtl) return null;
66
+ const days = parseTtlDays(ttlDays);
67
+ if (days != null) return expiresAtFrom(days, now);
68
+ return current ?? null;
69
+ }
package/src/write.mjs CHANGED
@@ -14,6 +14,7 @@
14
14
  // --source-agent <name> Which agent recorded this lesson (default: none)
15
15
  // --trigger <slug> What prompted the write (default: none)
16
16
  // --ttl-days <n> Days until the memory auto-expires (1–365)
17
+ // --clear-ttl Remove any existing expiry (make the memory permanent)
17
18
  // --org <slug> Write to this org (remote only)
18
19
  //
19
20
  // Provenance — where the lesson is being recorded FROM. Derived automatically
@@ -43,6 +44,7 @@ import { resolveStores, remoteUnavailableReason } from './stores.mjs';
43
44
  import { log, err, heading, status, c } from './util.mjs';
44
45
  import { parseScopeKey } from './lessons-view.mjs';
45
46
  import { deriveOrigin, mergeOrigin } from './origin.mjs';
47
+ import { parseTtlDays } from './store/ttl.mjs';
46
48
 
47
49
  // Read all of stdin to a string. Resolves to '' when stdin IS a TTY (no pipe).
48
50
  function readStdin() {
@@ -108,7 +110,28 @@ export async function write(args) {
108
110
  const tags = args.tags ? String(args.tags).split(',').map((t) => t.trim()).filter(Boolean) : [];
109
111
  const sourceAgent = typeof args['source-agent'] === 'string' ? args['source-agent'] : undefined;
110
112
  const trigger = typeof args.trigger === 'string' ? args.trigger : undefined;
111
- const ttlDays = args['ttl-days'] ? Number(args['ttl-days']) : undefined;
113
+ // `--ttl-days` is validated HERE, at the flag seam, rather than being left to the
114
+ // store: a truthiness test silently swallowed `--ttl-days 0` (falsy) and
115
+ // `--ttl-days abc` (NaN, dropped again by the `ttl_days` spread further down), so
116
+ // both exited 0 having written no expiry while `--ttl-days 999` correctly errored.
117
+ // The seam matters as much as the check — `store/remote.mjs` forwards `ttl_days`
118
+ // verbatim and `JSON.stringify(NaN)` would reach the server as `null`, so a
119
+ // store-side fix would leave the remote path silently broken. Mirrors how
120
+ // `--origin-pr` is handled below: an explicitly supplied value is a caller
121
+ // assertion, so a malformed one is a usage error.
122
+ let ttlDays;
123
+ if (args['ttl-days'] !== undefined) {
124
+ // A bare `--ttl-days` with no value parses as boolean `true` (see parseArgs);
125
+ // feed NaN so the shared validator rejects it instead of silently meaning 1 day.
126
+ const rawTtlDays = args['ttl-days'] === true ? NaN : args['ttl-days'];
127
+ try {
128
+ ttlDays = parseTtlDays(rawTtlDays);
129
+ } catch (e) {
130
+ err(`${c.red('Error:')} --ttl-days is invalid — ${(e && e.message) || String(e)}`);
131
+ return 1;
132
+ }
133
+ }
134
+ const clearTtl = Boolean(args['clear-ttl']);
112
135
  const orgSlug = typeof args.org === 'string' ? args.org : undefined;
113
136
 
114
137
  // ── Provenance ────────────────────────────────────────────────────────────
@@ -198,6 +221,7 @@ export async function write(args) {
198
221
  ...(sourceAgent ? { source_agent: sourceAgent } : {}),
199
222
  ...(trigger ? { trigger } : {}),
200
223
  ...(ttlDays ? { ttl_days: ttlDays } : {}),
224
+ ...(clearTtl ? { clear_ttl: true } : {}),
201
225
  ...(orgSlug ? { org: orgSlug } : {}),
202
226
  ...origin,
203
227
  };
@@ -249,5 +273,6 @@ export async function write(args) {
249
273
  'lorekit.cli.write.inserted': inserted,
250
274
  'lorekit.cli.write.has_tags': tags.length > 0,
251
275
  'lorekit.cli.write.has_ttl': Boolean(ttlDays),
276
+ 'lorekit.cli.write.clear_ttl': clearTtl,
252
277
  };
253
278
  }