@cruxy/cli 0.17.0 → 0.19.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/dist/agent/loop.d.ts +15 -0
- package/dist/agent/loop.js +21 -0
- package/dist/agent/prompts.d.ts +7 -0
- package/dist/agent/prompts.js +6 -0
- package/dist/agent/session.d.ts +26 -1
- package/dist/agent/session.js +39 -6
- package/dist/cli/commands/memory.d.ts +8 -0
- package/dist/cli/commands/memory.js +98 -0
- package/dist/cli/commands/run.js +19 -0
- package/dist/cli/commands/usage.d.ts +9 -0
- package/dist/cli/commands/usage.js +81 -0
- package/dist/cli/program.js +4 -0
- package/dist/cli/session-factory.js +39 -1
- package/dist/config/schema.d.ts +336 -12
- package/dist/config/schema.js +56 -0
- package/dist/constants.d.ts +18 -0
- package/dist/constants.js +18 -0
- package/dist/errors/constructors.d.ts +21 -0
- package/dist/errors/constructors.js +64 -0
- package/dist/errors/types.d.ts +12 -0
- package/dist/errors/types.js +24 -0
- package/dist/hooks/types.d.ts +1 -1
- package/dist/memory/index.d.ts +7 -0
- package/dist/memory/index.js +7 -0
- package/dist/memory/recall.d.ts +32 -0
- package/dist/memory/recall.js +73 -0
- package/dist/memory/remember-tool.d.ts +25 -0
- package/dist/memory/remember-tool.js +56 -0
- package/dist/memory/secrets.d.ts +29 -0
- package/dist/memory/secrets.js +61 -0
- package/dist/memory/service.d.ts +92 -0
- package/dist/memory/service.js +164 -0
- package/dist/memory/store.d.ts +32 -0
- package/dist/memory/store.js +100 -0
- package/dist/memory/trust.d.ts +52 -0
- package/dist/memory/trust.js +106 -0
- package/dist/memory/types.d.ts +101 -0
- package/dist/memory/types.js +58 -0
- package/dist/plan/service.d.ts +13 -1
- package/dist/plan/service.js +4 -0
- package/dist/usage/collect.d.ts +40 -0
- package/dist/usage/collect.js +34 -0
- package/dist/usage/cost.d.ts +19 -0
- package/dist/usage/cost.js +29 -0
- package/dist/usage/index.d.ts +15 -0
- package/dist/usage/index.js +15 -0
- package/dist/usage/store.d.ts +37 -0
- package/dist/usage/store.js +83 -0
- package/dist/usage/summary.d.ts +32 -0
- package/dist/usage/summary.js +119 -0
- package/dist/usage/types.d.ts +220 -0
- package/dist/usage/types.js +47 -0
- package/package.json +1 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { globalDir } from "../config/paths.js";
|
|
4
|
+
import { GLOBAL_DIR_NAME, MEMORY_DIR_NAME, MEMORY_FILE_NAME, } from "../constants.js";
|
|
5
|
+
import { containsSecret } from "./secrets.js";
|
|
6
|
+
import { MemoryEntrySchema, MemoryFileSchema, MEMORY_FILE_VERSION, } from "./types.js";
|
|
7
|
+
/** The default source files for a project root. */
|
|
8
|
+
export function defaultMemorySources(cwd) {
|
|
9
|
+
const root = path.resolve(cwd);
|
|
10
|
+
return {
|
|
11
|
+
user: path.join(globalDir(), MEMORY_DIR_NAME, MEMORY_FILE_NAME),
|
|
12
|
+
project: path.join(root, GLOBAL_DIR_NAME, MEMORY_DIR_NAME, MEMORY_FILE_NAME),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Read and validate one scope's file. A missing file yields an empty result
|
|
17
|
+
* (not an error). Each entry is (1) schema-validated and (2) scanned for secret
|
|
18
|
+
* content; failures are excluded and collected. The returned `entries` all carry
|
|
19
|
+
* the requested `scope` (the on-disk `scope` field is normalized to it, so a
|
|
20
|
+
* mislabeled entry can't cross scopes).
|
|
21
|
+
*/
|
|
22
|
+
export function loadScope(file, scope) {
|
|
23
|
+
let raw;
|
|
24
|
+
try {
|
|
25
|
+
raw = readFileSync(file, "utf8");
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Missing / unreadable → nothing recalled from this scope (not an error).
|
|
29
|
+
return { entries: [], errors: [] };
|
|
30
|
+
}
|
|
31
|
+
let parsedFile;
|
|
32
|
+
try {
|
|
33
|
+
parsedFile = JSON.parse(raw);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return {
|
|
37
|
+
entries: [],
|
|
38
|
+
errors: [
|
|
39
|
+
{ scope, reason: "invalid", id: "(file)", message: "not valid JSON" },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const fileResult = MemoryFileSchema.safeParse(parsedFile);
|
|
44
|
+
if (!fileResult.success) {
|
|
45
|
+
return {
|
|
46
|
+
entries: [],
|
|
47
|
+
errors: [
|
|
48
|
+
{
|
|
49
|
+
scope,
|
|
50
|
+
reason: "invalid",
|
|
51
|
+
id: "(file)",
|
|
52
|
+
message: `malformed memory file: ${fileResult.error.issues[0]?.message ?? "invalid shape"}`,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const entries = [];
|
|
58
|
+
const errors = [];
|
|
59
|
+
fileResult.data.entries.forEach((candidate, i) => {
|
|
60
|
+
const parsed = MemoryEntrySchema.safeParse(candidate);
|
|
61
|
+
if (!parsed.success) {
|
|
62
|
+
const id = candidate && typeof candidate === "object" && "id" in candidate
|
|
63
|
+
? String(candidate.id)
|
|
64
|
+
: `#${i}`;
|
|
65
|
+
errors.push({
|
|
66
|
+
scope,
|
|
67
|
+
reason: "invalid",
|
|
68
|
+
id,
|
|
69
|
+
message: parsed.error.issues[0]?.message ?? "invalid entry",
|
|
70
|
+
});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// Secrets denylist on LOAD (defense in depth): a hand-edited file cannot
|
|
74
|
+
// smuggle a secret into context even though `remember` also refuses on write.
|
|
75
|
+
const scan = containsSecret(parsed.data.content);
|
|
76
|
+
if (scan.secret) {
|
|
77
|
+
errors.push({
|
|
78
|
+
scope,
|
|
79
|
+
reason: "secret",
|
|
80
|
+
id: parsed.data.id,
|
|
81
|
+
message: `excluded — content looks like a secret (${scan.kind})`,
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Normalize scope to the file it came from (never trust the on-disk label to
|
|
86
|
+
// move an entry across scopes).
|
|
87
|
+
entries.push({ ...parsed.data, scope });
|
|
88
|
+
});
|
|
89
|
+
return { entries, errors };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Persist a scope's entries, overwriting the file. Creates the memory dir if
|
|
93
|
+
* needed. The user scope is written `0600` (it is personal, cross-project data);
|
|
94
|
+
* the project scope inherits normal repo permissions (it may be committed).
|
|
95
|
+
*/
|
|
96
|
+
export function saveScope(file, entries, scope) {
|
|
97
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
98
|
+
const body = JSON.stringify({ version: MEMORY_FILE_VERSION, entries }, null, 2);
|
|
99
|
+
writeFileSync(file, body, scope === "user" ? { mode: 0o600 } : undefined);
|
|
100
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { MemoryEntry, MemoryTrust } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The project-MEMORY trust model (C.29) — the exact supply-chain treatment C.19
|
|
4
|
+
* applies to project hooks, but with its OWN store so memory and hook trust are
|
|
5
|
+
* independent (the hook `trust.json` keys a single fingerprint per root; sharing
|
|
6
|
+
* it would couple the two, so memory gets `~/.cruxy/memory-trust.json`).
|
|
7
|
+
*
|
|
8
|
+
* Trust is recorded in the GLOBAL dir — in the user's home, NEVER inside a repo —
|
|
9
|
+
* so cloning a repo carries zero memory trust and an attacker cannot ship a
|
|
10
|
+
* pre-trusted marker. It is bound to a {@link fingerprintMemory fingerprint} of
|
|
11
|
+
* the exact project entries seen at trust time and re-checked on every run: if
|
|
12
|
+
* the project's memory changes, the fingerprint no longer matches and trust is
|
|
13
|
+
* stale → the entries are not recalled until re-trusted. This defeats
|
|
14
|
+
* trust-then-swap.
|
|
15
|
+
*/
|
|
16
|
+
/** ~/.cruxy/memory-trust.json */
|
|
17
|
+
export declare function memoryTrustPath(): string;
|
|
18
|
+
/**
|
|
19
|
+
* A stable content fingerprint of a repo's PROJECT memory entries. Canonical by
|
|
20
|
+
* construction so a benign reformat (reordered entries, whitespace, added/removed
|
|
21
|
+
* `id`/`createdAt` metadata) does NOT change it, while any change to the
|
|
22
|
+
* meaning-bearing content DOES:
|
|
23
|
+
* - only `kind` + `content` are hashed (id/createdAt/scope are not meaning);
|
|
24
|
+
* - content is whitespace-normalized (trim + collapse runs);
|
|
25
|
+
* - entries are sorted so order doesn't matter.
|
|
26
|
+
*
|
|
27
|
+
* The empty set has a fixed fingerprint — trusting "no project memory" is
|
|
28
|
+
* meaningful (adding the first foreign entry re-gates).
|
|
29
|
+
*/
|
|
30
|
+
export declare function fingerprintMemory(entries: readonly MemoryEntry[]): string;
|
|
31
|
+
/** The persisted trust seam — file-backed in production, injectable for tests. */
|
|
32
|
+
export interface MemoryTrustStore {
|
|
33
|
+
/** The recorded decision for a repo root, or undefined if never trusted. */
|
|
34
|
+
get(root: string): MemoryTrust | undefined;
|
|
35
|
+
/** Persist a trust decision (overwrites any prior one for the same root). */
|
|
36
|
+
record(trust: MemoryTrust): void;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Is this repo's current project memory trusted? True only when a decision
|
|
40
|
+
* exists AND its fingerprint matches the current one — changed project memory is
|
|
41
|
+
* treated as untrusted (stale), forcing a fresh `cruxy memory trust`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isMemoryTrusted(store: MemoryTrustStore, root: string, currentFingerprint: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* The real store, persisting to `~/.cruxy/memory-trust.json` as
|
|
46
|
+
* `{ [root]: MemoryTrust }`. Reads are lazy + cached; a corrupt file is treated
|
|
47
|
+
* as "no trust recorded" (fail-closed — a broken trust file must never grant
|
|
48
|
+
* trust).
|
|
49
|
+
*/
|
|
50
|
+
export declare function fileMemoryTrustStore(file?: string): MemoryTrustStore;
|
|
51
|
+
/** An in-memory store for tests (and any ephemeral run). */
|
|
52
|
+
export declare function memoryMemoryTrustStore(seed?: MemoryTrust[]): MemoryTrustStore;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { globalDir } from "../config/paths.js";
|
|
5
|
+
import { MEMORY_TRUST_FILE_NAME } from "../constants.js";
|
|
6
|
+
/**
|
|
7
|
+
* The project-MEMORY trust model (C.29) — the exact supply-chain treatment C.19
|
|
8
|
+
* applies to project hooks, but with its OWN store so memory and hook trust are
|
|
9
|
+
* independent (the hook `trust.json` keys a single fingerprint per root; sharing
|
|
10
|
+
* it would couple the two, so memory gets `~/.cruxy/memory-trust.json`).
|
|
11
|
+
*
|
|
12
|
+
* Trust is recorded in the GLOBAL dir — in the user's home, NEVER inside a repo —
|
|
13
|
+
* so cloning a repo carries zero memory trust and an attacker cannot ship a
|
|
14
|
+
* pre-trusted marker. It is bound to a {@link fingerprintMemory fingerprint} of
|
|
15
|
+
* the exact project entries seen at trust time and re-checked on every run: if
|
|
16
|
+
* the project's memory changes, the fingerprint no longer matches and trust is
|
|
17
|
+
* stale → the entries are not recalled until re-trusted. This defeats
|
|
18
|
+
* trust-then-swap.
|
|
19
|
+
*/
|
|
20
|
+
/** ~/.cruxy/memory-trust.json */
|
|
21
|
+
export function memoryTrustPath() {
|
|
22
|
+
return path.join(globalDir(), MEMORY_TRUST_FILE_NAME);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A stable content fingerprint of a repo's PROJECT memory entries. Canonical by
|
|
26
|
+
* construction so a benign reformat (reordered entries, whitespace, added/removed
|
|
27
|
+
* `id`/`createdAt` metadata) does NOT change it, while any change to the
|
|
28
|
+
* meaning-bearing content DOES:
|
|
29
|
+
* - only `kind` + `content` are hashed (id/createdAt/scope are not meaning);
|
|
30
|
+
* - content is whitespace-normalized (trim + collapse runs);
|
|
31
|
+
* - entries are sorted so order doesn't matter.
|
|
32
|
+
*
|
|
33
|
+
* The empty set has a fixed fingerprint — trusting "no project memory" is
|
|
34
|
+
* meaningful (adding the first foreign entry re-gates).
|
|
35
|
+
*/
|
|
36
|
+
export function fingerprintMemory(entries) {
|
|
37
|
+
const canonical = entries
|
|
38
|
+
.map((e) => [e.kind, e.content.trim().replace(/\s+/g, " ")])
|
|
39
|
+
.sort((a, b) => a[0] === b[0] ? a[1].localeCompare(b[1]) : a[0].localeCompare(b[0]));
|
|
40
|
+
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Is this repo's current project memory trusted? True only when a decision
|
|
44
|
+
* exists AND its fingerprint matches the current one — changed project memory is
|
|
45
|
+
* treated as untrusted (stale), forcing a fresh `cruxy memory trust`.
|
|
46
|
+
*/
|
|
47
|
+
export function isMemoryTrusted(store, root, currentFingerprint) {
|
|
48
|
+
const record = store.get(path.resolve(root));
|
|
49
|
+
return record !== undefined && record.fingerprint === currentFingerprint;
|
|
50
|
+
}
|
|
51
|
+
// ── file-backed store ─────────────────────────────────────────────────────────
|
|
52
|
+
/**
|
|
53
|
+
* The real store, persisting to `~/.cruxy/memory-trust.json` as
|
|
54
|
+
* `{ [root]: MemoryTrust }`. Reads are lazy + cached; a corrupt file is treated
|
|
55
|
+
* as "no trust recorded" (fail-closed — a broken trust file must never grant
|
|
56
|
+
* trust).
|
|
57
|
+
*/
|
|
58
|
+
export function fileMemoryTrustStore(file = memoryTrustPath()) {
|
|
59
|
+
let cache = null;
|
|
60
|
+
const load = () => {
|
|
61
|
+
if (cache)
|
|
62
|
+
return cache;
|
|
63
|
+
try {
|
|
64
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
65
|
+
cache =
|
|
66
|
+
raw && typeof raw === "object"
|
|
67
|
+
? raw
|
|
68
|
+
: {};
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Missing or corrupt → no trust (fail-closed).
|
|
72
|
+
cache = {};
|
|
73
|
+
}
|
|
74
|
+
return cache;
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
get(root) {
|
|
78
|
+
return load()[path.resolve(root)];
|
|
79
|
+
},
|
|
80
|
+
record(trust) {
|
|
81
|
+
const store = load();
|
|
82
|
+
store[path.resolve(trust.root)] = {
|
|
83
|
+
...trust,
|
|
84
|
+
root: path.resolve(trust.root),
|
|
85
|
+
};
|
|
86
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
87
|
+
// 0600: trust records name local paths; keep them owner-only.
|
|
88
|
+
writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
89
|
+
if (existsSync(file))
|
|
90
|
+
cache = store;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** An in-memory store for tests (and any ephemeral run). */
|
|
95
|
+
export function memoryMemoryTrustStore(seed = []) {
|
|
96
|
+
const store = new Map();
|
|
97
|
+
for (const t of seed)
|
|
98
|
+
store.set(path.resolve(t.root), t);
|
|
99
|
+
return {
|
|
100
|
+
get: (root) => store.get(path.resolve(root)),
|
|
101
|
+
record: (trust) => void store.set(path.resolve(trust.root), {
|
|
102
|
+
...trust,
|
|
103
|
+
root: path.resolve(trust.root),
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Types for persistent memory (C.29). Everything here is **data**: a memory
|
|
4
|
+
* entry is validated (fail-loud, malformed excluded) and stored as structured
|
|
5
|
+
* JSON that is NEVER eval'd. Recalled entries are injected into the model's
|
|
6
|
+
* context as clearly-demarcated REFERENCE DATA (see `recall.ts`) — they are not
|
|
7
|
+
* instructions and can never grant authority: the U.3 approval gate is
|
|
8
|
+
* structural and does not read memory at all (proven in `recall.test.ts`).
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* What a memory entry records. A small, closed set — the enum is the extension
|
|
12
|
+
* point (out of scope: freeform blobs, which we deliberately reject).
|
|
13
|
+
* - `fact` — a durable fact about the user or project.
|
|
14
|
+
* - `decision` — a choice made and its rationale.
|
|
15
|
+
* - `preference` — how the user likes things done.
|
|
16
|
+
*/
|
|
17
|
+
export declare const MEMORY_KINDS: readonly ["fact", "decision", "preference"];
|
|
18
|
+
export type MemoryKind = (typeof MEMORY_KINDS)[number];
|
|
19
|
+
/**
|
|
20
|
+
* Where an entry lives. `user` (`~/.cruxy/memory`) is your own memory, authored
|
|
21
|
+
* on this machine → trusted. `project` (`<repo>/.cruxy/memory`) can arrive with
|
|
22
|
+
* a cloned repo (another author) → untrusted until explicitly trusted (see
|
|
23
|
+
* `trust.ts`). This is the supply-chain boundary.
|
|
24
|
+
*/
|
|
25
|
+
export declare const MEMORY_SCOPES: readonly ["user", "project"];
|
|
26
|
+
export type MemoryScope = (typeof MEMORY_SCOPES)[number];
|
|
27
|
+
/** Recall renders `user` first (trusted, cross-project), then `project`. */
|
|
28
|
+
export declare const MEMORY_SCOPE_ORDER: readonly MemoryScope[];
|
|
29
|
+
/** Hard cap on a single note's length — memory is for terse facts, not blobs;
|
|
30
|
+
* this also bounds the per-entry cost of the recall budget. */
|
|
31
|
+
export declare const MAX_CONTENT_CHARS = 2000;
|
|
32
|
+
/**
|
|
33
|
+
* Strict schema for one memory entry as persisted. `.strict()` rejects unknown
|
|
34
|
+
* keys so a malformed/foreign object is a loud validation error (excluded), not
|
|
35
|
+
* silently absorbed. Parsed with this on every load — the file is never trusted
|
|
36
|
+
* shape-wise just because it is JSON.
|
|
37
|
+
*/
|
|
38
|
+
export declare const MemoryEntrySchema: z.ZodObject<{
|
|
39
|
+
/** Stable unique id (crypto.randomUUID) — the handle for `forget`. */
|
|
40
|
+
id: z.ZodString;
|
|
41
|
+
kind: z.ZodEnum<["fact", "decision", "preference"]>;
|
|
42
|
+
/** The note. DATA, never instructions. Non-empty, length-capped. */
|
|
43
|
+
content: z.ZodString;
|
|
44
|
+
scope: z.ZodEnum<["user", "project"]>;
|
|
45
|
+
/** ISO 8601 timestamp the entry was recorded. */
|
|
46
|
+
createdAt: z.ZodString;
|
|
47
|
+
}, "strict", z.ZodTypeAny, {
|
|
48
|
+
id: string;
|
|
49
|
+
kind: "fact" | "decision" | "preference";
|
|
50
|
+
createdAt: string;
|
|
51
|
+
content: string;
|
|
52
|
+
scope: "project" | "user";
|
|
53
|
+
}, {
|
|
54
|
+
id: string;
|
|
55
|
+
kind: "fact" | "decision" | "preference";
|
|
56
|
+
createdAt: string;
|
|
57
|
+
content: string;
|
|
58
|
+
scope: "project" | "user";
|
|
59
|
+
}>;
|
|
60
|
+
export type MemoryEntry = z.infer<typeof MemoryEntrySchema>;
|
|
61
|
+
/** Current on-disk file version — lets a future migration detect old files. */
|
|
62
|
+
export declare const MEMORY_FILE_VERSION = 1;
|
|
63
|
+
/** The whole `entries.json` file shape (one file per scope). */
|
|
64
|
+
export declare const MemoryFileSchema: z.ZodObject<{
|
|
65
|
+
version: z.ZodDefault<z.ZodNumber>;
|
|
66
|
+
entries: z.ZodDefault<z.ZodArray<z.ZodUnknown, "many">>;
|
|
67
|
+
}, "strict", z.ZodTypeAny, {
|
|
68
|
+
entries: unknown[];
|
|
69
|
+
version: number;
|
|
70
|
+
}, {
|
|
71
|
+
entries?: unknown[] | undefined;
|
|
72
|
+
version?: number | undefined;
|
|
73
|
+
}>;
|
|
74
|
+
/** Why an entry was rejected on load — surfaced, never silently dropped. */
|
|
75
|
+
export type MemoryLoadReason = "invalid" | "secret";
|
|
76
|
+
/** A rejected entry (malformed, or a detected secret), excluded from recall and
|
|
77
|
+
* from the writable set, and surfaced loudly. */
|
|
78
|
+
export interface MemoryLoadError {
|
|
79
|
+
scope: MemoryScope;
|
|
80
|
+
reason: MemoryLoadReason;
|
|
81
|
+
/** The offending entry's id when known (else a positional marker). */
|
|
82
|
+
id: string;
|
|
83
|
+
message: string;
|
|
84
|
+
}
|
|
85
|
+
/** The validated result of loading one scope's file: the good entries plus the
|
|
86
|
+
* excluded ones. A missing file is not an error — it yields an empty result. */
|
|
87
|
+
export interface MemoryLoad {
|
|
88
|
+
entries: MemoryEntry[];
|
|
89
|
+
errors: MemoryLoadError[];
|
|
90
|
+
}
|
|
91
|
+
/** One repo's recorded PROJECT-memory trust decision (persisted in
|
|
92
|
+
* `~/.cruxy/memory-trust.json`). The `fingerprint` binds trust to the exact
|
|
93
|
+
* project entries seen at trust time, so editing them re-gates (see `trust.ts`). */
|
|
94
|
+
export interface MemoryTrust {
|
|
95
|
+
/** Absolute project root. */
|
|
96
|
+
root: string;
|
|
97
|
+
/** sha256 of the canonicalized project entries (see `fingerprintMemory`). */
|
|
98
|
+
fingerprint: string;
|
|
99
|
+
/** ISO timestamp the decision was recorded. */
|
|
100
|
+
at: string;
|
|
101
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Types for persistent memory (C.29). Everything here is **data**: a memory
|
|
4
|
+
* entry is validated (fail-loud, malformed excluded) and stored as structured
|
|
5
|
+
* JSON that is NEVER eval'd. Recalled entries are injected into the model's
|
|
6
|
+
* context as clearly-demarcated REFERENCE DATA (see `recall.ts`) — they are not
|
|
7
|
+
* instructions and can never grant authority: the U.3 approval gate is
|
|
8
|
+
* structural and does not read memory at all (proven in `recall.test.ts`).
|
|
9
|
+
*/
|
|
10
|
+
// ── entry kind + scope ────────────────────────────────────────────────────────
|
|
11
|
+
/**
|
|
12
|
+
* What a memory entry records. A small, closed set — the enum is the extension
|
|
13
|
+
* point (out of scope: freeform blobs, which we deliberately reject).
|
|
14
|
+
* - `fact` — a durable fact about the user or project.
|
|
15
|
+
* - `decision` — a choice made and its rationale.
|
|
16
|
+
* - `preference` — how the user likes things done.
|
|
17
|
+
*/
|
|
18
|
+
export const MEMORY_KINDS = ["fact", "decision", "preference"];
|
|
19
|
+
/**
|
|
20
|
+
* Where an entry lives. `user` (`~/.cruxy/memory`) is your own memory, authored
|
|
21
|
+
* on this machine → trusted. `project` (`<repo>/.cruxy/memory`) can arrive with
|
|
22
|
+
* a cloned repo (another author) → untrusted until explicitly trusted (see
|
|
23
|
+
* `trust.ts`). This is the supply-chain boundary.
|
|
24
|
+
*/
|
|
25
|
+
export const MEMORY_SCOPES = ["user", "project"];
|
|
26
|
+
/** Recall renders `user` first (trusted, cross-project), then `project`. */
|
|
27
|
+
export const MEMORY_SCOPE_ORDER = ["user", "project"];
|
|
28
|
+
// ── entry schema ──────────────────────────────────────────────────────────────
|
|
29
|
+
/** Hard cap on a single note's length — memory is for terse facts, not blobs;
|
|
30
|
+
* this also bounds the per-entry cost of the recall budget. */
|
|
31
|
+
export const MAX_CONTENT_CHARS = 2000;
|
|
32
|
+
/**
|
|
33
|
+
* Strict schema for one memory entry as persisted. `.strict()` rejects unknown
|
|
34
|
+
* keys so a malformed/foreign object is a loud validation error (excluded), not
|
|
35
|
+
* silently absorbed. Parsed with this on every load — the file is never trusted
|
|
36
|
+
* shape-wise just because it is JSON.
|
|
37
|
+
*/
|
|
38
|
+
export const MemoryEntrySchema = z
|
|
39
|
+
.object({
|
|
40
|
+
/** Stable unique id (crypto.randomUUID) — the handle for `forget`. */
|
|
41
|
+
id: z.string().min(1),
|
|
42
|
+
kind: z.enum(MEMORY_KINDS),
|
|
43
|
+
/** The note. DATA, never instructions. Non-empty, length-capped. */
|
|
44
|
+
content: z.string().min(1).max(MAX_CONTENT_CHARS),
|
|
45
|
+
scope: z.enum(MEMORY_SCOPES),
|
|
46
|
+
/** ISO 8601 timestamp the entry was recorded. */
|
|
47
|
+
createdAt: z.string().min(1),
|
|
48
|
+
})
|
|
49
|
+
.strict();
|
|
50
|
+
/** Current on-disk file version — lets a future migration detect old files. */
|
|
51
|
+
export const MEMORY_FILE_VERSION = 1;
|
|
52
|
+
/** The whole `entries.json` file shape (one file per scope). */
|
|
53
|
+
export const MemoryFileSchema = z
|
|
54
|
+
.object({
|
|
55
|
+
version: z.number().int().positive().default(MEMORY_FILE_VERSION),
|
|
56
|
+
entries: z.array(z.unknown()).default([]),
|
|
57
|
+
})
|
|
58
|
+
.strict();
|
package/dist/plan/service.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Message, Provider } from "@cruxy/sdk";
|
|
1
|
+
import type { Message, Provider, Usage } from "@cruxy/sdk";
|
|
2
2
|
import type { CruxyConfig } from "../config/index.js";
|
|
3
3
|
import type { PromptIO } from "../approval/index.js";
|
|
4
4
|
import { ToolRegistry, type ToolContext } from "../tools/index.js";
|
|
@@ -34,6 +34,9 @@ export interface PlanSessionArgs {
|
|
|
34
34
|
dirty: boolean;
|
|
35
35
|
} | null;
|
|
36
36
|
projectInstructions?: string | null;
|
|
37
|
+
/** Persistent memory (C.29): recall block injected into the propose + execute
|
|
38
|
+
* phases' system prompts (reference data only). */
|
|
39
|
+
recalledMemory?: string | null;
|
|
37
40
|
renderer?: StreamRenderer;
|
|
38
41
|
/** Revision cap (defaults to {@link MAX_PLAN_REVISIONS}). */
|
|
39
42
|
maxRevisions?: number;
|
|
@@ -42,5 +45,14 @@ export interface PlanSessionArgs {
|
|
|
42
45
|
* and step execution on `main-turn`; omitted → the provider default.
|
|
43
46
|
*/
|
|
44
47
|
router?: Router;
|
|
48
|
+
/**
|
|
49
|
+
* Usage telemetry (C.22): forwarded to every model request the plan turn
|
|
50
|
+
* drives (propose + each execution step), so plan-mode usage is captured and
|
|
51
|
+
* tier-attributed exactly like a normal turn.
|
|
52
|
+
*/
|
|
53
|
+
onRequestUsage?: (req: {
|
|
54
|
+
tier?: string;
|
|
55
|
+
usage?: Usage;
|
|
56
|
+
}) => void;
|
|
45
57
|
}
|
|
46
58
|
export declare function runPlanSession(args: PlanSessionArgs): Promise<AgentResult>;
|
package/dist/plan/service.js
CHANGED
|
@@ -73,10 +73,12 @@ export async function runPlanSession(args) {
|
|
|
73
73
|
ctx: args.ctx,
|
|
74
74
|
git: args.git,
|
|
75
75
|
projectInstructions: args.projectInstructions,
|
|
76
|
+
recalledMemory: args.recalledMemory,
|
|
76
77
|
renderer: args.renderer,
|
|
77
78
|
planMode: true,
|
|
78
79
|
router: args.router,
|
|
79
80
|
taskClass: "plan",
|
|
81
|
+
onRequestUsage: args.onRequestUsage,
|
|
80
82
|
}));
|
|
81
83
|
if (!holder.plan) {
|
|
82
84
|
throw planInvalid("the model ended its turn without calling submit_plan");
|
|
@@ -107,9 +109,11 @@ export async function runPlanSession(args) {
|
|
|
107
109
|
ctx: args.ctx,
|
|
108
110
|
git: args.git,
|
|
109
111
|
projectInstructions: args.projectInstructions,
|
|
112
|
+
recalledMemory: args.recalledMemory,
|
|
110
113
|
renderer: args.renderer,
|
|
111
114
|
router: args.router,
|
|
112
115
|
taskClass: "main-turn",
|
|
116
|
+
onRequestUsage: args.onRequestUsage,
|
|
113
117
|
}));
|
|
114
118
|
};
|
|
115
119
|
await executePlan(plan, {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Usage } from "@cruxy/sdk";
|
|
2
|
+
import type { UsageRecord } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Usage collection (C.22). Accumulates per-request usage exactly as the agent
|
|
5
|
+
* loop reports it — one {@link UsageEntry} per completed model request — and
|
|
6
|
+
* emits a {@link UsageRecord} for the run.
|
|
7
|
+
*
|
|
8
|
+
* The one honesty invariant: `usage: undefined` (the loop's signal that the
|
|
9
|
+
* provider returned NO usage event for a request) is recorded as `undefined`
|
|
10
|
+
* token counts — the honest "unknown". A provider-reported `0` arrives as a real
|
|
11
|
+
* `Usage` and is stored as `0`. Nothing is estimated, re-tokenized, or
|
|
12
|
+
* zero-filled, and this module makes ZERO network calls.
|
|
13
|
+
*/
|
|
14
|
+
/** What the loop hands over for one completed request. */
|
|
15
|
+
export interface RequestUsage {
|
|
16
|
+
/** The routing tier (C.30) the request ran on, if routing was active. */
|
|
17
|
+
tier?: string;
|
|
18
|
+
/**
|
|
19
|
+
* The provider's usage for THIS request, or `undefined` when the provider
|
|
20
|
+
* emitted no usage event (⇒ tokens are unknown, not zero).
|
|
21
|
+
*/
|
|
22
|
+
usage?: Usage;
|
|
23
|
+
}
|
|
24
|
+
/** Wall clock as an injectable seam so tests are deterministic. */
|
|
25
|
+
export type Clock = () => string;
|
|
26
|
+
export declare class UsageCollector {
|
|
27
|
+
private readonly now;
|
|
28
|
+
private readonly entries;
|
|
29
|
+
constructor(now?: Clock);
|
|
30
|
+
/**
|
|
31
|
+
* Record one completed request. When `req.usage` is absent the entry's token
|
|
32
|
+
* counts stay `undefined` — the provider reported nothing, so we assert
|
|
33
|
+
* nothing. A real reported `0` is preserved as `0`.
|
|
34
|
+
*/
|
|
35
|
+
record(req: RequestUsage): void;
|
|
36
|
+
/** How many requests have been recorded so far. */
|
|
37
|
+
get count(): number;
|
|
38
|
+
/** Snapshot the collected entries into a persistable {@link UsageRecord}. */
|
|
39
|
+
toRecord(runId: string, sessionId: string | undefined, startedAt: string): UsageRecord;
|
|
40
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const systemClock = () => new Date().toISOString();
|
|
2
|
+
export class UsageCollector {
|
|
3
|
+
now;
|
|
4
|
+
entries = [];
|
|
5
|
+
constructor(now = systemClock) {
|
|
6
|
+
this.now = now;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Record one completed request. When `req.usage` is absent the entry's token
|
|
10
|
+
* counts stay `undefined` — the provider reported nothing, so we assert
|
|
11
|
+
* nothing. A real reported `0` is preserved as `0`.
|
|
12
|
+
*/
|
|
13
|
+
record(req) {
|
|
14
|
+
this.entries.push({
|
|
15
|
+
tier: req.tier,
|
|
16
|
+
inputTokens: req.usage?.input_tokens,
|
|
17
|
+
outputTokens: req.usage?.output_tokens,
|
|
18
|
+
at: this.now(),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/** How many requests have been recorded so far. */
|
|
22
|
+
get count() {
|
|
23
|
+
return this.entries.length;
|
|
24
|
+
}
|
|
25
|
+
/** Snapshot the collected entries into a persistable {@link UsageRecord}. */
|
|
26
|
+
toRecord(runId, sessionId, startedAt) {
|
|
27
|
+
return {
|
|
28
|
+
runId,
|
|
29
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
30
|
+
startedAt,
|
|
31
|
+
entries: [...this.entries],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PriceTable, TierPrice } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Token → cost mapping (C.22). The whole discipline lives in one rule: a cost is
|
|
4
|
+
* produced ONLY when the tier has a configured price AND at least one token side
|
|
5
|
+
* is known. Otherwise the result is `undefined` — cost is omitted, tokens are
|
|
6
|
+
* still shown, and NO dollar figure is ever fabricated. Prices are per MILLION
|
|
7
|
+
* tokens (see {@link TierPrice}). Keyed by tier only (U.8 gag). No network.
|
|
8
|
+
*/
|
|
9
|
+
/** The configured price for a tier, or `undefined` when the tier is unpriced. */
|
|
10
|
+
export declare function priceForTier(tier: string, prices: PriceTable): TierPrice | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Compute the cost of a tier's usage, or `undefined` when it cannot be stated
|
|
13
|
+
* honestly:
|
|
14
|
+
* - no configured price for the tier → `undefined` (cost omitted).
|
|
15
|
+
* - both token counts unknown → `undefined` (nothing real to price).
|
|
16
|
+
* A known side is priced; an unknown side contributes nothing (never a
|
|
17
|
+
* fabricated 0-token charge). tokens/1e6 × price, summed.
|
|
18
|
+
*/
|
|
19
|
+
export declare function costFor(tier: string, inputTokens: number | undefined, outputTokens: number | undefined, prices: PriceTable): number | undefined;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token → cost mapping (C.22). The whole discipline lives in one rule: a cost is
|
|
3
|
+
* produced ONLY when the tier has a configured price AND at least one token side
|
|
4
|
+
* is known. Otherwise the result is `undefined` — cost is omitted, tokens are
|
|
5
|
+
* still shown, and NO dollar figure is ever fabricated. Prices are per MILLION
|
|
6
|
+
* tokens (see {@link TierPrice}). Keyed by tier only (U.8 gag). No network.
|
|
7
|
+
*/
|
|
8
|
+
/** The configured price for a tier, or `undefined` when the tier is unpriced. */
|
|
9
|
+
export function priceForTier(tier, prices) {
|
|
10
|
+
return prices[tier];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Compute the cost of a tier's usage, or `undefined` when it cannot be stated
|
|
14
|
+
* honestly:
|
|
15
|
+
* - no configured price for the tier → `undefined` (cost omitted).
|
|
16
|
+
* - both token counts unknown → `undefined` (nothing real to price).
|
|
17
|
+
* A known side is priced; an unknown side contributes nothing (never a
|
|
18
|
+
* fabricated 0-token charge). tokens/1e6 × price, summed.
|
|
19
|
+
*/
|
|
20
|
+
export function costFor(tier, inputTokens, outputTokens, prices) {
|
|
21
|
+
const price = priceForTier(tier, prices);
|
|
22
|
+
if (!price)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (inputTokens === undefined && outputTokens === undefined)
|
|
25
|
+
return undefined;
|
|
26
|
+
const inCost = inputTokens !== undefined ? (inputTokens / 1_000_000) * price.input : 0;
|
|
27
|
+
const outCost = outputTokens !== undefined ? (outputTokens / 1_000_000) * price.output : 0;
|
|
28
|
+
return inCost + outCost;
|
|
29
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage telemetry + cost tracking (C.22) — LOCAL usage accounting only.
|
|
3
|
+
*
|
|
4
|
+
* NO-PHONE-HOME GUARANTEE: nothing here transmits anything. This module reads
|
|
5
|
+
* and writes ONE local file under `~/.cruxy/usage` and renders to the terminal;
|
|
6
|
+
* it imports no provider, no transport, and makes no `fetch`/HTTP call. The
|
|
7
|
+
* `@cruxy/sdk` import below is a TYPE-only import (`Usage`), erased at build. A
|
|
8
|
+
* future opt-in remote report would be a new, clearly-named seam — this build
|
|
9
|
+
* ships nothing that sends. Asserted by the runtime + static no-phone-home tests.
|
|
10
|
+
*/
|
|
11
|
+
export * from "./types.js";
|
|
12
|
+
export { UsageCollector, type RequestUsage, type Clock } from "./collect.js";
|
|
13
|
+
export { costFor, priceForTier } from "./cost.js";
|
|
14
|
+
export { loadUsage, appendRun, usageStorePath } from "./store.js";
|
|
15
|
+
export { summarizeRuns, renderSummary, formatCost, type SummarizeOptions, } from "./summary.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage telemetry + cost tracking (C.22) — LOCAL usage accounting only.
|
|
3
|
+
*
|
|
4
|
+
* NO-PHONE-HOME GUARANTEE: nothing here transmits anything. This module reads
|
|
5
|
+
* and writes ONE local file under `~/.cruxy/usage` and renders to the terminal;
|
|
6
|
+
* it imports no provider, no transport, and makes no `fetch`/HTTP call. The
|
|
7
|
+
* `@cruxy/sdk` import below is a TYPE-only import (`Usage`), erased at build. A
|
|
8
|
+
* future opt-in remote report would be a new, clearly-named seam — this build
|
|
9
|
+
* ships nothing that sends. Asserted by the runtime + static no-phone-home tests.
|
|
10
|
+
*/
|
|
11
|
+
export * from "./types.js";
|
|
12
|
+
export { UsageCollector } from "./collect.js";
|
|
13
|
+
export { costFor, priceForTier } from "./cost.js";
|
|
14
|
+
export { loadUsage, appendRun, usageStorePath } from "./store.js";
|
|
15
|
+
export { summarizeRuns, renderSummary, formatCost, } from "./summary.js";
|