memoryrail 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +189 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +364 -0
- package/dist/doctor.d.ts +8 -0
- package/dist/doctor.js +55 -0
- package/dist/frontmatter.d.ts +11 -0
- package/dist/frontmatter.js +57 -0
- package/dist/git.d.ts +4 -0
- package/dist/git.js +34 -0
- package/dist/handoff.d.ts +13 -0
- package/dist/handoff.js +35 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +14 -0
- package/dist/install.d.ts +21 -0
- package/dist/install.js +50 -0
- package/dist/lint.d.ts +14 -0
- package/dist/lint.js +86 -0
- package/dist/mcp.d.ts +4 -0
- package/dist/mcp.js +87 -0
- package/dist/precheck.d.ts +23 -0
- package/dist/precheck.js +66 -0
- package/dist/refs.d.ts +8 -0
- package/dist/refs.js +18 -0
- package/dist/search.d.ts +25 -0
- package/dist/search.js +104 -0
- package/dist/secrets.d.ts +14 -0
- package/dist/secrets.js +42 -0
- package/dist/store.d.ts +50 -0
- package/dist/store.js +273 -0
- package/dist/sync.d.ts +20 -0
- package/dist/sync.js +81 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.js +10 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +4 -0
- package/docs/SPEC.md +109 -0
- package/package.json +59 -4
- package/spec/memory.schema.json +30 -0
package/dist/search.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { refOf } from "./refs.js";
|
|
2
|
+
const STOPWORDS = new Set("a an and are as at be by for from has have how in is it its of on or that the this to was we what when where which with you your do does did not no can i".split(" "));
|
|
3
|
+
export function tokenize(text) {
|
|
4
|
+
return text
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.split(/[^a-z0-9]+/)
|
|
7
|
+
.filter((t) => t.length > 1 && !STOPWORDS.has(t))
|
|
8
|
+
.map(stem);
|
|
9
|
+
}
|
|
10
|
+
function stem(t) {
|
|
11
|
+
if (t.length > 4 && t.endsWith("ies"))
|
|
12
|
+
return t.slice(0, -3) + "y";
|
|
13
|
+
if (t.length > 3 && t.endsWith("s") && !t.endsWith("ss"))
|
|
14
|
+
return t.slice(0, -1);
|
|
15
|
+
return t;
|
|
16
|
+
}
|
|
17
|
+
export function estimateTokens(text) {
|
|
18
|
+
return Math.ceil(text.length / 4);
|
|
19
|
+
}
|
|
20
|
+
const K1 = 1.2;
|
|
21
|
+
const B = 0.75;
|
|
22
|
+
function termFreq(m) {
|
|
23
|
+
const tf = new Map();
|
|
24
|
+
const add = (text, weight) => {
|
|
25
|
+
for (const t of tokenize(text))
|
|
26
|
+
tf.set(t, (tf.get(t) ?? 0) + weight);
|
|
27
|
+
};
|
|
28
|
+
add(m.title, 3);
|
|
29
|
+
add(m.tags.join(" "), 3);
|
|
30
|
+
add(m.links.join(" "), 1);
|
|
31
|
+
add(m.body, 1);
|
|
32
|
+
return tf;
|
|
33
|
+
}
|
|
34
|
+
/** BM25 over title/tags/links/body, with a mild recency boost. */
|
|
35
|
+
export function rank(memories, query, now = new Date()) {
|
|
36
|
+
const qTerms = [...new Set(tokenize(query))];
|
|
37
|
+
const docs = memories.map((m) => ({ m, tf: termFreq(m) }));
|
|
38
|
+
const lens = docs.map((d) => [...d.tf.values()].reduce((a, b) => a + b, 0));
|
|
39
|
+
const avg = lens.reduce((a, b) => a + b, 0) / Math.max(1, lens.length) || 1;
|
|
40
|
+
const n = docs.length;
|
|
41
|
+
return docs
|
|
42
|
+
.map((d, i) => {
|
|
43
|
+
let score = 0;
|
|
44
|
+
for (const term of qTerms) {
|
|
45
|
+
const f = d.tf.get(term) ?? 0;
|
|
46
|
+
if (!f)
|
|
47
|
+
continue;
|
|
48
|
+
const df = docs.reduce((c, x) => c + (x.tf.has(term) ? 1 : 0), 0);
|
|
49
|
+
const idf = Math.log(1 + (n - df + 0.5) / (df + 0.5));
|
|
50
|
+
score += idf * ((f * (K1 + 1)) / (f + K1 * (1 - B + B * ((lens[i] ?? 0) / avg))));
|
|
51
|
+
}
|
|
52
|
+
const ageDays = Math.max(0, (now.getTime() - Date.parse(d.m.updated)) / 86_400_000);
|
|
53
|
+
const recency = Math.exp(-ageDays / 90);
|
|
54
|
+
return { memory: d.m, score: score * (1 + 0.25 * recency) + 0.001 * recency };
|
|
55
|
+
})
|
|
56
|
+
.sort((a, b) => b.score - a.score || Date.parse(b.memory.updated) - Date.parse(a.memory.updated));
|
|
57
|
+
}
|
|
58
|
+
export function renderMemory(m) {
|
|
59
|
+
const head = `### [${refOf(m)}] ${m.title}`;
|
|
60
|
+
const meta = [`${m.type}`, `id: ${m.id}`];
|
|
61
|
+
if (m.tags.length)
|
|
62
|
+
meta.push(`tags: ${m.tags.join(", ")}`);
|
|
63
|
+
if (m.links.length)
|
|
64
|
+
meta.push(`files: ${m.links.join(", ")}`);
|
|
65
|
+
if (m.status !== "active")
|
|
66
|
+
meta.push(`status: ${m.status}`);
|
|
67
|
+
return [head, m.body, meta.length ? `_${meta.join(" · ")}_` : ""].filter(Boolean).join("\n");
|
|
68
|
+
}
|
|
69
|
+
export function recall(all, opts = {}) {
|
|
70
|
+
const limit = opts.limit ?? 8;
|
|
71
|
+
const budget = opts.budget ?? 1500;
|
|
72
|
+
const pool = all.filter((m) => (opts.includeInactive || m.status === "active") && (!opts.types?.length || opts.types.includes(m.type)));
|
|
73
|
+
const pinned = pool
|
|
74
|
+
.filter((m) => m.pinned)
|
|
75
|
+
.sort((a, b) => Date.parse(b.updated) - Date.parse(a.updated))
|
|
76
|
+
.map((memory) => ({ memory, score: Number.POSITIVE_INFINITY }));
|
|
77
|
+
const rest = pool.filter((m) => !m.pinned);
|
|
78
|
+
const query = opts.query?.trim() ?? "";
|
|
79
|
+
const ranked = query
|
|
80
|
+
? rank(rest, query, opts.now).filter((h) => h.score > 0.01)
|
|
81
|
+
: rank(rest, "", opts.now);
|
|
82
|
+
const ordered = [...pinned, ...ranked];
|
|
83
|
+
const hits = [];
|
|
84
|
+
const parts = [];
|
|
85
|
+
let used = 0;
|
|
86
|
+
let truncated = false;
|
|
87
|
+
for (const hit of ordered) {
|
|
88
|
+
if (hits.length >= limit) {
|
|
89
|
+
truncated = true;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
const text = renderMemory(hit.memory);
|
|
93
|
+
const cost = estimateTokens(text);
|
|
94
|
+
// Always return at least one result, then respect the budget.
|
|
95
|
+
if (hits.length > 0 && used + cost > budget) {
|
|
96
|
+
truncated = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
hits.push(hit);
|
|
100
|
+
parts.push(text);
|
|
101
|
+
used += cost;
|
|
102
|
+
}
|
|
103
|
+
return { hits, rendered: parts.join("\n\n"), truncated };
|
|
104
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory is committed to git and fed back to models, so a secret written once
|
|
3
|
+
* is published and re-sent forever. Scan on write and refuse.
|
|
4
|
+
*
|
|
5
|
+
* Findings carry only the rule name, never the matched text.
|
|
6
|
+
*/
|
|
7
|
+
export interface SecretFinding {
|
|
8
|
+
rule: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function scanSecrets(...texts: Array<string | undefined>): SecretFinding[];
|
|
11
|
+
export declare class SecretError extends Error {
|
|
12
|
+
readonly findings: SecretFinding[];
|
|
13
|
+
constructor(findings: SecretFinding[]);
|
|
14
|
+
}
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory is committed to git and fed back to models, so a secret written once
|
|
3
|
+
* is published and re-sent forever. Scan on write and refuse.
|
|
4
|
+
*
|
|
5
|
+
* Findings carry only the rule name, never the matched text.
|
|
6
|
+
*/
|
|
7
|
+
const MIXED = String.raw `(?=[^\s'"]*\d)(?=[^\s'"]*[A-Za-z])[^\s'"]{8,}`;
|
|
8
|
+
const RULES = [
|
|
9
|
+
["private-key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
|
10
|
+
["aws-access-key", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
|
|
11
|
+
["github-token", /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})\b/],
|
|
12
|
+
["slack-token", /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/],
|
|
13
|
+
["stripe-key", /\b[rs]k_live_[A-Za-z0-9]{16,}\b/],
|
|
14
|
+
["google-api-key", /\bAIza[0-9A-Za-z_-]{35}\b/],
|
|
15
|
+
["anthropic-key", /\bsk-ant-[A-Za-z0-9_-]{20,}\b/],
|
|
16
|
+
["openai-key", /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b/],
|
|
17
|
+
["jwt", /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/],
|
|
18
|
+
["url-credentials", /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]{3,}@/i],
|
|
19
|
+
[
|
|
20
|
+
"credential-assignment",
|
|
21
|
+
new RegExp(String.raw `\b(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|auth)\w*\s*[:=]\s*['"]?${MIXED}`, "i"),
|
|
22
|
+
],
|
|
23
|
+
];
|
|
24
|
+
export function scanSecrets(...texts) {
|
|
25
|
+
const found = new Set();
|
|
26
|
+
for (const t of texts) {
|
|
27
|
+
if (!t)
|
|
28
|
+
continue;
|
|
29
|
+
for (const [rule, re] of RULES)
|
|
30
|
+
if (re.test(t))
|
|
31
|
+
found.add(rule);
|
|
32
|
+
}
|
|
33
|
+
return [...found].map((rule) => ({ rule }));
|
|
34
|
+
}
|
|
35
|
+
export class SecretError extends Error {
|
|
36
|
+
findings;
|
|
37
|
+
constructor(findings) {
|
|
38
|
+
super(`refusing to save: looks like it contains a secret (${findings.map((f) => f.rule).join(", ")}). ` +
|
|
39
|
+
`Describe where the secret lives instead of what it is. Humans can override with --allow-secrets.`);
|
|
40
|
+
this.findings = findings;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type Memory, type NewMemory, type Status } from "./types.js";
|
|
2
|
+
export declare const DIR_NAME = ".memoryrail";
|
|
3
|
+
export declare const MEMORIES_DIR = "memories";
|
|
4
|
+
export declare const FORMAT_VERSION = 1;
|
|
5
|
+
export interface LoadError {
|
|
6
|
+
file: string;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
export interface Config {
|
|
10
|
+
version: number;
|
|
11
|
+
/**
|
|
12
|
+
* `agents`: durable memories written over MCP start as `proposed` and need
|
|
13
|
+
* `memoryrail approve`. `off`: they are active immediately (git review only).
|
|
14
|
+
*/
|
|
15
|
+
review: "off" | "agents";
|
|
16
|
+
}
|
|
17
|
+
export declare function readConfig(root: string): Config;
|
|
18
|
+
export declare function writeConfig(root: string, config: Config): void;
|
|
19
|
+
/** Walk up from `start` looking for a directory that contains `.memoryrail/`. */
|
|
20
|
+
export declare function findRoot(start?: string): string | null;
|
|
21
|
+
export declare function initRoot(root: string): {
|
|
22
|
+
created: boolean;
|
|
23
|
+
};
|
|
24
|
+
export declare function slugify(s: string): string;
|
|
25
|
+
export declare class Store {
|
|
26
|
+
readonly root: string;
|
|
27
|
+
constructor(root: string);
|
|
28
|
+
get dir(): string;
|
|
29
|
+
private file;
|
|
30
|
+
/** Load every memory, reporting files that fail validation instead of throwing. */
|
|
31
|
+
loadAll(): {
|
|
32
|
+
memories: Memory[];
|
|
33
|
+
errors: LoadError[];
|
|
34
|
+
};
|
|
35
|
+
list(): Memory[];
|
|
36
|
+
get(id: string): Memory | undefined;
|
|
37
|
+
config(): Config;
|
|
38
|
+
/** Resolve an exact id, a short ref like `DEC-a3f9`, or an unambiguous id prefix. */
|
|
39
|
+
resolveId(idOrPrefix: string): Memory;
|
|
40
|
+
add(input: NewMemory): Memory;
|
|
41
|
+
/** Accept a proposed memory. Applies its supersession, if any. */
|
|
42
|
+
approve(idOrRef: string): Memory;
|
|
43
|
+
/** Discard a proposed memory. */
|
|
44
|
+
reject(idOrRef: string): Memory;
|
|
45
|
+
setStatus(idOrPrefix: string, status: Status): Memory;
|
|
46
|
+
/** Permanently delete a memory file. Prefer `setStatus(id, "archived")`. */
|
|
47
|
+
remove(idOrPrefix: string): Memory;
|
|
48
|
+
private write;
|
|
49
|
+
}
|
|
50
|
+
export declare function openStore(start?: string): Store;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse, stringify } from "./frontmatter.js";
|
|
4
|
+
import { REF_PATTERN, refOf } from "./refs.js";
|
|
5
|
+
import { SecretError, scanSecrets } from "./secrets.js";
|
|
6
|
+
import { isMemoryType, isStatus, } from "./types.js";
|
|
7
|
+
export const DIR_NAME = ".memoryrail";
|
|
8
|
+
export const MEMORIES_DIR = "memories";
|
|
9
|
+
export const FORMAT_VERSION = 1;
|
|
10
|
+
export function readConfig(root) {
|
|
11
|
+
const file = path.join(root, DIR_NAME, "config.json");
|
|
12
|
+
let raw = {};
|
|
13
|
+
try {
|
|
14
|
+
raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
/* fall through to defaults */
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
version: typeof raw["version"] === "number" ? raw["version"] : FORMAT_VERSION,
|
|
21
|
+
review: raw["review"] === "agents" ? "agents" : "off",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function writeConfig(root, config) {
|
|
25
|
+
fs.writeFileSync(path.join(root, DIR_NAME, "config.json"), JSON.stringify(config, null, 2) + "\n");
|
|
26
|
+
}
|
|
27
|
+
/** Walk up from `start` looking for a directory that contains `.memoryrail/`. */
|
|
28
|
+
export function findRoot(start = process.cwd()) {
|
|
29
|
+
const env = process.env["MEMORYRAIL_ROOT"];
|
|
30
|
+
if (env)
|
|
31
|
+
return fs.existsSync(path.join(env, DIR_NAME)) ? path.resolve(env) : null;
|
|
32
|
+
let dir = path.resolve(start);
|
|
33
|
+
for (;;) {
|
|
34
|
+
if (fs.existsSync(path.join(dir, DIR_NAME, "config.json")))
|
|
35
|
+
return dir;
|
|
36
|
+
const parent = path.dirname(dir);
|
|
37
|
+
if (parent === dir)
|
|
38
|
+
return null;
|
|
39
|
+
dir = parent;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function initRoot(root) {
|
|
43
|
+
const base = path.join(root, DIR_NAME);
|
|
44
|
+
const config = path.join(base, "config.json");
|
|
45
|
+
fs.mkdirSync(path.join(base, MEMORIES_DIR), { recursive: true });
|
|
46
|
+
if (fs.existsSync(config))
|
|
47
|
+
return { created: false };
|
|
48
|
+
writeConfig(root, { version: FORMAT_VERSION, review: "off" });
|
|
49
|
+
fs.writeFileSync(path.join(base, MEMORIES_DIR, ".gitkeep"), "");
|
|
50
|
+
return { created: true };
|
|
51
|
+
}
|
|
52
|
+
export function slugify(s) {
|
|
53
|
+
return (s
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.normalize("NFKD")
|
|
56
|
+
.replace(/[̀-ͯ]/g, "")
|
|
57
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
58
|
+
.replace(/^-+|-+$/g, "")
|
|
59
|
+
.slice(0, 48)
|
|
60
|
+
.replace(/-+$/, "") || "memory");
|
|
61
|
+
}
|
|
62
|
+
function stringList(v) {
|
|
63
|
+
if (v === undefined || v === null || v === "")
|
|
64
|
+
return [];
|
|
65
|
+
if (Array.isArray(v))
|
|
66
|
+
return v.map((x) => String(x));
|
|
67
|
+
return [String(v)];
|
|
68
|
+
}
|
|
69
|
+
function toMemory(data, body, fileId) {
|
|
70
|
+
const id = data["id"] === undefined ? fileId : String(data["id"]);
|
|
71
|
+
if (id !== fileId)
|
|
72
|
+
throw new Error(`id "${id}" does not match file name "${fileId}"`);
|
|
73
|
+
if (!isMemoryType(data["type"]))
|
|
74
|
+
throw new Error(`invalid or missing type: ${String(data["type"])}`);
|
|
75
|
+
const status = data["status"] ?? "active";
|
|
76
|
+
if (!isStatus(status))
|
|
77
|
+
throw new Error(`invalid status: ${String(status)}`);
|
|
78
|
+
if (typeof data["title"] !== "string" || !data["title"].trim())
|
|
79
|
+
throw new Error("missing title");
|
|
80
|
+
const created = String(data["created"] ?? "");
|
|
81
|
+
const updated = String(data["updated"] ?? created);
|
|
82
|
+
if (Number.isNaN(Date.parse(created)))
|
|
83
|
+
throw new Error(`invalid created date: ${created}`);
|
|
84
|
+
if (Number.isNaN(Date.parse(updated)))
|
|
85
|
+
throw new Error(`invalid updated date: ${updated}`);
|
|
86
|
+
const m = {
|
|
87
|
+
id,
|
|
88
|
+
type: data["type"],
|
|
89
|
+
title: data["title"].trim(),
|
|
90
|
+
status,
|
|
91
|
+
created,
|
|
92
|
+
updated,
|
|
93
|
+
tags: stringList(data["tags"]),
|
|
94
|
+
links: stringList(data["links"]),
|
|
95
|
+
pinned: data["pinned"] === true,
|
|
96
|
+
body,
|
|
97
|
+
};
|
|
98
|
+
if (data["supersedes"])
|
|
99
|
+
m.supersedes = String(data["supersedes"]);
|
|
100
|
+
if (data["superseded_by"])
|
|
101
|
+
m.supersededBy = String(data["superseded_by"]);
|
|
102
|
+
return m;
|
|
103
|
+
}
|
|
104
|
+
function serialize(m) {
|
|
105
|
+
return stringify({
|
|
106
|
+
id: m.id,
|
|
107
|
+
type: m.type,
|
|
108
|
+
title: m.title,
|
|
109
|
+
status: m.status,
|
|
110
|
+
created: m.created,
|
|
111
|
+
updated: m.updated,
|
|
112
|
+
tags: m.tags,
|
|
113
|
+
links: m.links,
|
|
114
|
+
pinned: m.pinned ? true : undefined,
|
|
115
|
+
supersedes: m.supersedes,
|
|
116
|
+
superseded_by: m.supersededBy,
|
|
117
|
+
}, m.body);
|
|
118
|
+
}
|
|
119
|
+
export class Store {
|
|
120
|
+
root;
|
|
121
|
+
constructor(root) {
|
|
122
|
+
this.root = path.resolve(root);
|
|
123
|
+
}
|
|
124
|
+
get dir() {
|
|
125
|
+
return path.join(this.root, DIR_NAME, MEMORIES_DIR);
|
|
126
|
+
}
|
|
127
|
+
file(id) {
|
|
128
|
+
return path.join(this.dir, `${id}.md`);
|
|
129
|
+
}
|
|
130
|
+
/** Load every memory, reporting files that fail validation instead of throwing. */
|
|
131
|
+
loadAll() {
|
|
132
|
+
const memories = [];
|
|
133
|
+
const errors = [];
|
|
134
|
+
if (!fs.existsSync(this.dir))
|
|
135
|
+
return { memories, errors };
|
|
136
|
+
for (const name of fs.readdirSync(this.dir).sort()) {
|
|
137
|
+
if (!name.endsWith(".md"))
|
|
138
|
+
continue;
|
|
139
|
+
const file = path.join(DIR_NAME, MEMORIES_DIR, name);
|
|
140
|
+
try {
|
|
141
|
+
const { data, body } = parse(fs.readFileSync(path.join(this.dir, name), "utf8"));
|
|
142
|
+
memories.push(toMemory(data, body, name.slice(0, -3)));
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
errors.push({ file, message: e instanceof Error ? e.message : String(e) });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { memories, errors };
|
|
149
|
+
}
|
|
150
|
+
list() {
|
|
151
|
+
return this.loadAll().memories;
|
|
152
|
+
}
|
|
153
|
+
get(id) {
|
|
154
|
+
if (!/^[\w.-]+$/.test(id))
|
|
155
|
+
return undefined;
|
|
156
|
+
return this.list().find((m) => m.id === id);
|
|
157
|
+
}
|
|
158
|
+
config() {
|
|
159
|
+
return readConfig(this.root);
|
|
160
|
+
}
|
|
161
|
+
/** Resolve an exact id, a short ref like `DEC-a3f9`, or an unambiguous id prefix. */
|
|
162
|
+
resolveId(idOrPrefix) {
|
|
163
|
+
const all = this.list();
|
|
164
|
+
const exact = all.find((m) => m.id === idOrPrefix);
|
|
165
|
+
if (exact)
|
|
166
|
+
return exact;
|
|
167
|
+
if (REF_PATTERN.test(idOrPrefix)) {
|
|
168
|
+
const wanted = idOrPrefix.toUpperCase().slice(0, 3) + idOrPrefix.slice(3).toLowerCase();
|
|
169
|
+
const byRef = all.filter((m) => refOf(m) === wanted);
|
|
170
|
+
if (byRef.length === 1)
|
|
171
|
+
return byRef[0];
|
|
172
|
+
if (byRef.length > 1)
|
|
173
|
+
throw new Error(`ref "${idOrPrefix}" is ambiguous: ${byRef.map((m) => m.id).join(", ")}`);
|
|
174
|
+
}
|
|
175
|
+
const matches = all.filter((m) => m.id.startsWith(idOrPrefix));
|
|
176
|
+
if (matches.length === 1)
|
|
177
|
+
return matches[0];
|
|
178
|
+
if (matches.length === 0)
|
|
179
|
+
throw new Error(`no memory with id "${idOrPrefix}"`);
|
|
180
|
+
throw new Error(`id prefix "${idOrPrefix}" is ambiguous: ${matches.map((m) => m.id).join(", ")}`);
|
|
181
|
+
}
|
|
182
|
+
add(input) {
|
|
183
|
+
const title = input.title.trim();
|
|
184
|
+
if (!title)
|
|
185
|
+
throw new Error("title is required");
|
|
186
|
+
if (!input.allowSecrets) {
|
|
187
|
+
const findings = scanSecrets(title, input.body, ...(input.tags ?? []), ...(input.links ?? []));
|
|
188
|
+
if (findings.length)
|
|
189
|
+
throw new SecretError(findings);
|
|
190
|
+
}
|
|
191
|
+
const prior = input.supersedes ? this.resolveId(input.supersedes) : undefined;
|
|
192
|
+
const status = input.status ?? "active";
|
|
193
|
+
const now = new Date().toISOString();
|
|
194
|
+
const stamp = now.slice(0, 10).replace(/-/g, "");
|
|
195
|
+
const base = `${stamp}-${slugify(title)}`;
|
|
196
|
+
let id = base;
|
|
197
|
+
for (let n = 2; fs.existsSync(this.file(id)); n++)
|
|
198
|
+
id = `${base}-${n}`;
|
|
199
|
+
const m = {
|
|
200
|
+
id,
|
|
201
|
+
type: input.type,
|
|
202
|
+
title,
|
|
203
|
+
status,
|
|
204
|
+
created: now,
|
|
205
|
+
updated: now,
|
|
206
|
+
tags: dedupe(input.tags ?? []),
|
|
207
|
+
links: dedupe((input.links ?? []).map(normalizeLink)),
|
|
208
|
+
pinned: input.pinned ?? false,
|
|
209
|
+
body: (input.body ?? "").trim(),
|
|
210
|
+
};
|
|
211
|
+
if (prior)
|
|
212
|
+
m.supersedes = prior.id;
|
|
213
|
+
this.write(m);
|
|
214
|
+
// A proposal must not retire the old memory until a human approves it.
|
|
215
|
+
if (prior && status === "active")
|
|
216
|
+
this.write({ ...prior, status: "superseded", supersededBy: id, updated: now });
|
|
217
|
+
return m;
|
|
218
|
+
}
|
|
219
|
+
/** Accept a proposed memory. Applies its supersession, if any. */
|
|
220
|
+
approve(idOrRef) {
|
|
221
|
+
const m = this.resolveId(idOrRef);
|
|
222
|
+
if (m.status !== "proposed")
|
|
223
|
+
throw new Error(`${m.id} is ${m.status}, not proposed`);
|
|
224
|
+
const now = new Date().toISOString();
|
|
225
|
+
const active = { ...m, status: "active", updated: now };
|
|
226
|
+
const prior = m.supersedes ? this.list().find((x) => x.id === m.supersedes) : undefined;
|
|
227
|
+
this.write(active);
|
|
228
|
+
if (prior && prior.status === "active") {
|
|
229
|
+
this.write({ ...prior, status: "superseded", supersededBy: m.id, updated: now });
|
|
230
|
+
}
|
|
231
|
+
return active;
|
|
232
|
+
}
|
|
233
|
+
/** Discard a proposed memory. */
|
|
234
|
+
reject(idOrRef) {
|
|
235
|
+
const m = this.resolveId(idOrRef);
|
|
236
|
+
if (m.status !== "proposed")
|
|
237
|
+
throw new Error(`${m.id} is ${m.status}, not proposed`);
|
|
238
|
+
fs.rmSync(this.file(m.id));
|
|
239
|
+
return m;
|
|
240
|
+
}
|
|
241
|
+
setStatus(idOrPrefix, status) {
|
|
242
|
+
const m = this.resolveId(idOrPrefix);
|
|
243
|
+
const next = { ...m, status, updated: new Date().toISOString() };
|
|
244
|
+
this.write(next);
|
|
245
|
+
return next;
|
|
246
|
+
}
|
|
247
|
+
/** Permanently delete a memory file. Prefer `setStatus(id, "archived")`. */
|
|
248
|
+
remove(idOrPrefix) {
|
|
249
|
+
const m = this.resolveId(idOrPrefix);
|
|
250
|
+
fs.rmSync(this.file(m.id));
|
|
251
|
+
return m;
|
|
252
|
+
}
|
|
253
|
+
write(m) {
|
|
254
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
255
|
+
const target = this.file(m.id);
|
|
256
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
257
|
+
fs.writeFileSync(tmp, serialize(m));
|
|
258
|
+
fs.renameSync(tmp, target);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function dedupe(xs) {
|
|
262
|
+
return [...new Set(xs.map((x) => x.trim()).filter(Boolean))];
|
|
263
|
+
}
|
|
264
|
+
function normalizeLink(p) {
|
|
265
|
+
return p.trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
|
266
|
+
}
|
|
267
|
+
export function openStore(start) {
|
|
268
|
+
const root = findRoot(start);
|
|
269
|
+
if (!root) {
|
|
270
|
+
throw new Error("no .memoryrail/ found in this directory or any parent. Run `memoryrail init` first.");
|
|
271
|
+
}
|
|
272
|
+
return new Store(root);
|
|
273
|
+
}
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Store } from "./store.js";
|
|
2
|
+
import type { Memory } from "./types.js";
|
|
3
|
+
export declare const START = "<!-- memoryrail:start -->";
|
|
4
|
+
export declare const END = "<!-- memoryrail:end -->";
|
|
5
|
+
export interface Target {
|
|
6
|
+
path: string;
|
|
7
|
+
/** Text to place before the managed block when the file is created from scratch. */
|
|
8
|
+
preamble?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const DEFAULT_TARGETS: Target[];
|
|
11
|
+
/** Deterministic: the same memories always render the same block, so `sync --check` is stable. */
|
|
12
|
+
export declare function renderBlock(memories: Memory[]): string;
|
|
13
|
+
export interface SyncChange {
|
|
14
|
+
path: string;
|
|
15
|
+
action: "created" | "updated" | "unchanged";
|
|
16
|
+
}
|
|
17
|
+
export declare function sync(store: Store, opts?: {
|
|
18
|
+
check?: boolean;
|
|
19
|
+
targets?: Target[];
|
|
20
|
+
}): SyncChange[];
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { refOf } from "./refs.js";
|
|
4
|
+
export const START = "<!-- memoryrail:start -->";
|
|
5
|
+
export const END = "<!-- memoryrail:end -->";
|
|
6
|
+
export const DEFAULT_TARGETS = [
|
|
7
|
+
{ path: "AGENTS.md" },
|
|
8
|
+
{ path: "CLAUDE.md" },
|
|
9
|
+
{
|
|
10
|
+
path: ".cursor/rules/memoryrail.mdc",
|
|
11
|
+
preamble: "---\ndescription: Project memory managed by MemoryRail\nalwaysApply: true\n---\n\n",
|
|
12
|
+
},
|
|
13
|
+
];
|
|
14
|
+
const SECTIONS = [
|
|
15
|
+
{ type: "constraint", heading: "Constraints" },
|
|
16
|
+
{ type: "decision", heading: "Decisions" },
|
|
17
|
+
{ type: "gotcha", heading: "Gotchas" },
|
|
18
|
+
{ type: "attempt", heading: "Failed approaches (do not retry without a new reason)" },
|
|
19
|
+
{ type: "thread", heading: "Open threads" },
|
|
20
|
+
];
|
|
21
|
+
const PER_SECTION = 25;
|
|
22
|
+
function condense(body, max = 280) {
|
|
23
|
+
const flat = body.replace(/\s+/g, " ").trim();
|
|
24
|
+
return flat.length > max ? flat.slice(0, max - 1).trimEnd() + "…" : flat;
|
|
25
|
+
}
|
|
26
|
+
function byPriority(a, b) {
|
|
27
|
+
return Number(b.pinned) - Number(a.pinned) || Date.parse(b.updated) - Date.parse(a.updated) || a.id.localeCompare(b.id);
|
|
28
|
+
}
|
|
29
|
+
/** Deterministic: the same memories always render the same block, so `sync --check` is stable. */
|
|
30
|
+
export function renderBlock(memories) {
|
|
31
|
+
const lines = [
|
|
32
|
+
START,
|
|
33
|
+
"## Project memory (managed by MemoryRail)",
|
|
34
|
+
"",
|
|
35
|
+
"_Generated from `.memoryrail/`. Do not edit between these markers: change the memories, then run `memoryrail sync`._",
|
|
36
|
+
];
|
|
37
|
+
for (const { type, heading } of SECTIONS) {
|
|
38
|
+
const items = memories
|
|
39
|
+
.filter((m) => m.type === type && m.status === "active")
|
|
40
|
+
.sort(byPriority)
|
|
41
|
+
.slice(0, PER_SECTION);
|
|
42
|
+
if (!items.length)
|
|
43
|
+
continue;
|
|
44
|
+
lines.push("", `### ${heading}`);
|
|
45
|
+
for (const m of items) {
|
|
46
|
+
const detail = condense(m.body);
|
|
47
|
+
const files = m.links.length ? ` (files: ${m.links.join(", ")})` : "";
|
|
48
|
+
lines.push(`- [${refOf(m)}] **${m.title}**${detail ? ` — ${detail}` : ""}${files}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
lines.push("", "How to use this memory:", "- Before changing code, run `memoryrail precheck \"<what you plan to do>\" --file <path>` (or the MCP `precheck` tool). Use `memoryrail recall \"<topic>\"` for detail.", "- Cite the memory you rely on as `[per DEC-xxxx]`. If your plan conflicts with a constraint or decision, stop and ask instead of proceeding.", "- Record new decisions, approaches that failed, and gotchas with `memoryrail remember`. Finish a session with `memoryrail handoff`.", END);
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
}
|
|
54
|
+
function applyBlock(existing, block, preamble = "") {
|
|
55
|
+
if (existing === null)
|
|
56
|
+
return `${preamble}${block}\n`;
|
|
57
|
+
const s = existing.indexOf(START);
|
|
58
|
+
const e = existing.indexOf(END);
|
|
59
|
+
if (s !== -1 && e > s)
|
|
60
|
+
return existing.slice(0, s) + block + existing.slice(e + END.length);
|
|
61
|
+
return existing.replace(/\s+$/, "") + `\n\n${block}\n`;
|
|
62
|
+
}
|
|
63
|
+
export function sync(store, opts = {}) {
|
|
64
|
+
const block = renderBlock(store.list());
|
|
65
|
+
const changes = [];
|
|
66
|
+
for (const t of opts.targets ?? DEFAULT_TARGETS) {
|
|
67
|
+
const abs = path.join(store.root, t.path);
|
|
68
|
+
const existing = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8").replace(/\r\n/g, "\n") : null;
|
|
69
|
+
const next = applyBlock(existing, block, t.preamble);
|
|
70
|
+
if (existing === next) {
|
|
71
|
+
changes.push({ path: t.path, action: "unchanged" });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
changes.push({ path: t.path, action: existing === null ? "created" : "updated" });
|
|
75
|
+
if (!opts.check) {
|
|
76
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
77
|
+
fs.writeFileSync(abs, next);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return changes;
|
|
81
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export declare const MEMORY_TYPES: readonly ["decision", "constraint", "gotcha", "attempt", "thread", "session"];
|
|
2
|
+
export type MemoryType = (typeof MEMORY_TYPES)[number];
|
|
3
|
+
/** Durable knowledge: what review mode gates and what precheck checks against. */
|
|
4
|
+
export declare const DURABLE_TYPES: readonly MemoryType[];
|
|
5
|
+
export declare const STATUSES: readonly ["active", "proposed", "superseded", "archived", "resolved"];
|
|
6
|
+
export type Status = (typeof STATUSES)[number];
|
|
7
|
+
export interface Memory {
|
|
8
|
+
id: string;
|
|
9
|
+
type: MemoryType;
|
|
10
|
+
title: string;
|
|
11
|
+
status: Status;
|
|
12
|
+
created: string;
|
|
13
|
+
updated: string;
|
|
14
|
+
tags: string[];
|
|
15
|
+
/** Repo-relative paths this memory is about. Used for staleness checks. */
|
|
16
|
+
links: string[];
|
|
17
|
+
/** Pinned memories are always returned first by recall. */
|
|
18
|
+
pinned: boolean;
|
|
19
|
+
supersedes?: string;
|
|
20
|
+
supersededBy?: string;
|
|
21
|
+
body: string;
|
|
22
|
+
}
|
|
23
|
+
export interface NewMemory {
|
|
24
|
+
type: MemoryType;
|
|
25
|
+
title: string;
|
|
26
|
+
body?: string;
|
|
27
|
+
tags?: string[];
|
|
28
|
+
links?: string[];
|
|
29
|
+
pinned?: boolean;
|
|
30
|
+
supersedes?: string;
|
|
31
|
+
/** `proposed` waits for human approval and is invisible to recall and sync. Default `active`. */
|
|
32
|
+
status?: "active" | "proposed";
|
|
33
|
+
/** Skip the secret scan. For deliberate human use only; never exposed over MCP. */
|
|
34
|
+
allowSecrets?: boolean;
|
|
35
|
+
}
|
|
36
|
+
export declare function isMemoryType(v: unknown): v is MemoryType;
|
|
37
|
+
export declare function isStatus(v: unknown): v is Status;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const MEMORY_TYPES = ["decision", "constraint", "gotcha", "attempt", "thread", "session"];
|
|
2
|
+
/** Durable knowledge: what review mode gates and what precheck checks against. */
|
|
3
|
+
export const DURABLE_TYPES = ["decision", "constraint", "gotcha", "attempt"];
|
|
4
|
+
export const STATUSES = ["active", "proposed", "superseded", "archived", "resolved"];
|
|
5
|
+
export function isMemoryType(v) {
|
|
6
|
+
return typeof v === "string" && MEMORY_TYPES.includes(v);
|
|
7
|
+
}
|
|
8
|
+
export function isStatus(v) {
|
|
9
|
+
return typeof v === "string" && STATUSES.includes(v);
|
|
10
|
+
}
|
package/dist/version.js
ADDED