@lorekit/cli 1.28.0 → 1.29.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/src/store/ttl.mjs CHANGED
@@ -11,6 +11,14 @@
11
11
  // `ttl_days` when both are supplied (the RPC's tri-state precedence).
12
12
  // - a read filters an expired row out lazily (there is no purge daemon
13
13
  // offline), exactly as the remote read paths do.
14
+ //
15
+ // `resolveDefaultTtlDays` is the one piece here with NO server counterpart, by
16
+ // design. It answers "what TTL did the user configure for a write that named
17
+ // none?" — a client-side policy question. The server contract is untouched:
18
+ // omitting `ttl_*` on `memory.write` still means the row is permanent, so an
19
+ // agent talking straight to the MCP endpoint is unaffected by a config file it
20
+ // cannot see. That asymmetry is deliberate; moving the default server-side would
21
+ // silently change what "omitted" means for every existing caller.
14
22
 
15
23
  export const TTL_MIN_DAYS = 1;
16
24
  export const TTL_MAX_DAYS = 365;
@@ -55,6 +63,76 @@ export function isLive(entry, now = new Date()) {
55
63
  return !entry.archived_at && !isExpired(entry.expires_at, now);
56
64
  }
57
65
 
66
+ // The DEFAULT TTL for a write that named none, resolved from the config layers
67
+ // (`ttl.default` and `scope.defaults.<prefix>.ttl_days` — see control.mjs).
68
+ //
69
+ // Returns the number of days, or null for "no default; the memory is permanent".
70
+ //
71
+ // Two rules that matter more than they look:
72
+ //
73
+ // 1. LONGEST MATCHING PREFIX WINS, not first-declared. `scope.defaults` is a
74
+ // plain object, so declaration order is whatever the author's editor left
75
+ // behind; a `branch::` entry and a `branch::owner/repo::` entry must resolve
76
+ // deterministically, and the more specific one is the one the author meant.
77
+ // (`tagsHint` UNIONS every match instead — correct there, because tags
78
+ // accumulate and a TTL cannot.)
79
+ // 2. AN EXPLICIT `null` MEANS PERMANENT and outranks `ttl.default`. Without it
80
+ // a repo-wide default could not be switched off for the one scope that
81
+ // holds durable lore, and `"ttl_days": null` is the only honest spelling of
82
+ // "keep this forever" — omitting the key has to keep meaning "inherit".
83
+ //
84
+ // Total by contract: a malformed config (fractional days, a string, out of
85
+ // range, a non-object entry) yields null rather than throwing. A config file is
86
+ // not a caller assertion the way `--ttl-days` is — it is ambient state that must
87
+ // never be able to break an unrelated write, the same posture the hook engine
88
+ // takes toward the host agent.
89
+ export function resolveDefaultTtlDays(scope, { ttlDefault = null, scopeDefaults = null } = {}) {
90
+ if (typeof scope === 'string' && scope && scopeDefaults && typeof scopeDefaults === 'object') {
91
+ let bestPrefix = null;
92
+ let bestValue;
93
+ for (const [prefix, cfg] of Object.entries(scopeDefaults)) {
94
+ if (!cfg || typeof cfg !== 'object' || !('ttl_days' in cfg)) continue;
95
+ if (!matchesScopePrefix(scope, prefix)) continue;
96
+ if (bestPrefix !== null && prefix.length <= bestPrefix.length) continue;
97
+ bestPrefix = prefix;
98
+ bestValue = cfg.ttl_days;
99
+ }
100
+ if (bestPrefix !== null) {
101
+ if (bestValue === null) return null; // explicit "permanent" for this scope
102
+ return safeTtlDays(bestValue);
103
+ }
104
+ }
105
+ return safeTtlDays(ttlDefault);
106
+ }
107
+
108
+ // Whether a write's resolved scope falls under a `scope.defaults` key. An exact
109
+ // match, or a `::`-delimited descendant — so `repo::owner` never captures
110
+ // `repo::owner-other/x`. Shared with the nudge's tags hint so the two cannot
111
+ // disagree about what "this scope is configured" means.
112
+ export function matchesScopePrefix(scope, prefix) {
113
+ if (typeof scope !== 'string' || typeof prefix !== 'string' || !prefix) return false;
114
+ if (scope === prefix) return true;
115
+ return scope.startsWith(prefix.endsWith('::') ? prefix : prefix + '::');
116
+ }
117
+
118
+ // parseTtlDays, but a rejected value degrades to null instead of throwing.
119
+ //
120
+ // The type guard is not redundant with parseTtlDays: that one coerces with
121
+ // Number(), which maps `true` to 1 and `[]` to 0 — fine for a flag the user
122
+ // typed (a CLI flag is always a string), a footgun for a JSON value where `true`
123
+ // is a plausible typo for "yes, expire these" and would silently mean ONE DAY.
124
+ // Only a number or a numeric string is a TTL here.
125
+ function safeTtlDays(value) {
126
+ if (typeof value !== 'number' && !(typeof value === 'string' && value.trim() !== '')) {
127
+ return null;
128
+ }
129
+ try {
130
+ return parseTtlDays(value);
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
58
136
  // Resolve a write's `expires_at` from the tri-state TTL inputs, mirroring
59
137
  // memory_write (00030/00031): `clearTtl` wins (→ permanent, and `ttlDays` is
60
138
  // never even validated); else a supplied `ttlDays` sets expiry from `now`; else
package/src/write.mjs CHANGED
@@ -13,7 +13,8 @@
13
13
  // --tags <a,b,c> Comma-separated tag list (default: no tags)
14
14
  // --source-agent <name> Which agent recorded this lesson (default: none)
15
15
  // --trigger <slug> What prompted the write (default: none)
16
- // --ttl-days <n> Days until the memory auto-expires (1–365)
16
+ // --ttl-days <n> Days until the memory auto-expires (1–365). When
17
+ // omitted, a configured default may apply — see below.
17
18
  // --clear-ttl Remove any existing expiry (make the memory permanent)
18
19
  // --org <slug> Write to this org (remote only)
19
20
  //
@@ -27,6 +28,15 @@
27
28
  // --origin-pr <n> The pull request this lesson came out of
28
29
  // --no-origin Record no provenance at all
29
30
  //
31
+ // Default TTL. A write that passes neither --ttl-days nor --clear-ttl picks up
32
+ // whatever the config layers configured for its scope (`ttl.default` and
33
+ // `scope.defaults.<prefix>.ttl_days`; see control.mjs). Precedence is explicit
34
+ // flag > config > permanent, so a flag is always the last word and --clear-ttl
35
+ // is how you say "permanent" against a repo that defaults to expiring. The
36
+ // resolved source is reported in the confirmation line and on the telemetry
37
+ // span, because a TTL nobody typed is exactly the kind of thing that should
38
+ // never be silent.
39
+ //
30
40
  // Store targeting (default: remote if configured, else local):
31
41
  // --remote Force write to the remote store
32
42
  // --local Force write to the local offline store
@@ -39,12 +49,12 @@
39
49
  // false = updated) when the remote reports it.
40
50
  import process from 'node:process';
41
51
  import { resolveProjectRoot } from './config.mjs';
42
- import { resolveDenies } from './control.mjs';
52
+ import { loadControl, resolveDenies } from './control.mjs';
43
53
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
44
54
  import { log, err, heading, status, c } from './util.mjs';
45
55
  import { parseScopeKey } from './lessons-view.mjs';
46
56
  import { deriveOrigin, mergeOrigin } from './origin.mjs';
47
- import { parseTtlDays } from './store/ttl.mjs';
57
+ import { parseTtlDays, resolveDefaultTtlDays } from './store/ttl.mjs';
48
58
 
49
59
  // Read all of stdin to a string. Resolves to '' when stdin IS a TTY (no pipe).
50
60
  function readStdin() {
@@ -132,6 +142,34 @@ export async function write(args) {
132
142
  }
133
143
  }
134
144
  const clearTtl = Boolean(args['clear-ttl']);
145
+
146
+ // Neither flag given → fall back to the scope's configured default, if any.
147
+ // `--clear-ttl` deliberately suppresses it: "make this permanent" has to mean
148
+ // permanent, not "permanent unless the repo config disagrees". Config is read
149
+ // through the same loadControl the hooks use, so the default the nudge advises
150
+ // and the default this command applies can never diverge.
151
+ let ttlSource = ttlDays ? 'flag' : 'none';
152
+ if (ttlDays === undefined && !clearTtl) {
153
+ const configured = resolveDefaultTtlDays(scope, loadControl(root, { env }));
154
+ if (configured != null) {
155
+ ttlDays = configured;
156
+ ttlSource = 'config';
157
+ }
158
+ }
159
+
160
+ // What gets REPORTED is the outcome, not the input. `--clear-ttl` beats
161
+ // `--ttl-days` inside resolveExpiresAt (and in memory_write, migrations
162
+ // 00030/00031), so `--ttl-days 7 --clear-ttl` persists a permanent row —
163
+ // yet ttlDays/ttlSource still described the flag the user typed, so the
164
+ // human output claimed "expires in 7 days" and --json reported
165
+ // ttl_days 7 / ttl_source "flag" for a row whose expires_at is null.
166
+ // Kept separate from ttlDays on purpose: writeArgs below spreads
167
+ // `...(ttlDays ? { ttl_days } : {})`, and nulling ttlDays itself would
168
+ // silently stop sending ttl_days to the remote RPC — a wire change nobody
169
+ // asked for. The precedence lives in one place; this only mirrors it.
170
+ const reportedTtlDays = clearTtl ? null : (ttlDays ?? null);
171
+ const reportedTtlSource = clearTtl ? 'none' : ttlSource;
172
+
135
173
  const orgSlug = typeof args.org === 'string' ? args.org : undefined;
136
174
 
137
175
  // ── Provenance ────────────────────────────────────────────────────────────
@@ -254,6 +292,8 @@ export async function write(args) {
254
292
  tags,
255
293
  source_agent: sourceAgent || null,
256
294
  trigger: trigger || null,
295
+ ttl_days: reportedTtlDays,
296
+ ttl_source: reportedTtlSource,
257
297
  origin,
258
298
  }, null, 2));
