@lorekit/cli 1.71.0 → 1.71.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 +69 -0
- package/bin/lorekit.mjs +35 -5
- package/package.json +1 -1
- package/src/commands/groom.mjs +4 -1
- package/src/commands/policy.mjs +27 -3
- package/src/shared/flags.mjs +73 -0
- package/src/store/remote.mjs +54 -11
package/README.md
CHANGED
|
@@ -667,6 +667,75 @@ The server's refusal is printed verbatim with a one-line next step; the CLI make
|
|
|
667
667
|
exactly one request and never retries, splits the sweep, or re-scopes around it.
|
|
668
668
|
Use an unscoped `lk_rw_*` / `lk_wo_*` token for maintenance.
|
|
669
669
|
|
|
670
|
+
### `lorekit groom` / `lorekit policy` / `lorekit protect` (aliases: `pin` / `unpin`)
|
|
671
|
+
|
|
672
|
+
Retention automation — the CLI counterpart to the MCP `groom.*`/`policy.*`/
|
|
673
|
+
`memory.protect` tools and the REST `/groom/*`/`/policies*`/`/protect` routes.
|
|
674
|
+
**Remote only**, matching `purge`: `retention_policies` has no local-store
|
|
675
|
+
equivalent.
|
|
676
|
+
|
|
677
|
+
```bash
|
|
678
|
+
# Preview what a rule would catch, without changing anything
|
|
679
|
+
lorekit groom --scope repo::acme/app --unseen-days 90
|
|
680
|
+
|
|
681
|
+
# Archive the matches (soft-archive, recoverable via `lorekit restore`)
|
|
682
|
+
lorekit groom --scope repo::acme/app --unseen-days 90 --run --yes
|
|
683
|
+
|
|
684
|
+
# Save it as a policy you can re-run by name, or let it sweep nightly
|
|
685
|
+
lorekit policy create --scope repo::acme/app --name "stale repo lore" \
|
|
686
|
+
--unseen-days 90 --mode review
|
|
687
|
+
lorekit policy list
|
|
688
|
+
lorekit policy update <id> --mode auto --enabled
|
|
689
|
+
lorekit policy delete <id>
|
|
690
|
+
|
|
691
|
+
# Exempt one lesson from every rule and every manual sweep
|
|
692
|
+
lorekit pin repo::acme/app::the-load-bearing-lesson
|
|
693
|
+
lorekit unpin repo::acme/app::the-load-bearing-lesson
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
`groom [--policy-id <id> | --scope <s> [conditions…]] [--run] [--yes]` previews
|
|
697
|
+
by default; `--run` archives. Exactly one of `--policy-id`/`--scope` is
|
|
698
|
+
required. `policy <list|create|update|delete>` manages the SAVED rules
|
|
699
|
+
`--policy-id` runs; `delete` removes the rule only, never the lessons it
|
|
700
|
+
matched.
|
|
701
|
+
|
|
702
|
+
Every condition ANDs together: the five age/activity fields
|
|
703
|
+
(`--min-age-days`/`--unseen-days`/`--max-seen-count`/`--max-read-count`/
|
|
704
|
+
`--max-opened-count`) plus the same **eight dimension filters** the Lore
|
|
705
|
+
Explorer's filter bar offers —
|
|
706
|
+
|
|
707
|
+
| Flag | `--<dim>-mode` values (default) |
|
|
708
|
+
|------|----------------------------------|
|
|
709
|
+
| `--tags <a,b>` | `any` \| `all` \| `none` (`any`) |
|
|
710
|
+
| `--kind <a,b>` | `in` \| `nin` (`in`) |
|
|
711
|
+
| `--host <a,b>` | `in` \| `nin` (`in`) |
|
|
712
|
+
| `--trigger <a,b>` | `in` \| `nin` (`in`) |
|
|
713
|
+
| `--source-agent <a,b>` | `in` \| `nin` (`in`) |
|
|
714
|
+
| `--origin-repo <a,b>` | `in` \| `nin` (`in`) |
|
|
715
|
+
| `--origin-branch <a,b>` | `in` \| `nin` (`in`) |
|
|
716
|
+
| `--origin-pr <a,b>` | `in` \| `nin` (`in`) |
|
|
717
|
+
|
|
718
|
+
— all comma-separated, same convention as `write --tags a,b,c`:
|
|
719
|
+
|
|
720
|
+
```bash
|
|
721
|
+
lorekit policy create --scope repo::acme/app --name "stale ci review events" \
|
|
722
|
+
--kind bus --kind-mode in --tags ci::pr-review-state --tags-mode all \
|
|
723
|
+
--min-age-days 30
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
`policy update` pairs every condition — age/count AND every dimension — with
|
|
727
|
+
a `--clear-<field>` boolean (e.g. `--clear-kind`, `--clear-min-age-days`) that
|
|
728
|
+
sends an explicit `null` rather than omitting the field: omitting a field on
|
|
729
|
+
update leaves it unchanged, `null` clears it.
|
|
730
|
+
|
|
731
|
+
```bash
|
|
732
|
+
lorekit policy update <id> --clear-kind --host reviewer,aw
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
`protect <scope::key> [--off]` (or the shorter `pin`/`unpin`) marks a lesson as
|
|
736
|
+
excluded from every policy and every `groom --run`, regardless of which rule
|
|
737
|
+
would otherwise match it — persists until explicitly cleared.
|
|
738
|
+
|
|
670
739
|
### `lorekit hook`
|
|
671
740
|
|
|
672
741
|
The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
|
package/bin/lorekit.mjs
CHANGED
|
@@ -935,7 +935,10 @@ ${c.bold('Options')}
|
|
|
935
935
|
${c.bold('Usage')}
|
|
936
936
|
lorekit groom --policy-id <id> [--run] [--yes] [--json]
|
|
937
937
|
lorekit groom --scope <s> [--min-age-days <n>] [--unseen-days <n>] [--max-seen-count <n>]
|
|
938
|
-
[--max-read-count <n>] [--max-opened-count <n>]
|
|
938
|
+
[--max-read-count <n>] [--max-opened-count <n>]
|
|
939
|
+
[--tags a,b --tags-mode any|all|none]
|
|
940
|
+
[--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr a,b [--<dim>-mode in|nin]]
|
|
941
|
+
[--run] [--yes] [--json]
|
|
939
942
|
|
|
940
943
|
Resolves the SAME candidates a saved policy or an inline condition set would
|
|
941
944
|
catch, via the retention-policy candidate query — a previewed count always
|
|
@@ -960,6 +963,12 @@ ${c.bold('Options')}
|
|
|
960
963
|
the hundreds — a small value matches nothing
|
|
961
964
|
--max-opened-count <n> Match only lessons an agent DELIBERATELY fetched at most n
|
|
962
965
|
times. Bulk reads do NOT count, so 0 means never chosen
|
|
966
|
+
--tags <a,b>, --tags-mode <any|all|none>
|
|
967
|
+
Match lessons carrying these labels (default: any)
|
|
968
|
+
--kind, --host, --trigger, --source-agent,
|
|
969
|
+
--origin-repo, --origin-branch, --origin-pr <a,b>
|
|
970
|
+
--<dim>-mode <in|nin> Match lessons whose dimension is one of these
|
|
971
|
+
values (default: in); comma-separated
|
|
963
972
|
--run Archive the matches instead of previewing
|
|
964
973
|
-y, --yes Confirm --run; required when non-interactive
|
|
965
974
|
--json Machine-readable result
|
|
@@ -974,12 +983,17 @@ ${c.bold('Usage')}
|
|
|
974
983
|
lorekit policy create --scope <s> --name <n> [--mode review|auto] [--enabled]
|
|
975
984
|
[--min-age-days <n>] [--unseen-days <n>] [--max-seen-count <n>]
|
|
976
985
|
[--max-read-count <n>] [--max-opened-count <n>]
|
|
986
|
+
[--tags a,b --tags-mode any|all|none]
|
|
987
|
+
[--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr a,b [--<dim>-mode in|nin]]
|
|
977
988
|
lorekit policy update <id> [--name <n>] [--mode review|auto] [--enabled|--disabled]
|
|
978
|
-
[--min-age-days <n>|--clear-min-age-days] [...]
|
|
989
|
+
[--min-age-days <n>|--clear-min-age-days] [...]
|
|
990
|
+
[--tags a,b|--clear-tags] [--<dim> a,b|--clear-<dim>] [--<dim>-mode in|nin] [--json]
|
|
979
991
|
lorekit policy delete <id> [--yes] [--json]
|
|
980
992
|
|
|
981
993
|
A policy is a saved retention rule: a scope plus AND-ed conditions
|
|
982
|
-
(min-age-days / unseen-days / max-seen-count / max-read-count / max-opened-count
|
|
994
|
+
(min-age-days / unseen-days / max-seen-count / max-read-count / max-opened-count,
|
|
995
|
+
plus the eight dimension filters — tags/kind/host/trigger/source-agent/
|
|
996
|
+
origin-repo/origin-branch/origin-pr).
|
|
983
997
|
\`mode: review\` surfaces it for
|
|
984
998
|
you to run by hand with ${c.cyan('lorekit groom --policy-id')}; \`mode: auto\` gets swept
|
|
985
999
|
nightly, but ONLY once you also pass --enabled — auto starts disabled on
|
|
@@ -995,8 +1009,14 @@ ${c.bold('Options')}
|
|
|
995
1009
|
--min-age-days <n>, --unseen-days <n>, --max-seen-count <n>, --max-read-count <n>,
|
|
996
1010
|
--max-opened-count <n>
|
|
997
1011
|
Conditions (create/update)
|
|
1012
|
+
--tags <a,b>, --tags-mode <any|all|none>
|
|
1013
|
+
--kind, --host, --trigger, --source-agent,
|
|
1014
|
+
--origin-repo, --origin-branch, --origin-pr <a,b>
|
|
1015
|
+
--<dim>-mode <in|nin> Dimension conditions (create/update); comma-separated
|
|
998
1016
|
--clear-min-age-days, --clear-unseen-days, --clear-max-seen-count,
|
|
999
|
-
--clear-max-read-count, --clear-max-opened-count
|
|
1017
|
+
--clear-max-read-count, --clear-max-opened-count,
|
|
1018
|
+
--clear-tags, --clear-kind, --clear-host, --clear-trigger,
|
|
1019
|
+
--clear-source-agent, --clear-origin-repo, --clear-origin-branch, --clear-origin-pr
|
|
1000
1020
|
Remove a condition (update only)
|
|
1001
1021
|
-y, --yes Confirm delete; required when non-interactive
|
|
1002
1022
|
--json Machine-readable result
|
|
@@ -1121,6 +1141,16 @@ const KNOWN_FLAGS = [
|
|
|
1121
1141
|
'name', 'mode', 'enabled', 'disabled',
|
|
1122
1142
|
'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count',
|
|
1123
1143
|
'clear-max-opened-count', 'off',
|
|
1144
|
+
// The eight retention dimension filters' `*-mode` value flags (the value
|
|
1145
|
+
// flags themselves — tags/source-agent/trigger/kind/host/origin-repo/
|
|
1146
|
+
// origin-branch/origin-pr — are already listed above for `write`) plus
|
|
1147
|
+
// their `clear-*` booleans (policy update only). See
|
|
1148
|
+
// `shared/flags.mjs`'s `parseDimensionConditions`, the one place that
|
|
1149
|
+
// owns the flag-name ↔ field-name ↔ mode-enum table these mirror.
|
|
1150
|
+
'tags-mode', 'source-agent-mode', 'trigger-mode', 'kind-mode', 'host-mode',
|
|
1151
|
+
'origin-repo-mode', 'origin-branch-mode', 'origin-pr-mode',
|
|
1152
|
+
'clear-tags', 'clear-source-agent', 'clear-trigger', 'clear-kind', 'clear-host',
|
|
1153
|
+
'clear-origin-repo', 'clear-origin-branch', 'clear-origin-pr',
|
|
1124
1154
|
// `obligations`
|
|
1125
1155
|
'files', 'strict', 'strict-all',
|
|
1126
1156
|
// `update`
|
|
@@ -1137,7 +1167,7 @@ async function main() {
|
|
|
1137
1167
|
const argv = process.argv.slice(2);
|
|
1138
1168
|
const args = parseArgs(argv, {
|
|
1139
1169
|
aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
|
|
1140
|
-
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', 'clear-max-read-count', 'clear-max-opened-count', 'strict', 'strict-all', 'check'],
|
|
1170
|
+
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', 'clear-max-read-count', 'clear-max-opened-count', 'strict', 'strict-all', 'clear-tags', 'clear-source-agent', 'clear-trigger', 'clear-kind', 'clear-host', 'clear-origin-repo', 'clear-origin-branch', 'clear-origin-pr', 'check'],
|
|
1141
1171
|
known: KNOWN_FLAGS,
|
|
1142
1172
|
});
|
|
1143
1173
|
|
package/package.json
CHANGED
package/src/commands/groom.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { resolveProjectRoot } from '../shared/config.mjs';
|
|
|
11
11
|
import { loadControl, resolveDenies } from '../shared/control.mjs';
|
|
12
12
|
import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs';
|
|
13
13
|
import { log, err, c, select } from '../shared/util.mjs';
|
|
14
|
-
import { parseIntFlag } from '../shared/flags.mjs';
|
|
14
|
+
import { parseIntFlag, parseDimensionConditions } from '../shared/flags.mjs';
|
|
15
15
|
|
|
16
16
|
/** Resolve the groom.preview/groom.run request from CLI args. */
|
|
17
17
|
export function parseGroomRequest(args) {
|
|
@@ -33,6 +33,8 @@ export function parseGroomRequest(args) {
|
|
|
33
33
|
if (maxRead.error) return { error: maxRead.error };
|
|
34
34
|
const maxOpened = parseIntFlag(args['max-opened-count'], 'max-opened-count');
|
|
35
35
|
if (maxOpened.error) return { error: maxOpened.error };
|
|
36
|
+
const dims = parseDimensionConditions(args, { clearable: false });
|
|
37
|
+
if (dims.error) return { error: dims.error };
|
|
36
38
|
|
|
37
39
|
return {
|
|
38
40
|
request: {
|
|
@@ -42,6 +44,7 @@ export function parseGroomRequest(args) {
|
|
|
42
44
|
max_seen_count: maxSeen.value,
|
|
43
45
|
max_read_count: maxRead.value,
|
|
44
46
|
max_opened_count: maxOpened.value,
|
|
47
|
+
...dims.conditions,
|
|
45
48
|
},
|
|
46
49
|
};
|
|
47
50
|
}
|
package/src/commands/policy.mjs
CHANGED
|
@@ -15,7 +15,19 @@ import { resolveProjectRoot } from '../shared/config.mjs';
|
|
|
15
15
|
import { loadControl, resolveDenies } from '../shared/control.mjs';
|
|
16
16
|
import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs';
|
|
17
17
|
import { log, err, c, select } from '../shared/util.mjs';
|
|
18
|
-
import { parseIntFlag } from '../shared/flags.mjs';
|
|
18
|
+
import { parseIntFlag, parseDimensionConditions } from '../shared/flags.mjs';
|
|
19
|
+
|
|
20
|
+
/** `field` → display label for `formatPolicy`'s dimension summary. */
|
|
21
|
+
const DIMENSION_DISPLAY = [
|
|
22
|
+
['tags', 'tags_mode', 'tags'],
|
|
23
|
+
['source_agent', 'source_agent_mode', 'source_agent'],
|
|
24
|
+
['trigger', 'trigger_mode', 'trigger'],
|
|
25
|
+
['kind', 'kind_mode', 'kind'],
|
|
26
|
+
['host', 'host_mode', 'host'],
|
|
27
|
+
['origin_repo', 'origin_repo_mode', 'origin_repo'],
|
|
28
|
+
['origin_branch', 'origin_branch_mode', 'origin_branch'],
|
|
29
|
+
['origin_pr', 'origin_pr_mode', 'origin_pr'],
|
|
30
|
+
];
|
|
19
31
|
|
|
20
32
|
function pickRemote({ root, env, args }) {
|
|
21
33
|
const { remoteDenied } = resolveDenies(root, { env });
|
|
@@ -32,6 +44,12 @@ function formatPolicy(p) {
|
|
|
32
44
|
if (p.max_seen_count != null) conditions.push(`max_seen_count=${p.max_seen_count}`);
|
|
33
45
|
if (p.max_read_count != null) conditions.push(`max_read_count=${p.max_read_count}`);
|
|
34
46
|
if (p.max_opened_count != null) conditions.push(`max_opened_count=${p.max_opened_count}`);
|
|
47
|
+
for (const [field, modeField, label] of DIMENSION_DISPLAY) {
|
|
48
|
+
const values = p[field];
|
|
49
|
+
if (!Array.isArray(values) || values.length === 0) continue;
|
|
50
|
+
const mode = p[modeField];
|
|
51
|
+
conditions.push(`${label}${mode ? `:${mode}` : ''}=[${values.join(',')}]`);
|
|
52
|
+
}
|
|
35
53
|
const mode = p.mode === 'auto' ? (p.enabled ? c.green('auto (enabled)') : c.dim('auto (disabled)')) : c.dim('review');
|
|
36
54
|
return `${c.cyan(p.id)} ${c.bold(p.name)} ${c.dim(p.scope)} ${mode}${conditions.length ? ` ${c.dim(conditions.join(', '))}` : ''}`;
|
|
37
55
|
}
|
|
@@ -52,7 +70,7 @@ async function list(args, store) {
|
|
|
52
70
|
|
|
53
71
|
async function create(args, store) {
|
|
54
72
|
if (!args.scope || !args.name) {
|
|
55
|
-
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] [--max-read-count N] [--max-opened-count N]`);
|
|
73
|
+
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] [--max-read-count N] [--max-opened-count N] [--tags a,b --tags-mode any|all|none] [--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr a,b [--<dim>-mode in|nin]]`);
|
|
56
74
|
return 1;
|
|
57
75
|
}
|
|
58
76
|
const minAge = parseIntFlag(args['min-age-days'], 'min-age-days');
|
|
@@ -69,6 +87,8 @@ async function create(args, store) {
|
|
|
69
87
|
err(`${c.red('Error:')} --mode must be "review" or "auto"`);
|
|
70
88
|
return 1;
|
|
71
89
|
}
|
|
90
|
+
const dims = parseDimensionConditions(args, { clearable: false });
|
|
91
|
+
if (dims.error) { err(`${c.red('Error:')} ${dims.error}`); return 1; }
|
|
72
92
|
|
|
73
93
|
const res = await store.policyCreate({
|
|
74
94
|
scope: args.scope,
|
|
@@ -80,6 +100,7 @@ async function create(args, store) {
|
|
|
80
100
|
max_seen_count: maxSeen.value,
|
|
81
101
|
max_read_count: maxRead.value,
|
|
82
102
|
max_opened_count: maxOpened.value,
|
|
103
|
+
...dims.conditions,
|
|
83
104
|
});
|
|
84
105
|
if (!res.ok) {
|
|
85
106
|
const msg = res.error?.message ?? res.error ?? res.networkError ?? 'the server rejected the request';
|
|
@@ -95,7 +116,7 @@ async function create(args, store) {
|
|
|
95
116
|
async function update(args, store) {
|
|
96
117
|
const id = args._[2];
|
|
97
118
|
if (!id) {
|
|
98
|
-
err(`${c.red('Usage:')} lorekit policy update <id> [--name N] [--mode review|auto] [--enabled|--disabled] [--min-age-days N] [
|
|
119
|
+
err(`${c.red('Usage:')} lorekit policy update <id> [--name N] [--mode review|auto] [--enabled|--disabled] [--min-age-days N|--clear-min-age-days] [...] [--tags a,b|--clear-tags] [--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr a,b|--clear-<dim>] [--<dim>-mode ...]`);
|
|
99
120
|
return 1;
|
|
100
121
|
}
|
|
101
122
|
if (args.mode !== undefined && args.mode !== 'review' && args.mode !== 'auto') {
|
|
@@ -121,6 +142,9 @@ async function update(args, store) {
|
|
|
121
142
|
if (parsed.error) { err(`${c.red('Error:')} ${parsed.error}`); return 1; }
|
|
122
143
|
patch[field] = parsed.value;
|
|
123
144
|
}
|
|
145
|
+
const dims = parseDimensionConditions(args, { clearable: true });
|
|
146
|
+
if (dims.error) { err(`${c.red('Error:')} ${dims.error}`); return 1; }
|
|
147
|
+
Object.assign(patch, dims.conditions);
|
|
124
148
|
if (Object.keys(patch).length === 0) {
|
|
125
149
|
err(`${c.red('Error:')} at least one field to update is required`);
|
|
126
150
|
return 1;
|
package/src/shared/flags.mjs
CHANGED
|
@@ -14,3 +14,76 @@ export function parseIntFlag(raw, name) {
|
|
|
14
14
|
}
|
|
15
15
|
return { value: Number(String(raw).trim()) };
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The eight dimension filters a retention policy / inline groom call can
|
|
20
|
+
* carry (migration 00093) — the CLI's flag-name ↔ field-name ↔ valid-mode
|
|
21
|
+
* table. Mirrors `groomDimensionFilterProperties` in
|
|
22
|
+
* `packages/schemas/src/shared/tool-catalog.ts` and `GROOM_DIMENSION_FIELDS`
|
|
23
|
+
* in `supabase/functions/mcp/tools.ts`; kept as ONE list here (not three) so
|
|
24
|
+
* `policy create`/`policy update`/`groom` cannot describe the dimensions
|
|
25
|
+
* differently.
|
|
26
|
+
*/
|
|
27
|
+
const DIMENSIONS = [
|
|
28
|
+
{ flag: 'tags', field: 'tags', modes: ['any', 'all', 'none'] },
|
|
29
|
+
{ flag: 'source-agent', field: 'source_agent', modes: ['in', 'nin'] },
|
|
30
|
+
{ flag: 'trigger', field: 'trigger', modes: ['in', 'nin'] },
|
|
31
|
+
{ flag: 'kind', field: 'kind', modes: ['in', 'nin'] },
|
|
32
|
+
{ flag: 'host', field: 'host', modes: ['in', 'nin'] },
|
|
33
|
+
{ flag: 'origin-repo', field: 'origin_repo', modes: ['in', 'nin'] },
|
|
34
|
+
{ flag: 'origin-branch', field: 'origin_branch', modes: ['in', 'nin'] },
|
|
35
|
+
{ flag: 'origin-pr', field: 'origin_pr', modes: ['in', 'nin'] },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse the eight retention dimension filters (`--tags`/`--kind`/`--host`/
|
|
40
|
+
* `--trigger`/`--source-agent`/`--origin-repo`/`--origin-branch`/`--origin-pr`,
|
|
41
|
+
* each with a `--<dim>-mode`) out of a parsed-args object.
|
|
42
|
+
*
|
|
43
|
+
* Comma-separated values — matching the existing `write --tags a,b,c`
|
|
44
|
+
* convention. The minimal flag parser (`bin/lorekit.mjs`'s `parseArgs`) is
|
|
45
|
+
* last-wins on a repeated flag, so comma-splitting is the established
|
|
46
|
+
* multi-value shape in this CLI, not a new one invented here.
|
|
47
|
+
*
|
|
48
|
+
* `--<dim>-mode` is validated against that DIMENSION's own enum (`tags-mode`
|
|
49
|
+
* accepts any/all/none; every other dimension accepts in/nin) — an invalid
|
|
50
|
+
* value is rejected rather than silently forwarded to the RPC as a raw
|
|
51
|
+
* Postgres enum error.
|
|
52
|
+
*
|
|
53
|
+
* With `clearable: true` (policy update), `--clear-<dim>` sends an explicit
|
|
54
|
+
* `null` for the VALUE field only, and skips both the value and mode flags for
|
|
55
|
+
* that dimension entirely — matching how `policy update --clear-min-age-days`
|
|
56
|
+
* already ignores `--min-age-days` when both are passed. The MODE field is
|
|
57
|
+
* left untouched by a clear: it is meaningless once the value is null, and
|
|
58
|
+
* silently dropping it if the caller separately set it would be a second
|
|
59
|
+
* surprise on top of the clear.
|
|
60
|
+
*
|
|
61
|
+
* Returns `{ conditions }` containing only the fields the caller actually
|
|
62
|
+
* named — never a full 16-key object of undefineds, so a caller can spread it
|
|
63
|
+
* straight into a request body without a second `stripUndefined` pass losing
|
|
64
|
+
* an intentional `null` — or `{ error }` naming the offending flag.
|
|
65
|
+
*/
|
|
66
|
+
export function parseDimensionConditions(args, { clearable = false } = {}) {
|
|
67
|
+
const conditions = {};
|
|
68
|
+
for (const { flag, field, modes } of DIMENSIONS) {
|
|
69
|
+
if (clearable && args[`clear-${flag}`]) {
|
|
70
|
+
conditions[field] = null;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const raw = args[flag];
|
|
74
|
+
if (raw !== undefined) {
|
|
75
|
+
conditions[field] = String(raw)
|
|
76
|
+
.split(',')
|
|
77
|
+
.map((s) => s.trim())
|
|
78
|
+
.filter((s) => s.length > 0);
|
|
79
|
+
}
|
|
80
|
+
const modeRaw = args[`${flag}-mode`];
|
|
81
|
+
if (modeRaw !== undefined) {
|
|
82
|
+
if (!modes.includes(modeRaw)) {
|
|
83
|
+
return { error: `--${flag}-mode must be one of ${modes.join('|')}, got ${JSON.stringify(String(modeRaw))}` };
|
|
84
|
+
}
|
|
85
|
+
conditions[`${field}_mode`] = modeRaw;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { conditions };
|
|
89
|
+
}
|
package/src/store/remote.mjs
CHANGED
|
@@ -42,6 +42,20 @@ function stripUndefined(obj) {
|
|
|
42
42
|
return out;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Drop only `undefined` — keeps an explicit `null`. Bare `stripUndefined`
|
|
46
|
+
// above is right for a CREATE body, where an omitted dimension and an
|
|
47
|
+
// explicit `null` mean the same "not filtered". It is wrong for a PATCH,
|
|
48
|
+
// where `null` is the caller's explicit instruction to CLEAR a
|
|
49
|
+
// previously-set condition (`policy update --clear-kind`) — dropping it
|
|
50
|
+
// there means the field silently stays whatever it already was, which is
|
|
51
|
+
// the opposite of what was asked. Named for what it keeps, not what it
|
|
52
|
+
// drops, since "strip" alone reads as "strip everything falsy-ish".
|
|
53
|
+
function stripUndefinedKeepNull(obj) {
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const [k, v] of Object.entries(obj || {})) if (v !== undefined) out[k] = v;
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
/**
|
|
46
60
|
* A read that could not be answered — a transport failure or a non-2xx status,
|
|
47
61
|
* as opposed to "the lesson is not there".
|
|
@@ -768,9 +782,20 @@ class RemoteStore {
|
|
|
768
782
|
return { ok: true, entries: Array.isArray(res.data?.entries) ? res.data.entries : [] };
|
|
769
783
|
}
|
|
770
784
|
|
|
771
|
-
// POST /policies → the created policy object.
|
|
772
|
-
|
|
773
|
-
|
|
785
|
+
// POST /policies → the created policy object. `...dims` carries whichever
|
|
786
|
+
// of the eight dimension filters (+ their `_mode` companions) the caller
|
|
787
|
+
// named — see `parseDimensionConditions` in `shared/flags.mjs`, the shared
|
|
788
|
+
// parser policy create/update and groom all go through.
|
|
789
|
+
async policyCreate({
|
|
790
|
+
scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
|
|
791
|
+
tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
|
|
792
|
+
origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
|
|
793
|
+
} = {}) {
|
|
794
|
+
const body = stripUndefined({
|
|
795
|
+
scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
|
|
796
|
+
tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
|
|
797
|
+
origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
|
|
798
|
+
});
|
|
774
799
|
const res = await this._rest('/memories/policies', { method: 'POST', body });
|
|
775
800
|
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
776
801
|
return { ok: true, policy: res.data };
|
|
@@ -778,10 +803,11 @@ class RemoteStore {
|
|
|
778
803
|
|
|
779
804
|
// PATCH /policies/:id → the updated policy object. An omitted field is left
|
|
780
805
|
// unchanged; pass an explicit `null` in the patch to clear a condition —
|
|
781
|
-
// this method does not strip nulls, only `undefined` (
|
|
782
|
-
// that distinction, which is the whole point of the RPC's JSONB-patch
|
|
806
|
+
// this method does not strip nulls, only `undefined` (stripUndefinedKeepNull
|
|
807
|
+
// keeps that distinction, which is the whole point of the RPC's JSONB-patch
|
|
808
|
+
// design and of `policy update --clear-*`).
|
|
783
809
|
async policyUpdate(id, patch = {}) {
|
|
784
|
-
const body =
|
|
810
|
+
const body = stripUndefinedKeepNull(patch);
|
|
785
811
|
const res = await this._rest(`/memories/policies/${encodeURIComponent(id)}`, { method: 'PATCH', body });
|
|
786
812
|
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
787
813
|
return { ok: true, policy: res.data };
|
|
@@ -796,9 +822,18 @@ class RemoteStore {
|
|
|
796
822
|
|
|
797
823
|
// POST /groom/preview → { count, keys: [{ scope, key }] } — the SAME
|
|
798
824
|
// candidates a groom() run would archive. Pass either `policy_id` or
|
|
799
|
-
// `scope` (+ optional conditions
|
|
800
|
-
|
|
801
|
-
|
|
825
|
+
// `scope` (+ optional conditions, including the eight dimension filters —
|
|
826
|
+
// see policyCreate's comment), never both.
|
|
827
|
+
async groomPreview({
|
|
828
|
+
policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
|
|
829
|
+
tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
|
|
830
|
+
origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
|
|
831
|
+
} = {}) {
|
|
832
|
+
const body = stripUndefined({
|
|
833
|
+
policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
|
|
834
|
+
tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
|
|
835
|
+
origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
|
|
836
|
+
});
|
|
802
837
|
const res = await this._rest('/memories/groom/preview', { method: 'POST', body });
|
|
803
838
|
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
804
839
|
return { ok: true, count: res.data?.count ?? 0, keys: Array.isArray(res.data?.keys) ? res.data.keys : [] };
|
|
@@ -806,8 +841,16 @@ class RemoteStore {
|
|
|
806
841
|
|
|
807
842
|
// POST /groom/run → archives every previewed candidate, in one transaction.
|
|
808
843
|
// Soft-archive only (recoverable via restore); never hard-deletes.
|
|
809
|
-
async groomRun({
|
|
810
|
-
|
|
844
|
+
async groomRun({
|
|
845
|
+
policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
|
|
846
|
+
tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
|
|
847
|
+
origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
|
|
848
|
+
} = {}) {
|
|
849
|
+
const body = stripUndefined({
|
|
850
|
+
policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
|
|
851
|
+
tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
|
|
852
|
+
origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
|
|
853
|
+
});
|
|
811
854
|
const res = await this._rest('/memories/groom/run', { method: 'POST', body });
|
|
812
855
|
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
813
856
|
return { ok: true, archived: res.data?.archived ?? 0, keys: Array.isArray(res.data?.keys) ? res.data.keys : [] };
|