@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/README.md +121 -6
- package/bin/lorekit.mjs +24 -7
- package/package.json +1 -1
- package/src/config.mjs +101 -15
- package/src/control.mjs +55 -2
- package/src/core/lessons.mjs +25 -6
- package/src/doctor.mjs +89 -39
- package/src/install.mjs +268 -25
- package/src/store/remote.mjs +54 -0
- package/src/store/ttl.mjs +78 -0
- package/src/write.mjs +52 -4
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(
|
|
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
|
}
|