259
299
  } else {
@@ -263,6 +303,13 @@ export async function write(args) {
263
303
  log(` ${c.dim('scope')} ${scope}`);
264
304
  log(` ${c.dim('key')} ${key}`);
265
305
  if (tags.length) log(` ${c.dim('tags')} ${tags.join(', ')}`);
306
+ // Name the source. A TTL the caller typed needs no explanation; one that came
307
+ // from a config file two directories up does, or the first surprise is a
308
+ // memory that quietly vanished.
309
+ if (reportedTtlDays) {
310
+ const suffix = reportedTtlSource === 'config' ? c.dim(' (from config)') : '';
311
+ log(` ${c.dim('expires')} in ${reportedTtlDays} day${reportedTtlDays === 1 ? '' : 's'}${suffix}`);
312
+ }
266
313
  status('pass', verb, `${scope}::${key}`);
267
314
  log('');
268
315
  }
@@ -272,7 +319,8 @@ export async function write(args) {
272
319
  'lorekit.cli.write.store': storeName,
273
320
  'lorekit.cli.write.inserted': inserted,
274
321
  'lorekit.cli.write.has_tags': tags.length > 0,
275
- 'lorekit.cli.write.has_ttl': Boolean(ttlDays),
322
+ 'lorekit.cli.write.has_ttl': Boolean(reportedTtlDays),
323
+ 'lorekit.cli.write.ttl_source': reportedTtlSource,
276
324
  'lorekit.cli.write.clear_ttl': clearTtl,
277
325
  };
278
326
  }