@staix/agent-mem 0.10.0 → 0.12.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/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -2
- package/package.json +1 -1
- package/src/cli.ts +26 -10
- package/src/commands-table.ts +2 -2
- package/src/index.ts +1 -0
- package/src/mcp-memory.ts +6 -2
- package/src/mcp.ts +1 -1
- package/src/memory/candidates.ts +44 -1
- package/src/memory/commands.ts +14 -4
- package/src/memory/consolidate.ts +9 -6
- package/src/memory/consolidation-clusters.ts +12 -7
- package/src/memory/doctor.ts +41 -7
- package/src/memory/eval-fixture.ts +7 -3
- package/src/memory/eval.ts +15 -6
- package/src/memory/external-import.ts +8 -1
- package/src/memory/platform.ts +8 -8
- package/src/memory/project-import.ts +31 -14
- package/src/memory/search-fts.ts +15 -6
- package/src/memory/search-score.ts +66 -0
- package/src/memory/status-text.ts +47 -0
- package/src/memory/store.ts +40 -13
- package/src/memory/style.ts +37 -0
- package/src/setup-toml.ts +5 -5
- package/src/setup.ts +76 -14
|
@@ -79,18 +79,22 @@ export function evalSeeded(set: EvalSet, ks?: readonly number[], rank?: RankFn):
|
|
|
79
79
|
const base = join(dir, "memory");
|
|
80
80
|
mkdirSync(base, { recursive: true });
|
|
81
81
|
// One entry per day, so the recency weight (a 30-day scale) is exercised, long-tier entries first and short-tier ones last: a short-term note leaves the index 14 days after it is
|
|
82
|
-
// written, and the clock stops a day after the last entry, so the short ones written in that window are live when the set is searched (a set with more short entries than that
|
|
82
|
+
// written, and the undated clock stops a day after the last entry, so the short ones written in that window are live when the set is searched (a set with more short entries than that
|
|
83
83
|
// window holds loses its oldest, and a question naming one is then reported as not in the store, like any other missing entry).
|
|
84
|
+
// Explicit dates override only their entry; undated entries retain their legacy file-order day.
|
|
85
|
+
// Search a day after the latest actual seed time unless the set supplies its own clock.
|
|
84
86
|
let current = EPOCH;
|
|
85
87
|
store = new MemoryStore({ memoryDir: base, now: () => current, screen: () => "keep" });
|
|
86
88
|
const ids = new Map<string, string>();
|
|
87
89
|
const ordered = [...set.entries.filter(entry => entry.tier !== "short"), ...set.entries.filter(entry => entry.tier === "short")];
|
|
90
|
+
let latest = -Infinity;
|
|
88
91
|
for (const [index, entry] of ordered.entries()) {
|
|
89
|
-
current = EPOCH + index * DAY;
|
|
92
|
+
current = entry.at === undefined ? EPOCH + index * DAY : Date.parse(entry.at);
|
|
93
|
+
latest = Math.max(latest, current);
|
|
90
94
|
ids.set(entry.key, store.note({ op: "note", project: PROJECT, scope: "project", tier: entry.tier ?? "long", kind: entry.kind ?? "finding", peer: "eval", source: "person",
|
|
91
95
|
...(entry.title ? { title: entry.title } : {}), text: entry.text, about: entry.about ?? [] }, entry.tier === "short"));
|
|
92
96
|
}
|
|
93
|
-
current =
|
|
97
|
+
current = set.now === undefined ? latest + DAY : Date.parse(set.now);
|
|
94
98
|
const window = Math.max(...(ks ?? [10]));
|
|
95
99
|
const search = rank ? { rank: (question: string) => rank(store!, question, window), engine: () => "custom" } : ranker(store, PROJECT, window);
|
|
96
100
|
// Resolved through `store.get`, so a note that has already expired is absent, not silently present: coverage stays separate from ranking.
|
package/src/memory/eval.ts
CHANGED
|
@@ -12,10 +12,10 @@ export const MAX_QUESTIONS = 500, MAX_ENTRIES = 200, MAX_QUESTION_CHARS = 500, M
|
|
|
12
12
|
export const DEFAULT_KS: readonly number[] = [1, 5, 10];
|
|
13
13
|
|
|
14
14
|
export const EVAL_KINDS = ["decision", "finding", "contract", "fail"] as const;
|
|
15
|
-
export interface EvalEntry { key: string; title?: string; text: string; kind?: (typeof EVAL_KINDS)[number]; tier?: "short" | "long"; about?: string[] }
|
|
15
|
+
export interface EvalEntry { key: string; title?: string; text: string; kind?: (typeof EVAL_KINDS)[number]; tier?: "short" | "long"; about?: string[]; /** Explicit seed date; absent keeps the legacy positional day. */ at?: string }
|
|
16
16
|
/** `tag` is an optional label for what a question probes (a test counts them); it never changes the scoring. */
|
|
17
17
|
export interface EvalQuestion { q: string; expect: string[]; tag?: string }
|
|
18
|
-
export interface EvalSet { schema: typeof EVAL_SCHEMA; entries?: EvalEntry[]; questions: EvalQuestion[] }
|
|
18
|
+
export interface EvalSet { schema: typeof EVAL_SCHEMA; entries?: EvalEntry[]; questions: EvalQuestion[]; /** Search clock for a self-contained set; defaults to one day after its latest seed. */ now?: string }
|
|
19
19
|
|
|
20
20
|
const fail = (message: string): never => { throw new Error(`invalid eval set: ${message}`); };
|
|
21
21
|
const object = (value: unknown, what: string): Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : fail(`${what} must be an object`);
|
|
@@ -24,17 +24,25 @@ function exact(row: Record<string, unknown>, allowed: readonly string[], what: s
|
|
|
24
24
|
}
|
|
25
25
|
const text = (value: unknown, what: string, max: number): string => typeof value === "string" && value.trim() !== "" && value.length <= max && !/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value) ? value : fail(`${what} must be a non-empty string of at most ${max} characters`);
|
|
26
26
|
|
|
27
|
+
/** ISO calendar date or canonical UTC timestamp; reject Date.parse's rollover and locale-dependent forms. */
|
|
28
|
+
function date(value: unknown, what: string): string {
|
|
29
|
+
if (typeof value !== "string" || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}(?:T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:[.][0-9]{3})?Z)?$/.test(value)) return fail(`${what} must be an ISO date or UTC timestamp`);
|
|
30
|
+
const ms = Date.parse(value), normalized = value.length === 10 ? value + "T00:00:00.000Z" : value.length === 20 ? value.slice(0, -1) + ".000Z" : value;
|
|
31
|
+
if (!Number.isFinite(ms) || new Date(ms).toISOString() !== normalized) return fail(`${what} must be a valid ISO date or UTC timestamp`);
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
/** Strict, bounded parse of untrusted JSON. Unknown keys are errors, so a typo cannot silently change what is measured. */
|
|
28
36
|
export function parseEvalSet(raw: unknown): EvalSet {
|
|
29
37
|
const root = object(raw, "the set");
|
|
30
|
-
exact(root, ["schema", "entries", "questions"], "the set");
|
|
38
|
+
exact(root, ["schema", "entries", "questions", "now"], "the set");
|
|
31
39
|
if (root.schema !== EVAL_SCHEMA) fail(`schema must be "${EVAL_SCHEMA}"`);
|
|
32
40
|
const entries: EvalEntry[] = [], keys = new Set<string>();
|
|
33
41
|
if (root.entries !== undefined) {
|
|
34
42
|
if (!Array.isArray(root.entries) || !root.entries.length || root.entries.length > MAX_ENTRIES) fail(`entries must hold 1 to ${MAX_ENTRIES} items when present`);
|
|
35
43
|
for (const [index, item] of (root.entries as unknown[]).entries()) {
|
|
36
44
|
const row = object(item, `entries[${index}]`);
|
|
37
|
-
exact(row, ["key", "title", "text", "kind", "tier", "about"], `entries[${index}]`);
|
|
45
|
+
exact(row, ["key", "title", "text", "kind", "tier", "about", "at"], `entries[${index}]`);
|
|
38
46
|
const key = text(row.key, `entries[${index}].key`, 80);
|
|
39
47
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(key)) fail(`entries[${index}].key may use letters, digits, ".", "_" and "-"`);
|
|
40
48
|
if (keys.has(key)) fail(`entries[${index}].key "${key}" repeats`);
|
|
@@ -42,9 +50,10 @@ export function parseEvalSet(raw: unknown): EvalSet {
|
|
|
42
50
|
const kind = row.kind === undefined ? undefined : (EVAL_KINDS as readonly unknown[]).includes(row.kind) ? row.kind as EvalEntry["kind"] : fail(`entries[${index}].kind must be one of ${EVAL_KINDS.join(", ")}`);
|
|
43
51
|
const tier = row.tier === undefined ? undefined : row.tier === "short" || row.tier === "long" ? row.tier : fail(`entries[${index}].tier must be short or long`);
|
|
44
52
|
const about = row.about === undefined ? undefined : Array.isArray(row.about) && row.about.length <= 20 ? (row.about as unknown[]).map((value, at) => text(value, `entries[${index}].about[${at}]`, 200)) : fail(`entries[${index}].about must be a list of at most 20 strings`);
|
|
45
|
-
entries.push({ key, text: text(row.text, `entries[${index}].text`, MAX_ENTRY_CHARS), ...(row.title === undefined ? {} : { title: text(row.title, `entries[${index}].title`, 200) }), ...(kind ? { kind } : {}), ...(tier ? { tier } : {}), ...(about ? { about } : {}) });
|
|
53
|
+
entries.push({ key, text: text(row.text, `entries[${index}].text`, MAX_ENTRY_CHARS), ...(row.title === undefined ? {} : { title: text(row.title, `entries[${index}].title`, 200) }), ...(kind ? { kind } : {}), ...(tier ? { tier } : {}), ...(about ? { about } : {}), ...(row.at === undefined ? {} : { at: date(row.at, `entries[${index}].at`) }) });
|
|
46
54
|
}
|
|
47
55
|
}
|
|
56
|
+
if (root.now !== undefined && !entries.length) fail("now requires self-contained entries");
|
|
48
57
|
if (!Array.isArray(root.questions) || !root.questions.length || root.questions.length > MAX_QUESTIONS) fail(`questions must hold 1 to ${MAX_QUESTIONS} items`);
|
|
49
58
|
const questions = (root.questions as unknown[]).map((item, index): EvalQuestion => {
|
|
50
59
|
const row = object(item, `questions[${index}]`);
|
|
@@ -58,7 +67,7 @@ export function parseEvalSet(raw: unknown): EvalSet {
|
|
|
58
67
|
}
|
|
59
68
|
return { q: text(row.q, `questions[${index}].q`, MAX_QUESTION_CHARS), expect, ...(tag ? { tag } : {}) };
|
|
60
69
|
});
|
|
61
|
-
return { schema: EVAL_SCHEMA, ...(entries.length ? { entries } : {}), questions };
|
|
70
|
+
return { schema: EVAL_SCHEMA, ...(entries.length ? { entries } : {}), questions, ...(root.now === undefined ? {} : { now: date(root.now, "now") }) };
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
/** `--k 1,5,10`: ascending, unique, 1 to MAX_K. */
|
|
@@ -206,7 +206,14 @@ function title(source: ExternalMemorySource, file: SourceFile, record: RawRecord
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
function portable(path: string): string { return path.split(sep).join("/"); }
|
|
209
|
-
|
|
209
|
+
/** Claude Code 2.1.311's project-path encoding, including its long-name suffix. */
|
|
210
|
+
function claudeProjectDirectory(cwd: string): string {
|
|
211
|
+
const path = realPath(cwd), encoded = path.replace(/[^a-zA-Z0-9]/g, "-");
|
|
212
|
+
if (encoded.length <= 200) return encoded;
|
|
213
|
+
let hash = 0;
|
|
214
|
+
for (let index = 0; index < path.length; index++) hash = (Math.imul(hash, 31) + path.charCodeAt(index)) | 0;
|
|
215
|
+
return `${encoded.slice(0, 200)}-${Math.abs(hash).toString(36)}`;
|
|
216
|
+
}
|
|
210
217
|
|
|
211
218
|
export async function importExternalMemory(opts: {
|
|
212
219
|
base: string;
|
package/src/memory/platform.ts
CHANGED
|
@@ -103,18 +103,18 @@ export function memoryGitEnv(env: NodeJS.ProcessEnv = process.env, options: { re
|
|
|
103
103
|
/** spawnSync form of the same call: the env option is merged with memoryGitEnv, everything else passes through. */
|
|
104
104
|
export function memoryGitSync(args: string[], options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<string> {
|
|
105
105
|
const argv = memoryGitArgv(args);
|
|
106
|
-
|
|
106
|
+
const timeout = gitTimeoutContext?.getStore();
|
|
107
|
+
return childProcess().spawnSync(argv[0]!, argv.slice(1), { ...(timeout !== undefined ? { timeout, killSignal: "SIGKILL" as const } : {}), ...options, env: memoryGitEnv(options.env ?? process.env) });
|
|
107
108
|
}
|
|
108
|
-
let
|
|
109
|
+
let gitTimeoutContext: import("node:async_hooks").AsyncLocalStorage<number> | undefined;
|
|
109
110
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* ponytail: one global instead of a parameter on every caller between doctor and its git calls (project key, repository root, visibility). Upgrade: thread an options argument through them.
|
|
111
|
+
* Bound `memoryGitSync` calls inside this async operation unless a call supplies its own timeout.
|
|
112
|
+
* Nested and concurrent operations keep independent contexts; unrelated host calls retain their own options.
|
|
113
|
+
* Load async_hooks only when a caller requests a deadline, keeping path-only hook imports unchanged.
|
|
114
114
|
*/
|
|
115
115
|
export async function withGitTimeout<T>(ms: number, work: () => Promise<T>): Promise<T> {
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
gitTimeoutContext ??= new (require("node:async_hooks").AsyncLocalStorage)();
|
|
117
|
+
return gitTimeoutContext!.run(ms, work);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
/**
|
|
@@ -15,7 +15,7 @@ const NOTE_KEYS = new Set(["schema", "id", "at", "machine", "op", "project", "sc
|
|
|
15
15
|
const OPERATIONS_WARNING = "Supersede, demote, settle and forget operations are not exported; re-import restores authored note rows only.";
|
|
16
16
|
|
|
17
17
|
type ImportedNote = { id: string; at: string; machine: string; note: NoteInput };
|
|
18
|
-
type Provenance = { source: Record<string, number>; peer: Record<string, number>; scope: Record<string, number> };
|
|
18
|
+
type Provenance = { source: Record<string, number>; peer: Record<string, number>; scope: Record<string, number>; machine: Record<string, number> };
|
|
19
19
|
export interface ProjectImportResult {
|
|
20
20
|
applied: boolean;
|
|
21
21
|
project: string;
|
|
@@ -46,13 +46,18 @@ function increment(counts: Map<string, number>, value: string | undefined): void
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
function provenance(notes: Iterable<ImportedNote>): Provenance {
|
|
49
|
-
const source = new Map<string, number>(), peer = new Map<string, number>(), scope = new Map<string, number>();
|
|
50
|
-
for (const { note } of notes) {
|
|
49
|
+
const source = new Map<string, number>(), peer = new Map<string, number>(), scope = new Map<string, number>(), machine = new Map<string, number>();
|
|
50
|
+
for (const { note, machine: origin } of notes) {
|
|
51
51
|
increment(source, note.source);
|
|
52
52
|
increment(peer, note.peer);
|
|
53
53
|
increment(scope, note.scope);
|
|
54
|
+
increment(machine, origin);
|
|
54
55
|
}
|
|
55
|
-
return { source: Object.fromEntries(source), peer: Object.fromEntries(peer), scope: Object.fromEntries(scope) };
|
|
56
|
+
return { source: Object.fromEntries(source), peer: Object.fromEntries(peer), scope: Object.fromEntries(scope), machine: Object.fromEntries(machine) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function relationTargets(imported: ImportedNote): string[] {
|
|
60
|
+
return [imported.note.supersedes, imported.note.promotedFrom].filter((target): target is string => typeof target === "string");
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
function unchangedAfterNormalization(note: NoteInput): boolean {
|
|
@@ -71,10 +76,10 @@ export async function importProject(store: MemoryStore, opts: {
|
|
|
71
76
|
const fd = openSync(opts.from, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
72
77
|
const stream = createReadStream(opts.from, { fd, encoding: "utf8" });
|
|
73
78
|
const result: ProjectImportResult = { applied: opts.confirm === true, project: opts.project, read: 0, added: 0, wouldAdd: 0,
|
|
74
|
-
skipped: { invalid: 0, policy: 0, other: 0 }, provenance: { source: {}, peer: {}, scope: {} }, warnings: [OPERATIONS_WARNING] };
|
|
79
|
+
skipped: { invalid: 0, policy: 0, other: 0 }, provenance: { source: {}, peer: {}, scope: {}, machine: {} }, warnings: [OPERATIONS_WARNING] };
|
|
75
80
|
let header = false, bytes = 0;
|
|
76
81
|
let aliases: string[] = [];
|
|
77
|
-
const pending = new Map<string, ImportedNote>(), accepted
|
|
82
|
+
const pending = new Map<string, ImportedNote>(), accepted = new Map<string, ImportedNote>();
|
|
78
83
|
try {
|
|
79
84
|
const stat = fstatSync(fd);
|
|
80
85
|
if (!stat.isFile() || stat.size > PROJECT_IMPORT_MAX_BYTES) throw new Error("project import needs a regular JSONL file of at most 64 MiB");
|
|
@@ -121,22 +126,34 @@ export async function importProject(store: MemoryStore, opts: {
|
|
|
121
126
|
if (!header) throw new Error("project import has no header");
|
|
122
127
|
|
|
123
128
|
for (const imported of pending.values()) {
|
|
124
|
-
if (
|
|
125
|
-
const target = imported.note[field as "supersedes" | "promotedFrom"] as string | null | undefined;
|
|
126
|
-
return target !== undefined && target !== null && !pending.has(target);
|
|
127
|
-
})) {
|
|
129
|
+
if (relationTargets(imported).some(target => !pending.has(target))) {
|
|
128
130
|
result.skipped.invalid++;
|
|
129
131
|
continue;
|
|
130
132
|
}
|
|
131
133
|
const screened = stripPrivateDeep(imported.note);
|
|
132
134
|
if (looksSecret(JSON.stringify(screened)) || !await opts.policy(screened)) { result.skipped.policy++; continue; }
|
|
133
|
-
accepted.
|
|
135
|
+
accepted.set(imported.id, imported);
|
|
136
|
+
}
|
|
137
|
+
// A policy-refused target cannot authorize its dependents. Propagate removals before any append,
|
|
138
|
+
// regardless of file order, in linear time even when a long relation chain is refused.
|
|
139
|
+
const dependents = new Map<string, string[]>(), refused: string[] = [];
|
|
140
|
+
for (const imported of accepted.values()) for (const target of relationTargets(imported)) {
|
|
141
|
+
const rows = dependents.get(target) ?? [];
|
|
142
|
+
rows.push(imported.id); dependents.set(target, rows);
|
|
143
|
+
if (!accepted.has(target)) refused.push(imported.id);
|
|
144
|
+
}
|
|
145
|
+
for (let at = 0; at < refused.length; at++) {
|
|
146
|
+
const id = refused[at]!;
|
|
147
|
+
if (!accepted.delete(id)) continue;
|
|
148
|
+
result.skipped.invalid++;
|
|
149
|
+
for (const dependent of dependents.get(id) ?? []) refused.push(dependent);
|
|
134
150
|
}
|
|
135
|
-
result.provenance = provenance(accepted);
|
|
151
|
+
result.provenance = provenance(accepted.values());
|
|
136
152
|
const existing = new Set(store.portableNotes(opts.project).map(note => note.id));
|
|
137
|
-
result.wouldAdd = accepted.filter(
|
|
153
|
+
result.wouldAdd = [...accepted.keys()].filter(id => !existing.has(id)).length;
|
|
138
154
|
if (opts.confirm === true) {
|
|
139
|
-
|
|
155
|
+
store.assertImportable([...accepted.values()].map(imported => imported.note));
|
|
156
|
+
for (const imported of accepted.values()) {
|
|
140
157
|
const written = store.importNote(imported.note, imported.at, { machine: imported.machine, expectedId: imported.id });
|
|
141
158
|
if (written.added) result.added++;
|
|
142
159
|
}
|
package/src/memory/search-fts.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rankedScoreSql, rankedScoreSqlDetails, type ScoreFactors } from "./search-score.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import { chmodSync, existsSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -187,6 +188,8 @@ export class SearchIndex {
|
|
|
187
188
|
close(): void { this.db?.close(); }
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
const SCREENED_SCORE = rankedScoreSql(), SCREENED_SUBSTRING_SCORE = rankedScoreSql("-1.0");
|
|
192
|
+
|
|
190
193
|
/** Screened query-local corpus in SQLite-owned TEMP storage, removed even when the process exits. */
|
|
191
194
|
export class ScreenedSearchIndex<T> {
|
|
192
195
|
readonly available = fts5Available();
|
|
@@ -222,15 +225,21 @@ export class ScreenedSearchIndex<T> {
|
|
|
222
225
|
})();
|
|
223
226
|
}
|
|
224
227
|
|
|
225
|
-
best(query: string, limit: number, now: number, useFts = this.available): { value: T; score: number }[] {
|
|
228
|
+
best(query: string, limit: number, now: number, useFts = this.available, explain = false): { value: T; score: number; scoreDetails?: ScoreFactors }[] {
|
|
226
229
|
if (useFts && !ftsTerms(query)) return [];
|
|
227
|
-
const
|
|
230
|
+
const score = useFts ? SCREENED_SCORE : SCREENED_SUBSTRING_SCORE;
|
|
228
231
|
const instant = new Date(now).toISOString();
|
|
229
232
|
const where = "WHERE (expires IS NULL OR expires > ?)" + (useFts ? " AND docs MATCH ?" : "");
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
233
|
+
const factors = explain ? rankedScoreSqlDetails(useFts ? "bm25(docs)" : "-1.0", useFts) : undefined;
|
|
234
|
+
const columns = factors ? `, ${factors.rawBm25Rank} AS rawBm25Rank, ${factors.tierFactor} AS tierFactor, ${factors.recencyFactor} AS recencyFactor` : "";
|
|
235
|
+
const statement = this.db.query(`SELECT payload, ${score} AS score${columns} FROM docs ${where} ORDER BY score DESC, ref LIMIT ?`);
|
|
236
|
+
const rows = (explain ? statement.all(instant, instant, now, ...(useFts ? [ftsTerms(query)] : []), limit)
|
|
237
|
+
: statement.all(instant, now, ...(useFts ? [ftsTerms(query)] : []), limit)) as { payload: string; score: number; rawBm25Rank?: number | null; tierFactor?: number | null; recencyFactor?: number | null }[];
|
|
238
|
+
return rows.map(row => {
|
|
239
|
+
const hit: { value: T; score: number; scoreDetails?: ScoreFactors } = { value: JSON.parse(row.payload) as T, score: row.score };
|
|
240
|
+
if (explain) hit.scoreDetails = { rawBm25Rank: row.rawBm25Rank ?? null, tierFactor: row.tierFactor ?? null, recencyFactor: row.recencyFactor ?? null, finalScore: row.score, matchMode: useFts ? "fts5" : "substring" };
|
|
241
|
+
return hit;
|
|
242
|
+
});
|
|
234
243
|
}
|
|
235
244
|
|
|
236
245
|
close(): void {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** One fixed score recipe for numeric library ranking and native screened SQL. */
|
|
2
|
+
export interface ScoreFactors {
|
|
3
|
+
rawBm25Rank: number | null;
|
|
4
|
+
tierFactor: number | null;
|
|
5
|
+
recencyFactor: number | null;
|
|
6
|
+
finalScore: number | null;
|
|
7
|
+
matchMode: "fts5" | "substring";
|
|
8
|
+
}
|
|
9
|
+
export interface SearchScoreDetails extends ScoreFactors { source: "curated" | "capture" | "repo" }
|
|
10
|
+
|
|
11
|
+
type ScoreExpr = { op: "rank" | "tier" | "age" } | { op: "constant"; value: number }
|
|
12
|
+
| { op: "negative" | "reciprocal"; value: ScoreExpr }
|
|
13
|
+
| { op: "add" | "multiply" | "max"; left: ScoreExpr; right: ScoreExpr };
|
|
14
|
+
const MONTH_DAYS = 30;
|
|
15
|
+
const TIER_WEIGHTS: Readonly<Record<string, number>> = { long: 1, capture: 0.5 };
|
|
16
|
+
const DEFAULT_TIER_WEIGHT = 0.9;
|
|
17
|
+
const TIER_FACTOR: ScoreExpr = { op: "tier" };
|
|
18
|
+
const DENOMINATOR: ScoreExpr = { op: "add", left: { op: "constant", value: 1 }, right: { op: "max", left: { op: "constant", value: 0 }, right: { op: "age" } } };
|
|
19
|
+
const RECENCY_FACTOR: ScoreExpr = { op: "reciprocal", value: DENOMINATOR };
|
|
20
|
+
const SCORE: ScoreExpr = { op: "multiply", left: { op: "multiply", left: { op: "negative", value: { op: "rank" } }, right: TIER_FACTOR }, right: RECENCY_FACTOR };
|
|
21
|
+
// ponytail: this represents only the fixed rank/tier/age recipe, not arbitrary expressions;
|
|
22
|
+
// extend its operations with numeric/SQL parity tests when the ranking contract changes.
|
|
23
|
+
function numeric(expr: ScoreExpr, rank: number, tier: string, at: string, now: number): number {
|
|
24
|
+
switch (expr.op) {
|
|
25
|
+
case "rank": return rank;
|
|
26
|
+
case "tier": return Object.hasOwn(TIER_WEIGHTS, tier) ? TIER_WEIGHTS[tier]! : DEFAULT_TIER_WEIGHT;
|
|
27
|
+
case "age": return (now - Date.parse(at)) / (MONTH_DAYS * 24 * 3600_000);
|
|
28
|
+
case "constant": return expr.value;
|
|
29
|
+
case "negative": return -numeric(expr.value, rank, tier, at, now);
|
|
30
|
+
case "reciprocal": return 1 / numeric(expr.value, rank, tier, at, now);
|
|
31
|
+
case "add": return numeric(expr.left, rank, tier, at, now) + numeric(expr.right, rank, tier, at, now);
|
|
32
|
+
case "multiply": return numeric(expr.left, rank, tier, at, now) * numeric(expr.right, rank, tier, at, now);
|
|
33
|
+
case "max": return Math.max(numeric(expr.left, rank, tier, at, now), numeric(expr.right, rank, tier, at, now));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const real = (value: number): string => Number.isInteger(value) ? `${value}.0` : String(value);
|
|
37
|
+
function sql(expr: ScoreExpr, rank: string): string {
|
|
38
|
+
const get = (part: ScoreExpr): string => sql(part, rank);
|
|
39
|
+
switch (expr.op) {
|
|
40
|
+
case "rank": return rank;
|
|
41
|
+
case "tier": return `CASE tier ${Object.entries(TIER_WEIGHTS).map(([name, value]) => `WHEN '${name}' THEN ${real(value)}`).join(" ")} ELSE ${real(DEFAULT_TIER_WEIGHT)} END`;
|
|
42
|
+
case "age": return `((julianday(?) - julianday(at)) / ${real(MONTH_DAYS)})`;
|
|
43
|
+
case "constant": return real(expr.value);
|
|
44
|
+
case "negative": return `(-(${get(expr.value)}))`;
|
|
45
|
+
case "reciprocal": return `(1.0 / ${get(expr.value)})`;
|
|
46
|
+
case "add": return `(${get(expr.left)} + ${get(expr.right)})`;
|
|
47
|
+
// Preserve the existing SQL quotient's floating-point order; numeric ranking
|
|
48
|
+
// retains its original reciprocal product. Both interpret the same recipe.
|
|
49
|
+
case "multiply": return expr.right.op === "reciprocal" ? `(${get(expr.left)} / ${get(expr.right.value)})` : `(${get(expr.left)} * ${get(expr.right)})`;
|
|
50
|
+
case "max": return `max(${get(expr.left)}, ${get(expr.right)})`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function rankedScore(rank: number, tier: string, at: string, now: number): number {
|
|
54
|
+
return numeric(SCORE, rank, tier, at, now);
|
|
55
|
+
}
|
|
56
|
+
/** SQL uses fixed internal identifiers and bound clock values, never interpolated row/query input. */
|
|
57
|
+
export function rankedScoreSql(rank = "bm25(docs)"): string { return sql(SCORE, rank); }
|
|
58
|
+
export function rankedScoreSqlDetails(rank = "bm25(docs)", useFts = true): { rawBm25Rank: string; tierFactor: string; recencyFactor: string } {
|
|
59
|
+
return { rawBm25Rank: useFts ? rank : "NULL", tierFactor: sql(TIER_FACTOR, rank), recencyFactor: sql(RECENCY_FACTOR, rank) };
|
|
60
|
+
}
|
|
61
|
+
export function rankedScoreDetails(source: SearchScoreDetails["source"], rank: number, tier: string, at: string, now: number, finalScore: number): SearchScoreDetails {
|
|
62
|
+
return { source, rawBm25Rank: rank, tierFactor: numeric(TIER_FACTOR, rank, tier, at, now), recencyFactor: numeric(RECENCY_FACTOR, rank, tier, at, now), finalScore, matchMode: "fts5" };
|
|
63
|
+
}
|
|
64
|
+
export function unrankedScoreDetails(source: SearchScoreDetails["source"], finalScore: number | null): SearchScoreDetails {
|
|
65
|
+
return { source, rawBm25Rank: null, tierFactor: null, recencyFactor: null, finalScore, matchMode: "substring" };
|
|
66
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { facts, label, PLAIN, type Style } from "./style.ts";
|
|
2
|
+
|
|
3
|
+
/** The `amem status` object as the CLI builds it; `amem status --json` prints it unchanged. */
|
|
4
|
+
export interface StatusView {
|
|
5
|
+
home: { path: string; source: string };
|
|
6
|
+
config: { machine: string | null; repo: string | null; denyPaths: number; piiPatterns: number } & Record<string, unknown>;
|
|
7
|
+
candidates: { unavailable: string } | ({ count: number; oldestExpiresInDays: number | null; dueWithin7Days: number; unshown: number; warning?: string });
|
|
8
|
+
active: boolean; generation?: string; phase?: string;
|
|
9
|
+
pending: Record<string, unknown> & { rows: number };
|
|
10
|
+
deadLeasesRemoved?: { lease: string; at: string }[]; refusals?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Person-facing `amem status` (#125): overall state, the effective home and policy, the counts that matter, then next actions. Zero counters are summarized, never listed. */
|
|
14
|
+
export function statusText(status: StatusView, s: Style = PLAIN): string {
|
|
15
|
+
const actions: string[] = [], warnings: string[] = [];
|
|
16
|
+
const candidates = status.candidates;
|
|
17
|
+
let candidateLine: string, candidateState: "ok" | "attention" | "unavailable";
|
|
18
|
+
if ("unavailable" in candidates) { candidateState = "unavailable"; candidateLine = candidates.unavailable; warnings.push(candidates.unavailable); }
|
|
19
|
+
else {
|
|
20
|
+
candidateState = candidates.dueWithin7Days ? "attention" : "ok";
|
|
21
|
+
candidateLine = candidates.count ? `${candidates.count} pending${candidates.unshown ? ` (${candidates.unshown} never shown)` : ""}${candidates.oldestExpiresInDays !== null ? `, oldest expires in ${candidates.oldestExpiresInDays} days` : ""}` : "none pending";
|
|
22
|
+
if (candidates.warning) actions.push(candidates.warning);
|
|
23
|
+
else if (candidates.count) actions.push("review pending candidates: amem review walk");
|
|
24
|
+
}
|
|
25
|
+
const refusals = status.refusals ?? [];
|
|
26
|
+
const interrupted = status.phase === "captured" && !status.active; // authoring refuses until activation (generation-index.ts)
|
|
27
|
+
const migrationState: "ok" | "attention" = refusals.length || status.pending.rows || interrupted ? "attention" : "ok";
|
|
28
|
+
if (interrupted) actions.push("a shard migration stopped after capture and authoring is refused until it activates: amem migrate --shards --resume");
|
|
29
|
+
if (refusals.length) warnings.push(...refusals.map(reason => `migration refusal: ${reason}`));
|
|
30
|
+
if (status.pending.rows && !interrupted) actions.push(`${status.pending.rows} old-layout rows wait for the shard migration: amem migrate --shards --resume carries them (amem migrate --shards --dry-run previews first)`);
|
|
31
|
+
const overall = candidateState === "unavailable" ? "unavailable" : candidateState === "attention" || migrationState === "attention" ? "attention" : "ok";
|
|
32
|
+
const { machine, repo, denyPaths, piiPatterns, ...limits } = status.config;
|
|
33
|
+
const pendingCounts = facts(Object.fromEntries(Object.entries(status.pending).filter(([key]) => !key.endsWith("Ids"))));
|
|
34
|
+
const lines = [
|
|
35
|
+
`${s.bold("amem status:")} ${overall === "ok" ? s.ok(overall) : overall === "attention" ? s.attention(overall) : s.error(overall)}`,
|
|
36
|
+
`home ${status.home.path} ${s.dim(`(${status.home.source})`)}`,
|
|
37
|
+
"",
|
|
38
|
+
`${label(s, "ok")} policy: machine ${machine ?? "none (defaults)"}, repo ${repo ?? "none"}; ${denyPaths} deny paths, ${piiPatterns} PII patterns${facts(limits) ? `; ${facts(limits)}` : ""}`,
|
|
39
|
+
`${label(s, candidateState)} candidates: ${candidateLine}`,
|
|
40
|
+
`${label(s, migrationState)} layout: ${status.active ? `shards active${status.generation ? ` (generation ${status.generation.slice(0, 8)})` : ""}` : status.phase ? `shard migration ${status.phase}${interrupted ? ", not active" : ""}` : "single home"}; ${pendingCounts ? `pending ${pendingCounts}` : "no pending old-layout appends"}`,
|
|
41
|
+
...(status.deadLeasesRemoved?.length ? [` ${s.dim(`${status.deadLeasesRemoved.length} dead reader leases removed`)}`] : []),
|
|
42
|
+
...(warnings.length ? ["", "Warnings:", ...warnings.map(warning => ` - ${s.attention(warning)}`)] : []),
|
|
43
|
+
"", actions.length ? "Next actions:" : "Next actions: none", ...actions.map(action => ` - ${action}`),
|
|
44
|
+
s.dim("Full report: amem status --json; health checks: amem doctor"),
|
|
45
|
+
];
|
|
46
|
+
return lines.join("\n");
|
|
47
|
+
}
|
package/src/memory/store.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { memoryHome } from "./home.ts";
|
|
|
10
10
|
import { machineId, type Machine } from "./machine.ts";
|
|
11
11
|
import { assertProjectActive } from "./lifecycle.ts";
|
|
12
12
|
export { machineId, type Machine } from "./machine.ts";
|
|
13
|
+
import { rankedScore, rankedScoreDetails, unrankedScoreDetails, type SearchScoreDetails } from "./search-score.ts";
|
|
14
|
+
export type { SearchScoreDetails } from "./search-score.ts";
|
|
13
15
|
import { readWindowSql, readRef, type ReadWindow, type ReadRef, SearchIndex, captureSearch } from "./search-fts.ts";
|
|
14
16
|
import { assertCuratedShardPaths, ensureShardDescriptor, readShardDescriptor, readShardedSelection, aliasedKeys, resolveAlias, resolveShardProfile, shardDir, shardKeyHash, shardMachineDir, userShardDir } from "./shard.ts";
|
|
15
17
|
import { readCaptureConfig, type CaptureRow } from "./capture.ts";
|
|
@@ -168,10 +170,12 @@ export interface Filter { project?: string; tier?: Tier; kind?: Kind; peer?: str
|
|
|
168
170
|
/** #13 search: `Filter` plus the capture-tier switch (on by default; capture is recall, never curated evidence). */
|
|
169
171
|
export interface SearchFilter extends Filter {
|
|
170
172
|
includeCapture?: boolean;
|
|
173
|
+
/** Attach score factors only when explicitly requested. */
|
|
174
|
+
explain?: boolean;
|
|
171
175
|
/** A host that writes capture with a `retentionDays` override passes the same value here; files decide otherwise. */
|
|
172
176
|
captureRetentionDays?: number;
|
|
173
177
|
}
|
|
174
|
-
export interface SearchHit { source: "curated" | "capture"; score: number; view?: View; capture?: CaptureRow }
|
|
178
|
+
export interface SearchHit { source: "curated" | "capture"; score: number; scoreDetails?: SearchScoreDetails; view?: View; capture?: CaptureRow }
|
|
175
179
|
export interface SearchResult { engine: "fts5" | "substring"; hits: SearchHit[] }
|
|
176
180
|
export interface Graph { nodes: { id: Node; type: string; label: string; kind?: string; tier?: Tier }[]; edges: { from: Node; to: Node; rel: string }[] }
|
|
177
181
|
|
|
@@ -490,18 +494,30 @@ export class MemoryStore {
|
|
|
490
494
|
// `ownClock`: a note asked for short-term on purpose expires on its own clock, open task or not, and is never promoted.
|
|
491
495
|
note(n: NoteInput, ownClock = false): string { return this.append(n.tier === "short" && !n.expires && (ownClock || !n.task || this.settled(n.task)) ? { ...n, expires: this.until() } : n, n.tier === "short"); }
|
|
492
496
|
|
|
493
|
-
/**
|
|
494
|
-
|
|
495
|
-
* the same id, so a second import writes nothing. Returns the id and whether it was new.
|
|
496
|
-
*/
|
|
497
|
-
importNote(n: NoteInput, at: string, identity: { machine?: string; expectedId?: string } = {}): { id: string; added: boolean } {
|
|
497
|
+
/** The already-opened owner of an imported row, shared by preflight and append. */
|
|
498
|
+
private importOwner(n: NoteInput): MemoryStore {
|
|
498
499
|
if (this.bootstrapOnly) throw new Error("migration bootstrap cannot import memory");
|
|
499
|
-
if (this.userOverlay && n.scope === "shared") return this.userOverlay.
|
|
500
|
+
if (this.userOverlay && n.scope === "shared") return this.userOverlay.importOwner(n);
|
|
500
501
|
if (this.profile.sharded && !this.profile.shardUser && n.scope === "project" && n.project !== this.profile.shardKey) {
|
|
501
502
|
const owner = this.aliasStores.find(store => store.profile.shardKey === n.project);
|
|
502
|
-
if (owner) return owner.
|
|
503
|
+
if (owner) return owner.importOwner(n);
|
|
503
504
|
}
|
|
504
505
|
if (this.profile.sharded && (this.profile.shardUser ? n.scope !== "shared" : n.scope !== "project" || n.project !== this.profile.shardKey)) throw new Error("entry does not belong to this curated shard");
|
|
506
|
+
return this;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Check every import route before a multi-row caller appends; never creates a missing alias shard. */
|
|
510
|
+
assertImportable(notes: Iterable<NoteInput>): void {
|
|
511
|
+
for (const note of notes) this.importOwner(note);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* An entry from another store with its own time (`ahub memory import claude-mem`): the same source row always gives
|
|
516
|
+
* the same id, so a second import writes nothing. Returns the id and whether it was new.
|
|
517
|
+
*/
|
|
518
|
+
importNote(n: NoteInput, at: string, identity: { machine?: string; expectedId?: string } = {}): { id: string; added: boolean } {
|
|
519
|
+
const owner = this.importOwner(n);
|
|
520
|
+
if (owner !== this) return owner.importNote(n, at, identity);
|
|
505
521
|
assertSharedV1Op(n.op);
|
|
506
522
|
// A fixed machine: the same observation imported on two machines is one entry once they sync. A project import preserves the exported identity instead.
|
|
507
523
|
const body = { schema: MEMORY_SCHEMA, at, machine: identity.machine ?? "claude-mem", ...n, text: stripPrivateSpans(n.text).slice(0, MAX_TEXT), ...(n.title ? { title: stripPrivateSpans(n.title).slice(0, MAX_TITLE) } : {}) } as Omit<Entry, "id">;
|
|
@@ -1057,12 +1073,15 @@ export class MemoryStore {
|
|
|
1057
1073
|
*/
|
|
1058
1074
|
search(q: string, f: SearchFilter = {}): SearchResult {
|
|
1059
1075
|
const limit = f.limit ?? 200;
|
|
1060
|
-
if (!this.searchIndex.available) return { engine: "substring", hits: this.list({ ...f, q }).map(
|
|
1076
|
+
if (!this.searchIndex.available) return { engine: "substring", hits: this.list({ ...f, q }).map(view => {
|
|
1077
|
+
const hit: SearchHit = { source: "curated", score: 0, view };
|
|
1078
|
+
if (f.explain) hit.scoreDetails = unrankedScoreDetails("curated", hit.score);
|
|
1079
|
+
return hit;
|
|
1080
|
+
}) };
|
|
1061
1081
|
const visible = new Map(this.live(f).filter((v) => (!f.project || this.inScope(v.project, f.project) || v.scope === "shared")
|
|
1062
1082
|
&& (!f.since || Date.parse(v.at) >= Date.parse(f.since)) && (f.includeSuperseded || !v.supersededBy)).map((v) => [v.id, v]));
|
|
1063
1083
|
for (const store of this.readStores()) store.syncSearchIndex();
|
|
1064
1084
|
const now = this.now();
|
|
1065
|
-
const recency = (at: string): number => 1 / (1 + Math.max(0, now - Date.parse(at)) / (30 * 24 * 3600_000));
|
|
1066
1085
|
const hits: SearchHit[] = [];
|
|
1067
1086
|
// The project filter runs inside the FTS query (its rows plus shared ones), so a busy sibling project can never
|
|
1068
1087
|
// starve this project's LIMIT.
|
|
@@ -1082,7 +1101,9 @@ export class MemoryStore {
|
|
|
1082
1101
|
if (!view || seen.has(hit.ref)) continue;
|
|
1083
1102
|
seen.add(hit.ref);
|
|
1084
1103
|
found++;
|
|
1085
|
-
|
|
1104
|
+
const selected: SearchHit = { source: "curated", score: rankedScore(hit.rank, view.tier, hit.at, now), view };
|
|
1105
|
+
if (f.explain) selected.scoreDetails = rankedScoreDetails("curated", hit.rank, view.tier, hit.at, now, selected.score);
|
|
1106
|
+
hits.push(selected);
|
|
1086
1107
|
}
|
|
1087
1108
|
if (ranked.length < page) break;
|
|
1088
1109
|
}
|
|
@@ -1097,12 +1118,18 @@ export class MemoryStore {
|
|
|
1097
1118
|
const retained = Number.isFinite(cutoff.getTime()) ? cutoff.toISOString() : undefined;
|
|
1098
1119
|
const since = f.since && Number.isFinite(Date.parse(f.since)) ? new Date(Date.parse(f.since)).toISOString() : undefined;
|
|
1099
1120
|
for (const { row, rank } of captureSearch(shard, q, Math.max(limit * 4, 32), since && (!retained || since > retained) ? since : retained)) {
|
|
1100
|
-
|
|
1121
|
+
const selected: SearchHit = { source: "capture", score: rankedScore(rank, "capture", row.at, now), capture: row };
|
|
1122
|
+
if (f.explain) selected.scoreDetails = rankedScoreDetails("capture", rank, "capture", row.at, now, selected.score);
|
|
1123
|
+
hits.push(selected);
|
|
1101
1124
|
}
|
|
1102
1125
|
}
|
|
1103
1126
|
// Where FTS misses entirely (a stopword-only query, a tokenization edge), the substring filter is the safety net.
|
|
1104
1127
|
if (!hits.length && q.trim()) {
|
|
1105
|
-
for (const view of this.list({ ...f, q }))
|
|
1128
|
+
for (const view of this.list({ ...f, q })) {
|
|
1129
|
+
const hit: SearchHit = { source: "curated", score: 0, view };
|
|
1130
|
+
if (f.explain) hit.scoreDetails = unrankedScoreDetails("curated", hit.score);
|
|
1131
|
+
hits.push(hit);
|
|
1132
|
+
}
|
|
1106
1133
|
}
|
|
1107
1134
|
const ref = (h: SearchHit): string => h.view?.id ?? String(h.capture?.id ?? 0).padStart(12, "0");
|
|
1108
1135
|
hits.sort((a, b) => b.score - a.score || ref(a).localeCompare(ref(b)));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny, dependency-free text style for person-facing CLI output (#125).
|
|
3
|
+
* Color is decoration only: every caller also prints a textual label, so plain output carries the same facts.
|
|
4
|
+
* JSON output never passes through this module.
|
|
5
|
+
*/
|
|
6
|
+
export interface Style {
|
|
7
|
+
readonly color: boolean;
|
|
8
|
+
ok(text: string): string;
|
|
9
|
+
attention(text: string): string;
|
|
10
|
+
error(text: string): string;
|
|
11
|
+
dim(text: string): string;
|
|
12
|
+
bold(text: string): string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Color only for an interactive stdout, and never with NO_COLOR (any value, per no-color.org) or TERM=dumb. */
|
|
16
|
+
export function colorEnabled(env: Readonly<Record<string, string | undefined>>, isTTY: boolean | undefined): boolean {
|
|
17
|
+
return isTTY === true && env.NO_COLOR === undefined && env.TERM !== "dumb";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const wrap = (on: boolean, open: number, close: number) => (text: string) => on ? `\x1b[${open}m${text}\x1b[${close}m` : text;
|
|
21
|
+
export function style(color: boolean): Style {
|
|
22
|
+
return { color, ok: wrap(color, 32, 39), attention: wrap(color, 33, 39), error: wrap(color, 31, 39), dim: wrap(color, 2, 22), bold: wrap(color, 1, 22) };
|
|
23
|
+
}
|
|
24
|
+
export const PLAIN: Style = style(false);
|
|
25
|
+
|
|
26
|
+
/** Status label, padded so the columns line up; the word is always present. */
|
|
27
|
+
export function label(s: Style, state: "ok" | "attention" | "skipped" | "unavailable" | "error"): string {
|
|
28
|
+
const word = { ok: "OK", attention: "ATTENTION", skipped: "SKIPPED", unavailable: "UNAVAILABLE", error: "ERROR" }[state].padEnd(11);
|
|
29
|
+
return state === "ok" ? s.ok(word) : state === "attention" ? s.attention(word) : state === "skipped" ? s.dim(word) : s.error(word);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** "key value" pairs, leaving out zero, empty and null values; returns "" when nothing material is left. */
|
|
33
|
+
export function facts(values: Record<string, unknown>): string {
|
|
34
|
+
return Object.entries(values).filter(([, value]) => value !== 0 && value !== null && value !== undefined && value !== false && value !== ""
|
|
35
|
+
&& !(Array.isArray(value) && value.length === 0) && !(typeof value === "object" && !Array.isArray(value) && Object.keys(value as object).length === 0))
|
|
36
|
+
.map(([key, value]) => `${key} ${typeof value === "object" ? Array.isArray(value) ? value.join(", ") : JSON.stringify(value) : String(value)}`).join(", ");
|
|
37
|
+
}
|
package/src/setup-toml.ts
CHANGED
|
@@ -162,11 +162,11 @@ function editTomlPath(text: string, path: readonly string[], value: unknown | un
|
|
|
162
162
|
changes.push({ start: position, end: position, text: `${newline}${key} = ${inline(value)}${eol}` });
|
|
163
163
|
}
|
|
164
164
|
output = replace(text, changes);
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
165
|
+
}
|
|
166
|
+
// Removing the only child of an implicit table must retain its parsed empty parent value.
|
|
167
|
+
for (let depth = path.length - 1; depth > 0; depth--) {
|
|
168
|
+
const parent = path.slice(0, depth), wanted = at(expected, parent);
|
|
169
|
+
if (plain(wanted) && !Object.keys(wanted).length && at(parse(output), parent) === undefined) output += `${output && !output.endsWith("\n") ? eol : ""}[${parent.map(part => JSON.stringify(part)).join(".")}]${eol}`;
|
|
170
170
|
}
|
|
171
171
|
if (!isDeepStrictEqual(shape(parse(output)), shape(expected))) return error();
|
|
172
172
|
return output;
|