@lorekit/cli 1.57.0 → 1.59.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/bin/lorekit.mjs +119 -1
- package/package.json +1 -1
- package/src/commands/groom.mjs +149 -0
- package/src/commands/policy.mjs +185 -0
- package/src/commands/protect.mjs +73 -0
- package/src/commands.mjs +8 -0
- package/src/shared/completions.mjs +25 -0
- package/src/shared/flags.mjs +16 -0
- package/src/shared/mcp.mjs +104 -6
- package/src/shared/mirror-pairs.mjs +12 -0
- package/src/store/remote.mjs +64 -0
- package/src/surfaces.generated.mjs +250 -5
package/bin/lorekit.mjs
CHANGED
|
@@ -97,6 +97,18 @@ ${c.bold('Commands')}
|
|
|
97
97
|
Permanently delete every TTL-EXPIRED memory. Same posture as
|
|
98
98
|
purge: remote only, account-wide, irreversible, --yes required
|
|
99
99
|
non-interactively. Takes no options.
|
|
100
|
+
groom Preview (default) or --run a retention sweep: --policy-id <id> or
|
|
101
|
+
--scope <s> [+ --min-age-days/--unseen-days/--max-seen-count].
|
|
102
|
+
Remote only. --run soft-archives matches (recoverable via
|
|
103
|
+
restore); prompts for confirmation, --yes to skip. --json.
|
|
104
|
+
policy Manage saved retention rules: list / create / update / delete.
|
|
105
|
+
policy create --scope <s> --name <n> [--mode review|auto]
|
|
106
|
+
[--enabled] [conditions...]. policy update <id> [fields...].
|
|
107
|
+
policy delete <id> [--yes]. Remote only. --json.
|
|
108
|
+
protect Mark a memory protected — excluded from every grooming sweep
|
|
109
|
+
regardless of policy. protect <scope::key> [--off] to unprotect.
|
|
110
|
+
Remote only. --json.
|
|
111
|
+
pin / unpin Shorthand for protect / protect --off. Remote only. --json.
|
|
100
112
|
bootstrap Apply the BYOD schema to a user-supplied Supabase database.
|
|
101
113
|
Only needed when using LOREKIT_STORAGE_URL / LOREKIT_STORAGE_ANON_KEY.
|
|
102
114
|
See docs/byod.md for setup instructions.
|
|
@@ -843,6 +855,108 @@ ${c.bold('Options')}
|
|
|
843
855
|
-t, --token <token> LoreKit token (needs write permission, unscoped)
|
|
844
856
|
`,
|
|
845
857
|
|
|
858
|
+
groom: `${c.bold('lorekit groom')} — preview or run a retention sweep
|
|
859
|
+
|
|
860
|
+
${c.bold('Usage')}
|
|
861
|
+
lorekit groom --policy-id <id> [--run] [--yes] [--json]
|
|
862
|
+
lorekit groom --scope <s> [--min-age-days <n>] [--unseen-days <n>] [--max-seen-count <n>] [--run] [--yes] [--json]
|
|
863
|
+
|
|
864
|
+
Resolves the SAME candidates a saved policy or an inline condition set would
|
|
865
|
+
catch, via the retention-policy candidate query — a previewed count always
|
|
866
|
+
equals what --run would archive. Exactly one of --policy-id or --scope is
|
|
867
|
+
required.
|
|
868
|
+
|
|
869
|
+
Default (no --run) PREVIEWS: prints the count and up to 20 matching keys,
|
|
870
|
+
changes nothing. --run ARCHIVES them (soft-archive, recoverable via
|
|
871
|
+
${c.cyan('lorekit restore')}) — prompts for confirmation first, ${c.cyan('--yes')} to skip
|
|
872
|
+
non-interactively.
|
|
873
|
+
|
|
874
|
+
Remote only — retention policies have no local-store equivalent.
|
|
875
|
+
|
|
876
|
+
${c.bold('Options')}
|
|
877
|
+
--policy-id <id> Run/preview a saved policy (mutually exclusive with --scope)
|
|
878
|
+
--scope <s> Inline scope to match (mutually exclusive with --policy-id)
|
|
879
|
+
--min-age-days <n> Match only lessons at least n days old
|
|
880
|
+
--unseen-days <n> Match lessons unseen for at least n days (never-seen always matches)
|
|
881
|
+
--max-seen-count <n> Match only lessons that recurred at most n times
|
|
882
|
+
--run Archive the matches instead of previewing
|
|
883
|
+
-y, --yes Confirm --run; required when non-interactive
|
|
884
|
+
--json Machine-readable result
|
|
885
|
+
-e, --endpoint <url> LoreKit endpoint (else LOREKIT_MCP_URL)
|
|
886
|
+
-t, --token <token> LoreKit token (needs write permission for --run)
|
|
887
|
+
`,
|
|
888
|
+
|
|
889
|
+
policy: `${c.bold('lorekit policy')} — manage saved retention rules
|
|
890
|
+
|
|
891
|
+
${c.bold('Usage')}
|
|
892
|
+
lorekit policy list [--json]
|
|
893
|
+
lorekit policy create --scope <s> --name <n> [--mode review|auto] [--enabled]
|
|
894
|
+
[--min-age-days <n>] [--unseen-days <n>] [--max-seen-count <n>]
|
|
895
|
+
lorekit policy update <id> [--name <n>] [--mode review|auto] [--enabled|--disabled]
|
|
896
|
+
[--min-age-days <n>|--clear-min-age-days] [...] [--json]
|
|
897
|
+
lorekit policy delete <id> [--yes] [--json]
|
|
898
|
+
|
|
899
|
+
A policy is a saved retention rule: a scope plus AND-ed conditions
|
|
900
|
+
(min-age-days / unseen-days / max-seen-count). \`mode: review\` surfaces it for
|
|
901
|
+
you to run by hand with ${c.cyan('lorekit groom --policy-id')}; \`mode: auto\` gets swept
|
|
902
|
+
nightly, but ONLY once you also pass --enabled — auto starts disabled on
|
|
903
|
+
every new policy so a saved rule never archives anything unattended.
|
|
904
|
+
|
|
905
|
+
Remote only — retention_policies has no local-store equivalent.
|
|
906
|
+
|
|
907
|
+
${c.bold('Options')}
|
|
908
|
+
--scope <s> Scope the policy matches (create)
|
|
909
|
+
--name <n> Policy name (create) / new name (update)
|
|
910
|
+
--mode <review|auto> Match mode (create/update)
|
|
911
|
+
--enabled / --disabled Turn auto-mode on/off (create/update)
|
|
912
|
+
--min-age-days <n>, --unseen-days <n>, --max-seen-count <n>
|
|
913
|
+
Conditions (create/update)
|
|
914
|
+
--clear-min-age-days, --clear-unseen-days, --clear-max-seen-count
|
|
915
|
+
Remove a condition (update only)
|
|
916
|
+
-y, --yes Confirm delete; required when non-interactive
|
|
917
|
+
--json Machine-readable result
|
|
918
|
+
-e, --endpoint <url> LoreKit endpoint (else LOREKIT_MCP_URL)
|
|
919
|
+
-t, --token <token> LoreKit token (needs write permission for create/update/delete)
|
|
920
|
+
`,
|
|
921
|
+
|
|
922
|
+
protect: `${c.bold('lorekit protect')} — exclude a memory from every grooming sweep
|
|
923
|
+
|
|
924
|
+
${c.bold('Usage')}
|
|
925
|
+
lorekit protect <scope::key> [--off] [--json]
|
|
926
|
+
lorekit protect <scope> <key> [--off] [--json]
|
|
927
|
+
|
|
928
|
+
Marks (or, with --off, unmarks) a lesson as protected: excluded from
|
|
929
|
+
${c.cyan('lorekit groom')} and every retention policy's candidate set, regardless of
|
|
930
|
+
which policy would otherwise have matched it. See also ${c.cyan('lorekit pin')} /
|
|
931
|
+
${c.cyan('lorekit unpin')}, the same operation under shorter names.
|
|
932
|
+
|
|
933
|
+
Remote only.
|
|
934
|
+
|
|
935
|
+
${c.bold('Options')}
|
|
936
|
+
--off Unprotect instead of protect
|
|
937
|
+
--scope <scope> Name the scope explicitly, overriding the positional
|
|
938
|
+
--key <key> Name the key explicitly
|
|
939
|
+
--json Machine-readable result
|
|
940
|
+
-e, --endpoint <url> LoreKit endpoint (else LOREKIT_MCP_URL)
|
|
941
|
+
-t, --token <token> LoreKit token (needs write permission)
|
|
942
|
+
`,
|
|
943
|
+
|
|
944
|
+
pin: `${c.bold('lorekit pin')} — shorthand for \`lorekit protect\`
|
|
945
|
+
|
|
946
|
+
${c.bold('Usage')}
|
|
947
|
+
lorekit pin <scope::key> [--json]
|
|
948
|
+
|
|
949
|
+
Identical to \`lorekit protect <scope::key>\`. See ${c.cyan('lorekit protect --help')}.
|
|
950
|
+
`,
|
|
951
|
+
|
|
952
|
+
unpin: `${c.bold('lorekit unpin')} — shorthand for \`lorekit protect --off\`
|
|
953
|
+
|
|
954
|
+
${c.bold('Usage')}
|
|
955
|
+
lorekit unpin <scope::key> [--json]
|
|
956
|
+
|
|
957
|
+
Identical to \`lorekit protect <scope::key> --off\`. See ${c.cyan('lorekit protect --help')}.
|
|
958
|
+
`,
|
|
959
|
+
|
|
846
960
|
hook: `${c.bold('lorekit hook')} — hook engine for Claude Code / Cursor / Codex
|
|
847
961
|
|
|
848
962
|
${c.bold('Usage')}
|
|
@@ -914,6 +1028,10 @@ const KNOWN_FLAGS = [
|
|
|
914
1028
|
'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
|
|
915
1029
|
// Scale-aware survey flags
|
|
916
1030
|
'all', 'max', 'since', 'until', 'key-prefix', 'cluster-by-key',
|
|
1031
|
+
// groom / policy / protect / pin / unpin
|
|
1032
|
+
'policy-id', 'min-age-days', 'unseen-days', 'max-seen-count', 'run',
|
|
1033
|
+
'name', 'mode', 'enabled', 'disabled',
|
|
1034
|
+
'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'off',
|
|
917
1035
|
// `obligations`
|
|
918
1036
|
'files', 'strict',
|
|
919
1037
|
];
|
|
@@ -928,7 +1046,7 @@ async function main() {
|
|
|
928
1046
|
const argv = process.argv.slice(2);
|
|
929
1047
|
const args = parseArgs(argv, {
|
|
930
1048
|
aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
|
|
931
|
-
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all', 'strict'],
|
|
1049
|
+
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all', 'run', 'enabled', 'disabled', 'off', 'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'strict'],
|
|
932
1050
|
known: KNOWN_FLAGS,
|
|
933
1051
|
});
|
|
934
1052
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// `lorekit groom` — preview or run a retention sweep.
|
|
2
|
+
//
|
|
3
|
+
// groom [--policy-id ID | --scope SCOPE] [conditions...] [--dry-run] preview candidates (default)
|
|
4
|
+
// groom [--policy-id ID | --scope SCOPE] [conditions...] --run [--yes] archive them
|
|
5
|
+
//
|
|
6
|
+
// Server-side only — retention policies and grooming have no local-store
|
|
7
|
+
// equivalent (matching purge/purge-expired), so this command is REMOTE ONLY.
|
|
8
|
+
// `--policy-id` reuses a saved rule; `--scope` + conditions build one inline.
|
|
9
|
+
// Exactly one of the two is required.
|
|
10
|
+
import { resolveProjectRoot } from '../shared/config.mjs';
|
|
11
|
+
import { loadControl, resolveDenies } from '../shared/control.mjs';
|
|
12
|
+
import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs';
|
|
13
|
+
import { log, err, c, select } from '../shared/util.mjs';
|
|
14
|
+
import { parseIntFlag } from '../shared/flags.mjs';
|
|
15
|
+
|
|
16
|
+
/** Resolve the groom.preview/groom.run request from CLI args. */
|
|
17
|
+
export function parseGroomRequest(args) {
|
|
18
|
+
if (args['policy-id'] && args.scope) {
|
|
19
|
+
return { error: '--policy-id and --scope are mutually exclusive — pass one or the other' };
|
|
20
|
+
}
|
|
21
|
+
if (!args['policy-id'] && !args.scope) {
|
|
22
|
+
return { error: 'either --policy-id or --scope is required' };
|
|
23
|
+
}
|
|
24
|
+
if (args['policy-id']) return { request: { policy_id: args['policy-id'] } };
|
|
25
|
+
|
|
26
|
+
const minAge = parseIntFlag(args['min-age-days'], 'min-age-days');
|
|
27
|
+
if (minAge.error) return { error: minAge.error };
|
|
28
|
+
const unseen = parseIntFlag(args['unseen-days'], 'unseen-days');
|
|
29
|
+
if (unseen.error) return { error: unseen.error };
|
|
30
|
+
const maxSeen = parseIntFlag(args['max-seen-count'], 'max-seen-count');
|
|
31
|
+
if (maxSeen.error) return { error: maxSeen.error };
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
request: {
|
|
35
|
+
scope: args.scope,
|
|
36
|
+
min_age_days: minAge.value,
|
|
37
|
+
unseen_days: unseen.value,
|
|
38
|
+
max_seen_count: maxSeen.value,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function pickRemote({ root, env, args }) {
|
|
44
|
+
const { remoteDenied } = resolveDenies(root, { env });
|
|
45
|
+
const { remote, connection } = resolveStores(root, { env, endpoint: args.endpoint, token: args.token });
|
|
46
|
+
if (remoteDenied) return { error: `remote store is disabled by deny constraint (${remoteDenied.source})` };
|
|
47
|
+
if (!remote.usable()) return { error: `remote store is not configured — ${remoteUnavailableReason(connection)}` };
|
|
48
|
+
return { store: remote };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function groom(args) {
|
|
52
|
+
const root = resolveProjectRoot(args.dir);
|
|
53
|
+
const env = process.env;
|
|
54
|
+
loadControl(root, { env });
|
|
55
|
+
|
|
56
|
+
const parsed = parseGroomRequest(args);
|
|
57
|
+
if (parsed.error) {
|
|
58
|
+
err(`${c.red('Error:')} ${parsed.error}`);
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const picked = pickRemote({ root, env, args });
|
|
63
|
+
if (picked.error) {
|
|
64
|
+
err(`${c.red('Error:')} ${picked.error}`);
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const run = Boolean(args.run);
|
|
69
|
+
|
|
70
|
+
// Preview is the default and is never gated — it changes nothing.
|
|
71
|
+
if (!run) {
|
|
72
|
+
const res = await picked.store.groomPreview(parsed.request);
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const msg = res.error?.message ?? res.error ?? res.networkError ?? 'the server rejected the request';
|
|
75
|
+
if (args.json) {
|
|
76
|
+
log(JSON.stringify({ ok: false, op: 'preview', count: null, keys: [], error: String(msg) }, null, 2));
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
err(`${c.red('Error:')} ${msg}`);
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
if (args.json) {
|
|
83
|
+
log(JSON.stringify({ ok: true, op: 'preview', count: res.count, keys: res.keys }, null, 2));
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
log(`${c.cyan(String(res.count))} lesson${res.count === 1 ? '' : 's'} would be archived:`);
|
|
87
|
+
for (const k of res.keys.slice(0, 20)) log(` ${c.dim(k.scope)}::${k.key}`);
|
|
88
|
+
if (res.keys.length > 20) log(c.dim(` … and ${res.keys.length - 20} more`));
|
|
89
|
+
log(c.dim('Re-run with --run to archive them.'));
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --run archives — a real, if recoverable (soft-archive), mutation. Gate it
|
|
94
|
+
// the same way as any lorekit action that changes state unattended: a
|
|
95
|
+
// preview count first, then confirm-or-`--yes`.
|
|
96
|
+
const preview = await picked.store.groomPreview(parsed.request);
|
|
97
|
+
if (!preview.ok) {
|
|
98
|
+
const msg = preview.error?.message ?? preview.error ?? preview.networkError ?? 'the server rejected the request';
|
|
99
|
+
err(`${c.red('Error:')} ${msg}`);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (preview.count === 0) {
|
|
104
|
+
if (args.json) {
|
|
105
|
+
log(JSON.stringify({ ok: true, op: 'run', archived: 0, keys: [] }, null, 2));
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
log(`${c.dim('Nothing to archive — 0 lessons match.')}`);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const decision = args.yes ? 'proceed' : (args.json || !process.stdin.isTTY) ? 'refuse' : 'prompt';
|
|
113
|
+
if (decision === 'refuse') {
|
|
114
|
+
err(`${c.red('Refusing:')} would archive ${preview.count} lesson${preview.count === 1 ? '' : 's'} with no terminal to confirm.`);
|
|
115
|
+
err(`Re-run with ${c.cyan('--yes')} to confirm.`);
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
if (decision === 'prompt') {
|
|
119
|
+
const go = await select(
|
|
120
|
+
`${c.bold('Archive')} ${preview.count} lesson${preview.count === 1 ? '' : 's'}? Recoverable via restore.`,
|
|
121
|
+
[
|
|
122
|
+
{ value: false, label: 'Cancel', hint: 'nothing is archived' },
|
|
123
|
+
{ value: true, label: 'Yes, archive', hint: 'reversible' },
|
|
124
|
+
],
|
|
125
|
+
{ defaultIndex: 0 },
|
|
126
|
+
);
|
|
127
|
+
if (!go) {
|
|
128
|
+
log(`${c.dim('Cancelled — nothing was archived.')}`);
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const res = await picked.store.groomRun(parsed.request);
|
|
134
|
+
if (!res.ok) {
|
|
135
|
+
const msg = res.error?.message ?? res.error ?? res.networkError ?? 'the server rejected the request';
|
|
136
|
+
if (args.json) {
|
|
137
|
+
log(JSON.stringify({ ok: false, op: 'run', archived: null, keys: [], error: String(msg) }, null, 2));
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
err(`${c.red('Error:')} ${msg}`);
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
if (args.json) {
|
|
144
|
+
log(JSON.stringify({ ok: true, op: 'run', archived: res.archived, keys: res.keys }, null, 2));
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
log(`${c.green('✓')} archived ${c.cyan(String(res.archived))} lesson${res.archived === 1 ? '' : 's'} ${c.dim('(remote)')}`);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// `lorekit policy <list|create|update|delete>` — manage saved retention rules.
|
|
2
|
+
//
|
|
3
|
+
// policy list list every saved policy
|
|
4
|
+
// policy create --scope S --name N [conditions...] save a new rule
|
|
5
|
+
// policy update <id> [fields...] change a saved rule
|
|
6
|
+
// policy delete <id> [--yes] delete the RULE only
|
|
7
|
+
//
|
|
8
|
+
// Server-side only — retention_policies has no local-store equivalent
|
|
9
|
+
// (matching groom/purge). `policy.create`/`update`/`delete` are catalog
|
|
10
|
+
// `cliExempt` (they are actions of THIS command, not their own subcommands),
|
|
11
|
+
// so only `policy.list` claims the `tool: 'memory.policy_list'`... no —
|
|
12
|
+
// `policy.list` claims `tool: 'policy.list'` in the registry; the other three
|
|
13
|
+
// verbs are dispatched here without a catalog tool binding of their own.
|
|
14
|
+
import { resolveProjectRoot } from '../shared/config.mjs';
|
|
15
|
+
import { loadControl, resolveDenies } from '../shared/control.mjs';
|
|
16
|
+
import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs';
|
|
17
|
+
import { log, err, c, select } from '../shared/util.mjs';
|
|
18
|
+
import { parseIntFlag } from '../shared/flags.mjs';
|
|
19
|
+
|
|
20
|
+
function pickRemote({ root, env, args }) {
|
|
21
|
+
const { remoteDenied } = resolveDenies(root, { env });
|
|
22
|
+
const { remote, connection } = resolveStores(root, { env, endpoint: args.endpoint, token: args.token });
|
|
23
|
+
if (remoteDenied) return { error: `remote store is disabled by deny constraint (${remoteDenied.source})` };
|
|
24
|
+
if (!remote.usable()) return { error: `remote store is not configured — ${remoteUnavailableReason(connection)}` };
|
|
25
|
+
return { store: remote };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatPolicy(p) {
|
|
29
|
+
const conditions = [];
|
|
30
|
+
if (p.min_age_days != null) conditions.push(`min_age_days=${p.min_age_days}`);
|
|
31
|
+
if (p.unseen_days != null) conditions.push(`unseen_days=${p.unseen_days}`);
|
|
32
|
+
if (p.max_seen_count != null) conditions.push(`max_seen_count=${p.max_seen_count}`);
|
|
33
|
+
const mode = p.mode === 'auto' ? (p.enabled ? c.green('auto (enabled)') : c.dim('auto (disabled)')) : c.dim('review');
|
|
34
|
+
return `${c.cyan(p.id)} ${c.bold(p.name)} ${c.dim(p.scope)} ${mode}${conditions.length ? ` ${c.dim(conditions.join(', '))}` : ''}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function list(args, store) {
|
|
38
|
+
const res = await store.policyList();
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
const msg = res.error?.message ?? res.error ?? res.networkError ?? 'the server rejected the request';
|
|
41
|
+
if (args.json) { log(JSON.stringify({ ok: false, entries: [], error: String(msg) }, null, 2)); return 1; }
|
|
42
|
+
err(`${c.red('Error:')} ${msg}`);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
if (args.json) { log(JSON.stringify({ ok: true, entries: res.entries }, null, 2)); return 0; }
|
|
46
|
+
if (res.entries.length === 0) { log(c.dim('No retention policies yet. Create one with `lorekit policy create`.')); return 0; }
|
|
47
|
+
for (const p of res.entries) log(formatPolicy(p));
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function create(args, store) {
|
|
52
|
+
if (!args.scope || !args.name) {
|
|
53
|
+
err(`${c.red('Usage:')} lorekit policy create --scope <scope> --name <name> [--mode review|auto] [--enabled] [--min-age-days N] [--unseen-days N] [--max-seen-count N]`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
const minAge = parseIntFlag(args['min-age-days'], 'min-age-days');
|
|
57
|
+
if (minAge.error) { err(`${c.red('Error:')} ${minAge.error}`); return 1; }
|
|
58
|
+
const unseen = parseIntFlag(args['unseen-days'], 'unseen-days');
|
|
59
|
+
if (unseen.error) { err(`${c.red('Error:')} ${unseen.error}`); return 1; }
|
|
60
|
+
const maxSeen = parseIntFlag(args['max-seen-count'], 'max-seen-count');
|
|
61
|
+
if (maxSeen.error) { err(`${c.red('Error:')} ${maxSeen.error}`); return 1; }
|
|
62
|
+
if (args.mode !== undefined && args.mode !== 'review' && args.mode !== 'auto') {
|
|
63
|
+
err(`${c.red('Error:')} --mode must be "review" or "auto"`);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const res = await store.policyCreate({
|
|
68
|
+
scope: args.scope,
|
|
69
|
+
name: args.name,
|
|
70
|
+
mode: args.mode,
|
|
71
|
+
enabled: args.enabled ? true : undefined,
|
|
72
|
+
min_age_days: minAge.value,
|
|
73
|
+
unseen_days: unseen.value,
|
|
74
|
+
max_seen_count: maxSeen.value,
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
const msg = res.error?.message ?? res.error ?? res.networkError ?? 'the server rejected the request';
|
|
78
|
+
if (args.json) { log(JSON.stringify({ ok: false, policy: null, error: String(msg) }, null, 2)); return 1; }
|
|
79
|
+
err(`${c.red('Error:')} ${msg}`);
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
if (args.json) { log(JSON.stringify({ ok: true, policy: res.policy }, null, 2)); return 0; }
|
|
83
|
+
log(`${c.green('✓')} created policy ${formatPolicy(res.policy)}`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function update(args, store) {
|
|
88
|
+
const id = args._[2];
|
|
89
|
+
if (!id) {
|
|
90
|
+
err(`${c.red('Usage:')} lorekit policy update <id> [--name N] [--mode review|auto] [--enabled|--disabled] [--min-age-days N] [--unseen-days N] [--max-seen-count N]`);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
if (args.mode !== undefined && args.mode !== 'review' && args.mode !== 'auto') {
|
|
94
|
+
err(`${c.red('Error:')} --mode must be "review" or "auto"`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const patch = {};
|
|
99
|
+
if (args.name !== undefined) patch.name = args.name;
|
|
100
|
+
if (args.mode !== undefined) patch.mode = args.mode;
|
|
101
|
+
if (args.enabled) patch.enabled = true;
|
|
102
|
+
if (args.disabled) patch.enabled = false;
|
|
103
|
+
for (const [flag, clearFlag, field] of [
|
|
104
|
+
['min-age-days', 'clear-min-age-days', 'min_age_days'],
|
|
105
|
+
['unseen-days', 'clear-unseen-days', 'unseen_days'],
|
|
106
|
+
['max-seen-count', 'clear-max-seen-count', 'max_seen_count'],
|
|
107
|
+
]) {
|
|
108
|
+
if (args[clearFlag]) { patch[field] = null; continue; }
|
|
109
|
+
if (args[flag] === undefined) continue;
|
|
110
|
+
const parsed = parseIntFlag(args[flag], flag);
|
|
111
|
+
if (parsed.error) { err(`${c.red('Error:')} ${parsed.error}`); return 1; }
|
|
112
|
+
patch[field] = parsed.value;
|
|
113
|
+
}
|
|
114
|
+
if (Object.keys(patch).length === 0) {
|
|
115
|
+
err(`${c.red('Error:')} at least one field to update is required`);
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const res = await store.policyUpdate(id, patch);
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
const msg = res.error?.message ?? res.error ?? res.networkError ?? 'the server rejected the request';
|
|
122
|
+
if (args.json) { log(JSON.stringify({ ok: false, policy: null, error: String(msg) }, null, 2)); return 1; }
|
|
123
|
+
err(`${c.red('Error:')} ${msg}${res.httpStatus === 404 ? ` — no policy found for id ${id}` : ''}`);
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
if (args.json) { log(JSON.stringify({ ok: true, policy: res.policy }, null, 2)); return 0; }
|
|
127
|
+
log(`${c.green('✓')} updated policy ${formatPolicy(res.policy)}`);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function del(args, store) {
|
|
132
|
+
const id = args._[2];
|
|
133
|
+
if (!id) {
|
|
134
|
+
err(`${c.red('Usage:')} lorekit policy delete <id>`);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
const decision = args.yes ? 'proceed' : (args.json || !process.stdin.isTTY) ? 'refuse' : 'prompt';
|
|
138
|
+
if (decision === 'refuse') {
|
|
139
|
+
err(`${c.red('Refusing:')} deletes the policy with no terminal to confirm. Re-run with ${c.cyan('--yes')}.`);
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
if (decision === 'prompt') {
|
|
143
|
+
const go = await select(
|
|
144
|
+
`${c.bold('Delete policy')} ${id}? The lessons it matched are untouched.`,
|
|
145
|
+
[
|
|
146
|
+
{ value: false, label: 'Cancel' },
|
|
147
|
+
{ value: true, label: 'Yes, delete' },
|
|
148
|
+
],
|
|
149
|
+
{ defaultIndex: 0 },
|
|
150
|
+
);
|
|
151
|
+
if (!go) { log(c.dim('Cancelled.')); return 0; }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const res = await store.policyDelete(id);
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
const msg = res.error?.message ?? res.error ?? res.networkError ?? 'the server rejected the request';
|
|
157
|
+
if (args.json) { log(JSON.stringify({ ok: false, deleted: false, error: String(msg) }, null, 2)); return 1; }
|
|
158
|
+
err(`${c.red('Error:')} ${msg}${res.httpStatus === 404 ? ` — no policy found for id ${id}` : ''}`);
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
if (args.json) { log(JSON.stringify({ ok: true, deleted: true }, null, 2)); return 0; }
|
|
162
|
+
log(`${c.green('✓')} deleted policy ${c.cyan(id)}`);
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function policy(args) {
|
|
167
|
+
const root = resolveProjectRoot(args.dir);
|
|
168
|
+
const env = process.env;
|
|
169
|
+
loadControl(root, { env });
|
|
170
|
+
|
|
171
|
+
const picked = pickRemote({ root, env, args });
|
|
172
|
+
if (picked.error) {
|
|
173
|
+
err(`${c.red('Error:')} ${picked.error}`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const sub = args._[1];
|
|
178
|
+
if (sub === 'list') return list(args, picked.store);
|
|
179
|
+
if (sub === 'create') return create(args, picked.store);
|
|
180
|
+
if (sub === 'update') return update(args, picked.store);
|
|
181
|
+
if (sub === 'delete') return del(args, picked.store);
|
|
182
|
+
|
|
183
|
+
err(`${c.red('Usage:')} lorekit policy <list|create|update|delete> [options]`);
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// `lorekit protect <scope::key> [--off]` / `lorekit pin|unpin <scope::key>`
|
|
2
|
+
//
|
|
3
|
+
// Marks or unmarks a lesson as protected — excluded from every grooming
|
|
4
|
+
// candidate set regardless of policy. `protect` is the catalog's
|
|
5
|
+
// `memory.protect` operation (pass `--off` to unprotect); `pin`/`unpin` are
|
|
6
|
+
// convenience commands that call the SAME underlying REST call with the value
|
|
7
|
+
// fixed, matching the plan's "pin/unpin aliases handled in the handler" —
|
|
8
|
+
// implemented as two extra COMMANDS entries rather than catalog cliAliases,
|
|
9
|
+
// since an alias only renames a command to an identical-behaviour canonical
|
|
10
|
+
// one and pin/unpin invert the boolean.
|
|
11
|
+
import { resolveProjectRoot } from '../shared/config.mjs';
|
|
12
|
+
import { loadControl, resolveDenies } from '../shared/control.mjs';
|
|
13
|
+
import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs';
|
|
14
|
+
import { log, err, c } from '../shared/util.mjs';
|
|
15
|
+
import { resolveScopeKeyArgs, scopeIssue } from '../shared/lessons-view.mjs';
|
|
16
|
+
|
|
17
|
+
function pickRemote({ root, env, args }) {
|
|
18
|
+
const { remoteDenied } = resolveDenies(root, { env });
|
|
19
|
+
const { remote, connection } = resolveStores(root, { env, endpoint: args.endpoint, token: args.token });
|
|
20
|
+
if (remoteDenied) return { error: `remote store is disabled by deny constraint (${remoteDenied.source})` };
|
|
21
|
+
if (!remote.usable()) return { error: `remote store is not configured — ${remoteUnavailableReason(connection)}` };
|
|
22
|
+
return { store: remote };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// One implementation for all four verbs; `isProtected` selects the value,
|
|
26
|
+
// `verb` only changes the printed word.
|
|
27
|
+
async function run(args, isProtected, verb) {
|
|
28
|
+
const root = resolveProjectRoot(args.dir);
|
|
29
|
+
const env = process.env;
|
|
30
|
+
loadControl(root, { env });
|
|
31
|
+
|
|
32
|
+
const positionals = args._.slice(1);
|
|
33
|
+
const { scope, key } = resolveScopeKeyArgs(positionals, { scope: args.scope, key: args.key });
|
|
34
|
+
|
|
35
|
+
const badScope = scope ? scopeIssue(scope) : null;
|
|
36
|
+
if (badScope) {
|
|
37
|
+
err(`${c.red('Error:')} invalid scope ${c.cyan(scope)} — ${badScope}`);
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
if (!scope || !key) {
|
|
41
|
+
err(`${c.red('Usage:')} lorekit ${verb} <scope::key>`);
|
|
42
|
+
err(` lorekit ${verb} <scope> <key>`);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const picked = pickRemote({ root, env, args });
|
|
47
|
+
if (picked.error) {
|
|
48
|
+
err(`${c.red('Error:')} ${picked.error}`);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const res = await picked.store.protect({ scope, key, protected: isProtected });
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
const msg = res.error?.message ?? res.error ?? res.networkError ?? 'the server rejected the request';
|
|
55
|
+
if (args.json) {
|
|
56
|
+
log(JSON.stringify({ ok: false, scope, key, protected: null, error: String(msg) }, null, 2));
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
err(`${c.red('Error:')} could not ${verb} ${c.cyan(`${scope}::${key}`)} — ${msg}`);
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
if (args.json) {
|
|
63
|
+
log(JSON.stringify({ ok: true, scope, key, protected: res.protected }, null, 2));
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
const past = isProtected ? 'protected' : 'unprotected';
|
|
67
|
+
log(`${c.green('✓')} ${past} ${c.cyan(`${scope}::${key}`)} ${c.dim('(remote)')}`);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function protect(args) { return run(args, !args.off, 'protect'); }
|
|
72
|
+
export function pin(args) { return run(args, true, 'pin'); }
|
|
73
|
+
export function unpin(args) { return run(args, false, 'unpin'); }
|
package/src/commands.mjs
CHANGED
|
@@ -54,6 +54,9 @@ import { migrate } from './commands/migrate.mjs';
|
|
|
54
54
|
import { bootstrap } from './commands/bootstrap.mjs';
|
|
55
55
|
import { mcpServer } from './commands/mcp-server.mjs';
|
|
56
56
|
import { purge, purgeExpired } from './commands/purge.mjs';
|
|
57
|
+
import { groom } from './commands/groom.mjs';
|
|
58
|
+
import { policy } from './commands/policy.mjs';
|
|
59
|
+
import { protect, pin, unpin } from './commands/protect.mjs';
|
|
57
60
|
import { completion } from './commands/completion.mjs';
|
|
58
61
|
|
|
59
62
|
/**
|
|
@@ -89,6 +92,11 @@ export const COMMANDS = [
|
|
|
89
92
|
{ name: 'restore', run: restore, traced: true, strictFlags: true, tool: 'memory.restore' },
|
|
90
93
|
{ name: 'purge', run: purge, traced: true, strictFlags: true, tool: 'memory.purge' },
|
|
91
94
|
{ name: 'purge-expired', run: purgeExpired, traced: true, strictFlags: true, tool: 'memory.purge_expired' },
|
|
95
|
+
{ name: 'groom', run: groom, traced: true, strictFlags: true, tool: 'groom.preview' },
|
|
96
|
+
{ name: 'policy', run: policy, traced: true, strictFlags: true, tool: 'policy.list' },
|
|
97
|
+
{ name: 'protect', run: protect, traced: true, strictFlags: true, tool: 'memory.protect' },
|
|
98
|
+
{ name: 'pin', run: pin, traced: true, strictFlags: true, native: 'shorthand for `protect` (protected=true)' },
|
|
99
|
+
{ name: 'unpin', run: unpin, traced: true, strictFlags: true, native: 'shorthand for `protect --off` (protected=false)' },
|
|
92
100
|
|
|
93
101
|
// ── Machine-facing ──────────────────────────────────────────────────────────
|
|
94
102
|
// `completion` is machine-facing for the same reason hook/mcp are: its stdout
|
|
@@ -71,6 +71,18 @@ const FLAG = {
|
|
|
71
71
|
'retention-days': { desc: 'Only purge archived older than n days', arg: 'n' },
|
|
72
72
|
files: { desc: 'Changed files to check', arg: 'path' },
|
|
73
73
|
strict: { desc: 'Exit non-zero on any unmet obligation' },
|
|
74
|
+
'policy-id': { desc: 'Run/preview a saved policy', arg: 'id' },
|
|
75
|
+
'min-age-days': { desc: 'Match lessons at least n days old', arg: 'n' },
|
|
76
|
+
'unseen-days': { desc: 'Match lessons unseen for at least n days', arg: 'n' },
|
|
77
|
+
'max-seen-count': { desc: 'Match lessons that recurred at most n times', arg: 'n' },
|
|
78
|
+
run: { desc: 'Archive the matches instead of previewing' },
|
|
79
|
+
name: { desc: 'Policy name', arg: 'name' },
|
|
80
|
+
enabled: { desc: 'Turn auto-mode on' },
|
|
81
|
+
disabled: { desc: 'Turn auto-mode off' },
|
|
82
|
+
'clear-min-age-days': { desc: 'Remove the min-age-days condition' },
|
|
83
|
+
'clear-unseen-days': { desc: 'Remove the unseen-days condition' },
|
|
84
|
+
'clear-max-seen-count': { desc: 'Remove the max-seen-count condition' },
|
|
85
|
+
off: { desc: 'Unprotect instead of protect' },
|
|
74
86
|
};
|
|
75
87
|
|
|
76
88
|
// Every command's completion shape, in the top-level help order. `flags` lists
|
|
@@ -133,6 +145,19 @@ const COMMANDS = [
|
|
|
133
145
|
flags: ['retention-days', 'yes', 'json', 'endpoint', 'token'] },
|
|
134
146
|
{ name: 'purge-expired', summary: 'Delete every TTL-expired memory',
|
|
135
147
|
flags: ['yes', 'json', 'endpoint', 'token'] },
|
|
148
|
+
{ name: 'groom', summary: 'Preview or run a retention sweep',
|
|
149
|
+
values: { mode: ['review', 'auto'] },
|
|
150
|
+
flags: ['policy-id', 'scope', 'min-age-days', 'unseen-days', 'max-seen-count', 'run', 'yes', 'json', 'endpoint', 'token'] },
|
|
151
|
+
{ name: 'policy', summary: 'Manage saved retention rules',
|
|
152
|
+
values: { mode: ['review', 'auto'] },
|
|
153
|
+
flags: ['scope', 'name', 'mode', 'enabled', 'disabled', 'min-age-days', 'unseen-days', 'max-seen-count',
|
|
154
|
+
'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'yes', 'json', 'endpoint', 'token'] },
|
|
155
|
+
{ name: 'protect', summary: 'Mark a memory protected, excluded from every grooming sweep', positional: 'address',
|
|
156
|
+
flags: ['off', 'scope', 'key', 'json', 'endpoint', 'token'] },
|
|
157
|
+
{ name: 'pin', summary: 'Shorthand for `protect` (protected=true)', positional: 'address',
|
|
158
|
+
flags: ['json', 'endpoint', 'token'] },
|
|
159
|
+
{ name: 'unpin', summary: 'Shorthand for `protect --off` (protected=false)', positional: 'address',
|
|
160
|
+
flags: ['json', 'endpoint', 'token'] },
|
|
136
161
|
{ name: 'completion', summary: 'Print a shell completion script', positional: 'shell' },
|
|
137
162
|
];
|
|
138
163
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Shared CLI flag-value parsers. Zero-dependency, following the package convention.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse an optional non-negative-integer flag value.
|
|
5
|
+
*
|
|
6
|
+
* Returns `{ value: undefined }` when the flag was omitted, `{ value: N }` on a
|
|
7
|
+
* valid whole number, or `{ error }` naming the flag on anything else — never
|
|
8
|
+
* coerces a half-understood value (`12abc`, `1.5`) into a number silently.
|
|
9
|
+
*/
|
|
10
|
+
export function parseIntFlag(raw, name) {
|
|
11
|
+
if (raw === undefined) return { value: undefined };
|
|
12
|
+
if (!/^\d+$/.test(String(raw).trim())) {
|
|
13
|
+
return { error: `--${name} must be a whole number, got ${JSON.stringify(String(raw))}` };
|
|
14
|
+
}
|
|
15
|
+
return { value: Number(String(raw).trim()) };
|
|
16
|
+
}
|
package/src/shared/mcp.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// Minimal MCP-over-HTTP (JSON-RPC 2.0) client for the LoreKit endpoint.
|
|
2
|
-
// Zero dependencies — uses the global fetch (Node 18+).
|
|
2
|
+
// Zero EXTERNAL dependencies — uses the global fetch (Node 18+). Imports
|
|
3
|
+
// below are same-package sibling modules (`./origin.mjs`), not npm deps.
|
|
4
|
+
|
|
5
|
+
import { prNumberFromEnv, isValidRepo } from './origin.mjs';
|
|
3
6
|
|
|
4
7
|
// Split a configured server URL like ".../mcp?token=lk_rw_x" into
|
|
5
8
|
// { endpoint: ".../mcp", token: "lk_rw_x" }.
|
|
@@ -170,6 +173,94 @@ export function normalizeCorrelationId(raw) {
|
|
|
170
173
|
return /^[A-Za-z0-9_\-./:#@]+$/.test(t) ? t : null;
|
|
171
174
|
}
|
|
172
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The bounded `session_kind` vocabulary (migration 00082) — sent via
|
|
178
|
+
* `X-LoreKit-Session-Kind`, validated edge-side by the CROSS-LANGUAGE twin of
|
|
179
|
+
* this file's derivation, `packages/mcp-core/src/telemetry/session-kind.ts`
|
|
180
|
+
* (`parseSessionKind`). Kept here rather than imported: this package has no
|
|
181
|
+
* dependency on `@lorekit/core`, and the two are guarded for behavioural
|
|
182
|
+
* parity by `session-kind-parity.spec.ts` rather than a byte comparison,
|
|
183
|
+
* which is what a cross-language pair (this `.mjs` vs that `.ts`) needs.
|
|
184
|
+
*/
|
|
185
|
+
const SESSION_KINDS = ['local', 'ci', 'pr', 'unknown'];
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Derive `{ correlationId, sessionKind }` from the ambient environment, for
|
|
189
|
+
* every call site that does not have an EXPLICIT `LOREKIT_CORRELATION_ID` —
|
|
190
|
+
* the caller checks that first and skips this entirely when it is set, since
|
|
191
|
+
* an explicit value always wins.
|
|
192
|
+
*
|
|
193
|
+
* Precedence, first match wins:
|
|
194
|
+
* 1. PR context — `prNumberFromEnv` (LOREKIT_PR / GITHUB_REF / GITHUB_PR_NUMBER,
|
|
195
|
+
* see `origin.mjs`) resolves a PR number AND a repo is known → `pr` +
|
|
196
|
+
* `pr:<owner>/<repo>#<n>`.
|
|
197
|
+
* 2. CI environment (`GITHUB_ACTIONS`/`CI`) — `ci` always; a correlation id
|
|
198
|
+
* of `ci:<owner>/<repo>#<run_id>` when both a repo and GITHUB_RUN_ID are
|
|
199
|
+
* known, otherwise no correlation id (still `ci` — the session KIND is
|
|
200
|
+
* known even when a stable id to group by is not).
|
|
201
|
+
* 3. A host-provided session id (`LOREKIT_SESSION_ID`, or the handful of
|
|
202
|
+
* well-known agent-host env vars below) — `local` +
|
|
203
|
+
* `session:<id>`. The raw id itself is never logged or stored anywhere
|
|
204
|
+
* beyond this derived correlation id.
|
|
205
|
+
* 4. Otherwise `unknown`, no correlation id — never a guess.
|
|
206
|
+
*
|
|
207
|
+
* TOTAL and fail-safe: reads only `env` (never throws on a missing/odd
|
|
208
|
+
* value), and every branch degrades to the next rather than throwing. A
|
|
209
|
+
* derived value that fails `normalizeCorrelationId`'s charset/length check is
|
|
210
|
+
* dropped (session_kind is still reported; only the drill-down id is not).
|
|
211
|
+
*/
|
|
212
|
+
export function deriveSessionContext(env = process.env) {
|
|
213
|
+
const repo = isValidRepo(env.GITHUB_REPOSITORY);
|
|
214
|
+
const prNumber = prNumberFromEnv(env);
|
|
215
|
+
|
|
216
|
+
if (prNumber !== null && repo) {
|
|
217
|
+
const correlationId = normalizeCorrelationId(`pr:${repo}#${prNumber}`);
|
|
218
|
+
if (correlationId) return { correlationId, sessionKind: 'pr' };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const isCI = env.GITHUB_ACTIONS === 'true' || env.CI === 'true' || env.CI === '1';
|
|
222
|
+
if (isCI) {
|
|
223
|
+
const runId = typeof env.GITHUB_RUN_ID === 'string' ? env.GITHUB_RUN_ID.trim() : '';
|
|
224
|
+
if (repo && runId) {
|
|
225
|
+
const correlationId = normalizeCorrelationId(`ci:${repo}#${runId}`);
|
|
226
|
+
if (correlationId) return { correlationId, sessionKind: 'ci' };
|
|
227
|
+
}
|
|
228
|
+
return { correlationId: null, sessionKind: 'ci' };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Well-known agent-host session id env vars. Best-effort: hosts differ and
|
|
232
|
+
// this is not an exhaustive registry, so an unrecognised host still falls
|
|
233
|
+
// through to `unknown` rather than fabricating an id.
|
|
234
|
+
const sessionId = firstNonEmptyEnv(env, ['LOREKIT_SESSION_ID', 'CLAUDE_SESSION_ID']);
|
|
235
|
+
if (sessionId) {
|
|
236
|
+
// A local session IS known even when the specific id fails the
|
|
237
|
+
// correlation-id charset/length check — report the kind either way, and
|
|
238
|
+
// let the id itself degrade to null rather than losing the whole reading.
|
|
239
|
+
return { correlationId: normalizeCorrelationId(`session:${sessionId}`), sessionKind: 'local' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { correlationId: null, sessionKind: 'unknown' };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function firstNonEmptyEnv(env, keys) {
|
|
246
|
+
for (const key of keys) {
|
|
247
|
+
const v = env[key];
|
|
248
|
+
if (typeof v === 'string' && v.trim() !== '') return v.trim();
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Validate a `session_kind` value against the closed vocabulary. Total and
|
|
255
|
+
* fail-safe — mirrors `parseSessionKind`'s behaviour (never used to VALIDATE
|
|
256
|
+
* an incoming value here, since this process only ever sends a value it just
|
|
257
|
+
* derived itself, but kept as the single place the vocabulary is spelled out
|
|
258
|
+
* so `deriveSessionContext` and any future caller cannot drift from it).
|
|
259
|
+
*/
|
|
260
|
+
export function isSessionKind(value) {
|
|
261
|
+
return SESSION_KINDS.includes(value);
|
|
262
|
+
}
|
|
263
|
+
|
|
173
264
|
/**
|
|
174
265
|
* Normalise a deployment-environment marker restFetch attaches as
|
|
175
266
|
* X-LoreKit-Deployment-Environment when DEPLOYMENT_ENVIRONMENT (or
|
|
@@ -219,11 +310,17 @@ export async function restFetch(baseUrl, token, path, { method = 'GET', body, ti
|
|
|
219
310
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
220
311
|
try {
|
|
221
312
|
const url = `${baseUrl}${path}`;
|
|
222
|
-
//
|
|
223
|
-
// job or a hook
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
313
|
+
// Usage correlation: an EXPLICIT LOREKIT_CORRELATION_ID always wins (e.g. a
|
|
314
|
+
// CI job or a hook hand-setting a PR/session id). Otherwise, derive one
|
|
315
|
+
// from the ambient environment (CI/PR/session — see `deriveSessionContext`)
|
|
316
|
+
// so GET /memories/usage?correlation_id=… and the session_kind dimension
|
|
317
|
+
// are populated without anyone having to export anything by hand. Both
|
|
318
|
+
// stay unset only when neither an explicit value nor a derivable one
|
|
319
|
+
// exists (`sessionKind: 'unknown'`, no correlationId).
|
|
320
|
+
const explicitCorrelationId = normalizeCorrelationId(process.env.LOREKIT_CORRELATION_ID);
|
|
321
|
+
const derived = explicitCorrelationId ? null : deriveSessionContext(process.env);
|
|
322
|
+
const correlationId = explicitCorrelationId ?? derived?.correlationId ?? null;
|
|
323
|
+
const sessionKind = derived?.sessionKind ?? null;
|
|
227
324
|
// Opt-in test-run marker: when DEPLOYMENT_ENVIRONMENT is set (a deploy/CI
|
|
228
325
|
// smoke sets it to `test`), tell the edge to report that
|
|
229
326
|
// `deployment.environment.name` for this request so Dash0 can filter synthetic
|
|
@@ -238,6 +335,7 @@ export async function restFetch(baseUrl, token, path, { method = 'GET', body, ti
|
|
|
238
335
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
239
336
|
...(traceparent ? { traceparent } : {}),
|
|
240
337
|
...(correlationId ? { 'x-lorekit-correlation-id': correlationId } : {}),
|
|
338
|
+
...(sessionKind ? { 'x-lorekit-session-kind': sessionKind } : {}),
|
|
241
339
|
...(runEnv ? { 'x-lorekit-deployment-environment': runEnv } : {}),
|
|
242
340
|
// Name the calling surface so usage analytics can tell a CLI read from a
|
|
243
341
|
// dashboard one. Not cosmetic: `GET /memories/read-activity` EXCLUDES the
|
|
@@ -54,6 +54,12 @@ export const mirrorPairs = [
|
|
|
54
54
|
{ core: 'packages/mcp-core/src/webhook/github-app-jwt.ts', edge: 'supabase/functions/mcp/github-app-jwt.ts', driftChecked: true },
|
|
55
55
|
{ core: 'packages/mcp-core/src/telemetry/trace-context.ts', edge: 'supabase/functions/_shared/telemetry/trace-context.ts', driftChecked: true },
|
|
56
56
|
{ core: 'packages/mcp-core/src/rest/rest-tool-name.ts', edge: 'supabase/functions/_shared/rest/rest-tool-name.ts', driftChecked: true },
|
|
57
|
+
// The `X-LoreKit-Session-Kind` validator (migration 00082). Also has a
|
|
58
|
+
// SECOND, cross-LANGUAGE twin — the CLI's `deriveSessionContext` in
|
|
59
|
+
// `packages/cli/src/shared/mcp.mjs` — guarded behaviourally by
|
|
60
|
+
// `packages/cli/test/session-context.test.mjs`, the same split
|
|
61
|
+
// `lesson-rank.ts` uses below.
|
|
62
|
+
{ core: 'packages/mcp-core/src/telemetry/session-kind.ts', edge: 'supabase/functions/_shared/telemetry/session-kind.ts', driftChecked: true },
|
|
57
63
|
// Has a SECOND, cross-LANGUAGE twin no byte comparison can cover — the
|
|
58
64
|
// CLI's own `lessons-pure.mjs` — guarded behaviourally by
|
|
59
65
|
// `lesson-rank-parity.spec.ts` instead.
|
|
@@ -70,6 +76,12 @@ export const mirrorPairs = [
|
|
|
70
76
|
{ core: 'packages/mcp-core/src/auth/account-wide-tools.ts', edge: 'supabase/functions/_shared/auth/account-wide-tools.ts', driftChecked: true },
|
|
71
77
|
{ core: 'packages/mcp-core/src/telemetry/io-ledger.ts', edge: 'supabase/functions/_shared/telemetry/io-ledger.ts', driftChecked: true },
|
|
72
78
|
{ core: 'packages/mcp-core/src/telemetry/db-query-metrics.ts', edge: 'supabase/functions/_shared/telemetry/db-query-metrics.ts', driftChecked: true },
|
|
79
|
+
// Retention-policy candidate/precedence logic. The SQL RPC
|
|
80
|
+
// (`lorekit_groom_candidates`, migration 00088) is authoritative; this is the
|
|
81
|
+
// unit-testable mirror the edge groom/policy handlers use to resolve a
|
|
82
|
+
// `policy_id` or inline request into the conditions struct the RPC takes.
|
|
83
|
+
// Both copies are import-free, so the byte-comparison drift check applies.
|
|
84
|
+
{ core: 'packages/mcp-core/src/retention/groom.ts', edge: 'supabase/functions/_shared/retention/groom.ts', driftChecked: true },
|
|
73
85
|
// Excluded from the byte-comparison drift check: the edge copy types the
|
|
74
86
|
// client as `ReturnType<typeof createClient>` off an `npm:` specifier where
|
|
75
87
|
// mcp-core imports a typed `SupabaseClient`, and additionally carries
|
package/src/store/remote.mjs
CHANGED
|
@@ -695,6 +695,70 @@ class RemoteStore {
|
|
|
695
695
|
};
|
|
696
696
|
}
|
|
697
697
|
|
|
698
|
+
// ── Retention policies ("grooming") → REST ────────────────────────────────
|
|
699
|
+
// Server-side only (retention_policies table + pg_cron sweep) — v1 has no
|
|
700
|
+
// local-store equivalent, matching the account-wide sweeps just above.
|
|
701
|
+
|
|
702
|
+
// GET /policies → { entries: [...] }
|
|
703
|
+
async policyList() {
|
|
704
|
+
const res = await this._rest('/memories/policies');
|
|
705
|
+
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
706
|
+
return { ok: true, entries: Array.isArray(res.data?.entries) ? res.data.entries : [] };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// POST /policies → the created policy object.
|
|
710
|
+
async policyCreate({ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count } = {}) {
|
|
711
|
+
const body = stripUndefined({ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count });
|
|
712
|
+
const res = await this._rest('/memories/policies', { method: 'POST', body });
|
|
713
|
+
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
714
|
+
return { ok: true, policy: res.data };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// PATCH /policies/:id → the updated policy object. An omitted field is left
|
|
718
|
+
// unchanged; pass an explicit `null` in the patch to clear a condition —
|
|
719
|
+
// this method does not strip nulls, only `undefined` (stripUndefined keeps
|
|
720
|
+
// that distinction, which is the whole point of the RPC's JSONB-patch design).
|
|
721
|
+
async policyUpdate(id, patch = {}) {
|
|
722
|
+
const body = stripUndefined(patch);
|
|
723
|
+
const res = await this._rest(`/memories/policies/${encodeURIComponent(id)}`, { method: 'PATCH', body });
|
|
724
|
+
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
725
|
+
return { ok: true, policy: res.data };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// DELETE /policies/:id → deletes the RULE only; never touches the lessons it matched.
|
|
729
|
+
async policyDelete(id) {
|
|
730
|
+
const res = await this._rest(`/memories/policies/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
731
|
+
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
732
|
+
return { ok: true, deleted: true };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// POST /groom/preview → { count, keys: [{ scope, key }] } — the SAME
|
|
736
|
+
// candidates a groom() run would archive. Pass either `policy_id` or
|
|
737
|
+
// `scope` (+ optional conditions), never both.
|
|
738
|
+
async groomPreview({ policy_id, scope, min_age_days, unseen_days, max_seen_count } = {}) {
|
|
739
|
+
const body = stripUndefined({ policy_id, scope, min_age_days, unseen_days, max_seen_count });
|
|
740
|
+
const res = await this._rest('/memories/groom/preview', { method: 'POST', body });
|
|
741
|
+
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
742
|
+
return { ok: true, count: res.data?.count ?? 0, keys: Array.isArray(res.data?.keys) ? res.data.keys : [] };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// POST /groom/run → archives every previewed candidate, in one transaction.
|
|
746
|
+
// Soft-archive only (recoverable via restore); never hard-deletes.
|
|
747
|
+
async groomRun({ policy_id, scope, min_age_days, unseen_days, max_seen_count } = {}) {
|
|
748
|
+
const body = stripUndefined({ policy_id, scope, min_age_days, unseen_days, max_seen_count });
|
|
749
|
+
const res = await this._rest('/memories/groom/run', { method: 'POST', body });
|
|
750
|
+
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
751
|
+
return { ok: true, archived: res.data?.archived ?? 0, keys: Array.isArray(res.data?.keys) ? res.data.keys : [] };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// POST /protect → { protected }. Excludes/includes a lesson from every
|
|
755
|
+
// grooming candidate set regardless of policy.
|
|
756
|
+
async protect({ scope, key, protected: isProtected } = {}) {
|
|
757
|
+
const res = await this._rest('/memories/protect', { method: 'POST', body: { scope, key, protected: isProtected } });
|
|
758
|
+
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
759
|
+
return { ok: true, protected: res.data?.protected ?? isProtected };
|
|
760
|
+
}
|
|
761
|
+
|
|
698
762
|
// Authentication probe for doctor — does the configured token STILL work?
|
|
699
763
|
//
|
|
700
764
|
// `ping()` deliberately hits the PUBLIC `/health` function, so it stays green
|
|
@@ -21,7 +21,14 @@ export const MCP_TOOL_NAMES = [
|
|
|
21
21
|
"org.create",
|
|
22
22
|
"org.list",
|
|
23
23
|
"org.rename",
|
|
24
|
-
"org.delete"
|
|
24
|
+
"org.delete",
|
|
25
|
+
"policy.list",
|
|
26
|
+
"policy.create",
|
|
27
|
+
"policy.update",
|
|
28
|
+
"policy.delete",
|
|
29
|
+
"groom.preview",
|
|
30
|
+
"groom.run",
|
|
31
|
+
"memory.protect"
|
|
25
32
|
];
|
|
26
33
|
|
|
27
34
|
/** The `memory.*` family — dispatched against a store (local or remote). */
|
|
@@ -36,7 +43,8 @@ export const MEMORY_TOOL_NAMES = [
|
|
|
36
43
|
"memory.list_archived",
|
|
37
44
|
"memory.restore",
|
|
38
45
|
"memory.purge",
|
|
39
|
-
"memory.purge_expired"
|
|
46
|
+
"memory.purge_expired",
|
|
47
|
+
"memory.protect"
|
|
40
48
|
];
|
|
41
49
|
|
|
42
50
|
/** The `org.*` family — always proxied to the REST API, never the local store. */
|
|
@@ -462,6 +470,229 @@ export const MCP_TOOL_DEFS = [
|
|
|
462
470
|
}
|
|
463
471
|
}
|
|
464
472
|
}
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
"name": "policy.list",
|
|
476
|
+
"description": "List every retention policy you own",
|
|
477
|
+
"inputSchema": {
|
|
478
|
+
"type": "object",
|
|
479
|
+
"properties": {}
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
"name": "policy.create",
|
|
484
|
+
"description": "Create a scoped retention policy that auto-archives (never hard-deletes) matching lessons",
|
|
485
|
+
"inputSchema": {
|
|
486
|
+
"type": "object",
|
|
487
|
+
"required": [
|
|
488
|
+
"scope",
|
|
489
|
+
"name"
|
|
490
|
+
],
|
|
491
|
+
"properties": {
|
|
492
|
+
"scope": {
|
|
493
|
+
"type": "string",
|
|
494
|
+
"description": "Canonical scope string, e.g. `repo::mthines/lorekit`."
|
|
495
|
+
},
|
|
496
|
+
"name": {
|
|
497
|
+
"type": "string",
|
|
498
|
+
"description": "Human-readable name for the policy."
|
|
499
|
+
},
|
|
500
|
+
"mode": {
|
|
501
|
+
"type": "string",
|
|
502
|
+
"enum": [
|
|
503
|
+
"review",
|
|
504
|
+
"auto"
|
|
505
|
+
],
|
|
506
|
+
"default": "review",
|
|
507
|
+
"description": "`review` — surfaced for a human to run manually. `auto` — swept nightly, if enabled."
|
|
508
|
+
},
|
|
509
|
+
"enabled": {
|
|
510
|
+
"type": "boolean",
|
|
511
|
+
"default": false,
|
|
512
|
+
"description": "Whether `auto` mode is active. Always starts false, even when mode is `auto`."
|
|
513
|
+
},
|
|
514
|
+
"min_age_days": {
|
|
515
|
+
"type": "integer",
|
|
516
|
+
"minimum": 1,
|
|
517
|
+
"maximum": 3650,
|
|
518
|
+
"description": "Match only lessons at least this many days old."
|
|
519
|
+
},
|
|
520
|
+
"unseen_days": {
|
|
521
|
+
"type": "integer",
|
|
522
|
+
"minimum": 1,
|
|
523
|
+
"maximum": 3650,
|
|
524
|
+
"description": "Match lessons unseen for at least this many days. A never-seen lesson always matches."
|
|
525
|
+
},
|
|
526
|
+
"max_seen_count": {
|
|
527
|
+
"type": "integer",
|
|
528
|
+
"minimum": 0,
|
|
529
|
+
"maximum": 100000,
|
|
530
|
+
"description": "Match only lessons that have recurred at most this many times."
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
"name": "policy.update",
|
|
537
|
+
"description": "Update a retention policy. Every field but id is optional",
|
|
538
|
+
"inputSchema": {
|
|
539
|
+
"type": "object",
|
|
540
|
+
"required": [
|
|
541
|
+
"id"
|
|
542
|
+
],
|
|
543
|
+
"properties": {
|
|
544
|
+
"id": {
|
|
545
|
+
"type": "string",
|
|
546
|
+
"description": "The policy id to update."
|
|
547
|
+
},
|
|
548
|
+
"name": {
|
|
549
|
+
"type": "string",
|
|
550
|
+
"description": "New name for the policy."
|
|
551
|
+
},
|
|
552
|
+
"mode": {
|
|
553
|
+
"type": "string",
|
|
554
|
+
"enum": [
|
|
555
|
+
"review",
|
|
556
|
+
"auto"
|
|
557
|
+
],
|
|
558
|
+
"description": "`review` — surfaced for a human to run manually. `auto` — swept nightly, if enabled."
|
|
559
|
+
},
|
|
560
|
+
"enabled": {
|
|
561
|
+
"type": "boolean",
|
|
562
|
+
"description": "Whether `auto` mode is active."
|
|
563
|
+
},
|
|
564
|
+
"min_age_days": {
|
|
565
|
+
"type": "integer",
|
|
566
|
+
"minimum": 1,
|
|
567
|
+
"maximum": 3650,
|
|
568
|
+
"description": "Match only lessons at least this many days old. Omit to leave unchanged; pass explicit null to clear."
|
|
569
|
+
},
|
|
570
|
+
"unseen_days": {
|
|
571
|
+
"type": "integer",
|
|
572
|
+
"minimum": 1,
|
|
573
|
+
"maximum": 3650,
|
|
574
|
+
"description": "Match lessons unseen for at least this many days. Omit to leave unchanged; pass explicit null to clear."
|
|
575
|
+
},
|
|
576
|
+
"max_seen_count": {
|
|
577
|
+
"type": "integer",
|
|
578
|
+
"minimum": 0,
|
|
579
|
+
"maximum": 100000,
|
|
580
|
+
"description": "Match only lessons that have recurred at most this many times. Omit to leave unchanged; pass explicit null to clear."
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
"name": "policy.delete",
|
|
587
|
+
"description": "Delete a retention policy. Deletes the rule only — never touches the lessons it matched",
|
|
588
|
+
"inputSchema": {
|
|
589
|
+
"type": "object",
|
|
590
|
+
"required": [
|
|
591
|
+
"id"
|
|
592
|
+
],
|
|
593
|
+
"properties": {
|
|
594
|
+
"id": {
|
|
595
|
+
"type": "string",
|
|
596
|
+
"description": "The policy id to delete."
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
"name": "groom.preview",
|
|
603
|
+
"description": "Preview the lessons a saved policy or an inline condition set would archive, without changing anything",
|
|
604
|
+
"inputSchema": {
|
|
605
|
+
"type": "object",
|
|
606
|
+
"properties": {
|
|
607
|
+
"policy_id": {
|
|
608
|
+
"type": "string",
|
|
609
|
+
"description": "Preview an existing saved policy. Mutually exclusive with `scope`/conditions."
|
|
610
|
+
},
|
|
611
|
+
"scope": {
|
|
612
|
+
"type": "string",
|
|
613
|
+
"description": "Canonical scope string, e.g. `repo::mthines/lorekit`."
|
|
614
|
+
},
|
|
615
|
+
"min_age_days": {
|
|
616
|
+
"type": "integer",
|
|
617
|
+
"minimum": 1,
|
|
618
|
+
"maximum": 3650,
|
|
619
|
+
"description": "Match only lessons at least this many days old."
|
|
620
|
+
},
|
|
621
|
+
"unseen_days": {
|
|
622
|
+
"type": "integer",
|
|
623
|
+
"minimum": 1,
|
|
624
|
+
"maximum": 3650,
|
|
625
|
+
"description": "Match lessons unseen for at least this many days. A never-seen lesson always matches."
|
|
626
|
+
},
|
|
627
|
+
"max_seen_count": {
|
|
628
|
+
"type": "integer",
|
|
629
|
+
"minimum": 0,
|
|
630
|
+
"maximum": 100000,
|
|
631
|
+
"description": "Match only lessons that have recurred at most this many times."
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
"name": "groom.run",
|
|
638
|
+
"description": "Archive every lesson a saved policy or an inline condition set matches. Soft-archive only — never hard-deletes",
|
|
639
|
+
"inputSchema": {
|
|
640
|
+
"type": "object",
|
|
641
|
+
"properties": {
|
|
642
|
+
"policy_id": {
|
|
643
|
+
"type": "string",
|
|
644
|
+
"description": "Run an existing saved policy. Mutually exclusive with `scope`/conditions."
|
|
645
|
+
},
|
|
646
|
+
"scope": {
|
|
647
|
+
"type": "string",
|
|
648
|
+
"description": "Canonical scope string, e.g. `repo::mthines/lorekit`."
|
|
649
|
+
},
|
|
650
|
+
"min_age_days": {
|
|
651
|
+
"type": "integer",
|
|
652
|
+
"minimum": 1,
|
|
653
|
+
"maximum": 3650,
|
|
654
|
+
"description": "Match only lessons at least this many days old."
|
|
655
|
+
},
|
|
656
|
+
"unseen_days": {
|
|
657
|
+
"type": "integer",
|
|
658
|
+
"minimum": 1,
|
|
659
|
+
"maximum": 3650,
|
|
660
|
+
"description": "Match lessons unseen for at least this many days. A never-seen lesson always matches."
|
|
661
|
+
},
|
|
662
|
+
"max_seen_count": {
|
|
663
|
+
"type": "integer",
|
|
664
|
+
"minimum": 0,
|
|
665
|
+
"maximum": 100000,
|
|
666
|
+
"description": "Match only lessons that have recurred at most this many times."
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
"name": "memory.protect",
|
|
673
|
+
"description": "Mark or unmark a lesson as protected — excluded from every grooming candidate set regardless of policy",
|
|
674
|
+
"inputSchema": {
|
|
675
|
+
"type": "object",
|
|
676
|
+
"required": [
|
|
677
|
+
"scope",
|
|
678
|
+
"key",
|
|
679
|
+
"protected"
|
|
680
|
+
],
|
|
681
|
+
"properties": {
|
|
682
|
+
"scope": {
|
|
683
|
+
"type": "string",
|
|
684
|
+
"description": "Canonical scope string, e.g. `repo::mthines/lorekit`."
|
|
685
|
+
},
|
|
686
|
+
"key": {
|
|
687
|
+
"type": "string",
|
|
688
|
+
"description": "Lesson identifier, unique within the scope. Max 512 characters."
|
|
689
|
+
},
|
|
690
|
+
"protected": {
|
|
691
|
+
"type": "boolean",
|
|
692
|
+
"description": "true to protect, false to unprotect."
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
465
696
|
}
|
|
466
697
|
];
|
|
467
698
|
|
|
@@ -476,7 +707,10 @@ export const CLI_BINDINGS = {
|
|
|
476
707
|
"scopes": "memory.scopes",
|
|
477
708
|
"restore": "memory.restore",
|
|
478
709
|
"purge": "memory.purge",
|
|
479
|
-
"purge-expired": "memory.purge_expired"
|
|
710
|
+
"purge-expired": "memory.purge_expired",
|
|
711
|
+
"policy": "policy.list",
|
|
712
|
+
"groom": "groom.preview",
|
|
713
|
+
"protect": "memory.protect"
|
|
480
714
|
};
|
|
481
715
|
|
|
482
716
|
/** CLI alias -> canonical command name. */
|
|
@@ -495,14 +729,25 @@ export const CLI_EXEMPT = {
|
|
|
495
729
|
"org.create": "org management reaches the CLI via the local stdio MCP server (`lorekit mcp`), not a `lorekit` subcommand",
|
|
496
730
|
"org.list": "org management reaches the CLI via the local stdio MCP server (`lorekit mcp`), not a `lorekit` subcommand",
|
|
497
731
|
"org.rename": "org management reaches the CLI via the local stdio MCP server (`lorekit mcp`), not a `lorekit` subcommand",
|
|
498
|
-
"org.delete": "org management reaches the CLI via the local stdio MCP server (`lorekit mcp`), not a `lorekit` subcommand"
|
|
732
|
+
"org.delete": "org management reaches the CLI via the local stdio MCP server (`lorekit mcp`), not a `lorekit` subcommand",
|
|
733
|
+
"policy.create": "an action of the `lorekit policy` command",
|
|
734
|
+
"policy.update": "an action of the `lorekit policy` command",
|
|
735
|
+
"policy.delete": "an action of the `lorekit policy` command",
|
|
736
|
+
"groom.run": "the `--run` mode of `lorekit groom` (`groom.preview` claims the `groom` binding)"
|
|
499
737
|
};
|
|
500
738
|
|
|
501
739
|
/** Op -> why the local stdio MCP server does not dispatch it. */
|
|
502
740
|
export const LOCAL_MCP_EXEMPT = {
|
|
503
741
|
"memory.list_archived": "reachable through memory.list's archived filter on the offline store",
|
|
504
742
|
"memory.purge": "account-wide sweep against server-side state; the offline store has no equivalent",
|
|
505
|
-
"memory.purge_expired": "account-wide sweep against server-side state; the offline store has no equivalent"
|
|
743
|
+
"memory.purge_expired": "account-wide sweep against server-side state; the offline store has no equivalent",
|
|
744
|
+
"policy.list": "server-side retention_policies table; the offline store has no equivalent",
|
|
745
|
+
"policy.create": "server-side retention_policies table; the offline store has no equivalent",
|
|
746
|
+
"policy.update": "server-side retention_policies table; the offline store has no equivalent",
|
|
747
|
+
"policy.delete": "server-side retention_policies table; the offline store has no equivalent",
|
|
748
|
+
"groom.preview": "server-side candidate query against retention_policies + memories; the offline store has no equivalent",
|
|
749
|
+
"groom.run": "server-side candidate query against retention_policies + memories; the offline store has no equivalent",
|
|
750
|
+
"memory.protect": "the offline store has no protected column in v1"
|
|
506
751
|
};
|
|
507
752
|
|
|
508
753
|
/**
|