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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal frontmatter codec. Values are written as JSON, which is a valid
|
|
3
|
+
* subset of YAML, so the files stay readable by any YAML tooling while this
|
|
4
|
+
* package needs no YAML dependency.
|
|
5
|
+
*/
|
|
6
|
+
export function parse(text) {
|
|
7
|
+
const src = text.replace(/\r\n/g, "\n");
|
|
8
|
+
if (!src.startsWith("---\n")) {
|
|
9
|
+
throw new Error("missing frontmatter (file must start with ---)");
|
|
10
|
+
}
|
|
11
|
+
const rest = src.slice(4);
|
|
12
|
+
let end = rest.indexOf("\n---\n");
|
|
13
|
+
let bodyStart;
|
|
14
|
+
if (end !== -1) {
|
|
15
|
+
bodyStart = end + 5;
|
|
16
|
+
}
|
|
17
|
+
else if (rest.endsWith("\n---")) {
|
|
18
|
+
end = rest.length - 4;
|
|
19
|
+
bodyStart = rest.length;
|
|
20
|
+
}
|
|
21
|
+
else if (rest.startsWith("---\n")) {
|
|
22
|
+
end = 0;
|
|
23
|
+
bodyStart = 4;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
throw new Error("unterminated frontmatter (missing closing ---)");
|
|
27
|
+
}
|
|
28
|
+
const data = {};
|
|
29
|
+
const block = end === 0 ? "" : rest.slice(0, end);
|
|
30
|
+
for (const [i, line] of block.split("\n").entries()) {
|
|
31
|
+
if (!line.trim() || line.trimStart().startsWith("#"))
|
|
32
|
+
continue;
|
|
33
|
+
const m = /^([A-Za-z_][\w-]*):[ \t]*(.*)$/.exec(line);
|
|
34
|
+
if (!m)
|
|
35
|
+
throw new Error(`invalid frontmatter line ${i + 2}: ${line}`);
|
|
36
|
+
data[m[1]] = parseValue(m[2]);
|
|
37
|
+
}
|
|
38
|
+
return { data, body: rest.slice(bodyStart).replace(/^\n+/, "").replace(/\s+$/, "") };
|
|
39
|
+
}
|
|
40
|
+
function parseValue(raw) {
|
|
41
|
+
const v = raw.trim();
|
|
42
|
+
if (v === "")
|
|
43
|
+
return "";
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(v);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return v;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function stringify(data, body) {
|
|
52
|
+
const lines = Object.entries(data)
|
|
53
|
+
.filter(([, v]) => v !== undefined)
|
|
54
|
+
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`);
|
|
55
|
+
const trimmed = body.replace(/\s+$/, "");
|
|
56
|
+
return `---\n${lines.join("\n")}\n---\n${trimmed ? `\n${trimmed}\n` : ""}`;
|
|
57
|
+
}
|
package/dist/git.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Paths staged for the next commit (empty outside a git repo). */
|
|
2
|
+
export declare function stagedFiles(root: string): string[];
|
|
3
|
+
/** Time of the last commit touching `file`, or null if unknown (no git, untracked, ...). */
|
|
4
|
+
export declare function lastCommitTime(root: string, file: string): Date | null;
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
/** Paths staged for the next commit (empty outside a git repo). */
|
|
3
|
+
export function stagedFiles(root) {
|
|
4
|
+
try {
|
|
5
|
+
return execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMRD"], {
|
|
6
|
+
cwd: root,
|
|
7
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
8
|
+
encoding: "utf8",
|
|
9
|
+
})
|
|
10
|
+
.split("\n")
|
|
11
|
+
.map((s) => s.trim())
|
|
12
|
+
.filter(Boolean);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Time of the last commit touching `file`, or null if unknown (no git, untracked, ...). */
|
|
19
|
+
export function lastCommitTime(root, file) {
|
|
20
|
+
try {
|
|
21
|
+
const out = execFileSync("git", ["log", "-1", "--format=%cI", "--", file], {
|
|
22
|
+
cwd: root,
|
|
23
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
}).trim();
|
|
26
|
+
if (!out)
|
|
27
|
+
return null;
|
|
28
|
+
const d = new Date(out);
|
|
29
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Memory, NewMemory } from "./types.js";
|
|
2
|
+
export interface HandoffInput {
|
|
3
|
+
title?: string;
|
|
4
|
+
summary: string;
|
|
5
|
+
next?: string[];
|
|
6
|
+
links?: string[];
|
|
7
|
+
tags?: string[];
|
|
8
|
+
}
|
|
9
|
+
export declare function handoffToMemory(input: HandoffInput, now?: Date): NewMemory;
|
|
10
|
+
/** A paste-ready prompt that brings a fresh agent up to speed. */
|
|
11
|
+
export declare function resumePrompt(memories: Memory[], opts?: {
|
|
12
|
+
recentDecisions?: number;
|
|
13
|
+
}): string;
|
package/dist/handoff.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { renderMemory } from "./search.js";
|
|
2
|
+
export function handoffToMemory(input, now = new Date()) {
|
|
3
|
+
const summary = input.summary.trim();
|
|
4
|
+
if (!summary)
|
|
5
|
+
throw new Error("handoff needs a summary of what was done");
|
|
6
|
+
const next = (input.next ?? []).map((n) => n.trim()).filter(Boolean);
|
|
7
|
+
const body = next.length ? `${summary}\n\n### Next steps\n${next.map((n) => `- ${n}`).join("\n")}` : summary;
|
|
8
|
+
return {
|
|
9
|
+
type: "session",
|
|
10
|
+
title: input.title?.trim() || `Session ${now.toISOString().slice(0, 16).replace("T", " ")}`,
|
|
11
|
+
body,
|
|
12
|
+
links: input.links,
|
|
13
|
+
tags: input.tags,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function section(heading, items, render = renderMemory) {
|
|
17
|
+
return items.length ? `## ${heading}\n\n${items.map(render).join("\n\n")}` : "";
|
|
18
|
+
}
|
|
19
|
+
const newest = (a, b) => Date.parse(b.updated) - Date.parse(a.updated);
|
|
20
|
+
/** A paste-ready prompt that brings a fresh agent up to speed. */
|
|
21
|
+
export function resumePrompt(memories, opts = {}) {
|
|
22
|
+
const active = memories.filter((m) => m.status === "active");
|
|
23
|
+
const of = (t) => active.filter((m) => m.type === t).sort(newest);
|
|
24
|
+
const lastSession = of("session")[0];
|
|
25
|
+
const parts = [
|
|
26
|
+
"You are resuming work on an existing project. Below is the project memory recorded by previous sessions. Treat constraints as rules, decisions as settled unless told otherwise, and gotchas as mistakes not to repeat.",
|
|
27
|
+
lastSession ? section("Last session", [lastSession]) : "",
|
|
28
|
+
section("Open threads", of("thread")),
|
|
29
|
+
section("Constraints", of("constraint")),
|
|
30
|
+
section("Recent decisions", of("decision").slice(0, opts.recentDecisions ?? 5)),
|
|
31
|
+
section("Gotchas", of("gotcha")),
|
|
32
|
+
"When you finish, record what you did and what is next with `memoryrail handoff`, and log any new decision with `memoryrail remember`.",
|
|
33
|
+
];
|
|
34
|
+
return parts.filter(Boolean).join("\n\n");
|
|
35
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export { parse as parseFrontmatter, stringify as stringifyFrontmatter } from "./frontmatter.js";
|
|
3
|
+
export { Store, findRoot, initRoot, openStore, slugify, DIR_NAME, MEMORIES_DIR, FORMAT_VERSION } from "./store.js";
|
|
4
|
+
export { recall, rank, tokenize, renderMemory, estimateTokens } from "./search.js";
|
|
5
|
+
export type { Hit, RecallOptions, RecallResult } from "./search.js";
|
|
6
|
+
export { lint } from "./lint.js";
|
|
7
|
+
export type { Issue, Severity } from "./lint.js";
|
|
8
|
+
export { sync, renderBlock, DEFAULT_TARGETS } from "./sync.js";
|
|
9
|
+
export type { SyncChange, Target } from "./sync.js";
|
|
10
|
+
export { handoffToMemory, resumePrompt } from "./handoff.js";
|
|
11
|
+
export type { HandoffInput } from "./handoff.js";
|
|
12
|
+
export { precheck } from "./precheck.js";
|
|
13
|
+
export type { PrecheckInput, PrecheckResult, Warning } from "./precheck.js";
|
|
14
|
+
export { refOf } from "./refs.js";
|
|
15
|
+
export { scanSecrets, SecretError } from "./secrets.js";
|
|
16
|
+
export type { SecretFinding } from "./secrets.js";
|
|
17
|
+
export { installMcp, isInstalled, serverEntry } from "./install.js";
|
|
18
|
+
export type { Client, InstallResult } from "./install.js";
|
|
19
|
+
export { doctor } from "./doctor.js";
|
|
20
|
+
export type { Check } from "./doctor.js";
|
|
21
|
+
export { readConfig, writeConfig } from "./store.js";
|
|
22
|
+
export type { Config } from "./store.js";
|
|
23
|
+
export { VERSION } from "./version.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export { parse as parseFrontmatter, stringify as stringifyFrontmatter } from "./frontmatter.js";
|
|
3
|
+
export { Store, findRoot, initRoot, openStore, slugify, DIR_NAME, MEMORIES_DIR, FORMAT_VERSION } from "./store.js";
|
|
4
|
+
export { recall, rank, tokenize, renderMemory, estimateTokens } from "./search.js";
|
|
5
|
+
export { lint } from "./lint.js";
|
|
6
|
+
export { sync, renderBlock, DEFAULT_TARGETS } from "./sync.js";
|
|
7
|
+
export { handoffToMemory, resumePrompt } from "./handoff.js";
|
|
8
|
+
export { precheck } from "./precheck.js";
|
|
9
|
+
export { refOf } from "./refs.js";
|
|
10
|
+
export { scanSecrets, SecretError } from "./secrets.js";
|
|
11
|
+
export { installMcp, isInstalled, serverEntry } from "./install.js";
|
|
12
|
+
export { doctor } from "./doctor.js";
|
|
13
|
+
export { readConfig, writeConfig } from "./store.js";
|
|
14
|
+
export { VERSION } from "./version.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type Client = "claude" | "cursor";
|
|
2
|
+
export declare const CLIENT_FILES: Record<Client, string>;
|
|
3
|
+
export interface InstallResult {
|
|
4
|
+
client: Client;
|
|
5
|
+
file: string;
|
|
6
|
+
action: "created" | "updated" | "unchanged";
|
|
7
|
+
}
|
|
8
|
+
export declare function serverEntry(opts?: {
|
|
9
|
+
npx?: boolean;
|
|
10
|
+
}): {
|
|
11
|
+
command: string;
|
|
12
|
+
args: string[];
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Register the MemoryRail MCP server in a client's project config, keeping
|
|
16
|
+
* every other server and setting in the file. Refuses to touch invalid JSON.
|
|
17
|
+
*/
|
|
18
|
+
export declare function installMcp(root: string, client: Client, opts?: {
|
|
19
|
+
npx?: boolean;
|
|
20
|
+
}): InstallResult;
|
|
21
|
+
export declare function isInstalled(root: string, client: Client): boolean;
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const CLIENT_FILES = {
|
|
4
|
+
claude: ".mcp.json", // Claude Code project-scoped MCP servers
|
|
5
|
+
cursor: ".cursor/mcp.json",
|
|
6
|
+
};
|
|
7
|
+
export function serverEntry(opts = {}) {
|
|
8
|
+
return opts.npx ? { command: "npx", args: ["-y", "memoryrail", "serve"] } : { command: "memoryrail", args: ["serve"] };
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Register the MemoryRail MCP server in a client's project config, keeping
|
|
12
|
+
* every other server and setting in the file. Refuses to touch invalid JSON.
|
|
13
|
+
*/
|
|
14
|
+
export function installMcp(root, client, opts = {}) {
|
|
15
|
+
const rel = CLIENT_FILES[client];
|
|
16
|
+
const abs = path.join(root, rel);
|
|
17
|
+
const entry = serverEntry(opts);
|
|
18
|
+
let doc = {};
|
|
19
|
+
const exists = fs.existsSync(abs);
|
|
20
|
+
if (exists) {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(fs.readFileSync(abs, "utf8"));
|
|
23
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
24
|
+
throw new Error("not an object");
|
|
25
|
+
doc = parsed;
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
throw new Error(`${rel} is not valid JSON (${e instanceof Error ? e.message : String(e)}); fix it first. Nothing was changed.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const servers = doc["mcpServers"] && typeof doc["mcpServers"] === "object" && !Array.isArray(doc["mcpServers"])
|
|
32
|
+
? doc["mcpServers"]
|
|
33
|
+
: {};
|
|
34
|
+
if (JSON.stringify(servers["memoryrail"]) === JSON.stringify(entry)) {
|
|
35
|
+
return { client, file: rel, action: "unchanged" };
|
|
36
|
+
}
|
|
37
|
+
const next = { ...doc, mcpServers: { ...servers, memoryrail: entry } };
|
|
38
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
39
|
+
fs.writeFileSync(abs, JSON.stringify(next, null, 2) + "\n");
|
|
40
|
+
return { client, file: rel, action: exists ? "updated" : "created" };
|
|
41
|
+
}
|
|
42
|
+
export function isInstalled(root, client) {
|
|
43
|
+
try {
|
|
44
|
+
const doc = JSON.parse(fs.readFileSync(path.join(root, CLIENT_FILES[client]), "utf8"));
|
|
45
|
+
return Boolean(doc.mcpServers && "memoryrail" in doc.mcpServers);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/dist/lint.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Store } from "./store.js";
|
|
2
|
+
export type Severity = "error" | "warning";
|
|
3
|
+
export interface Issue {
|
|
4
|
+
severity: Severity;
|
|
5
|
+
rule: string;
|
|
6
|
+
id?: string;
|
|
7
|
+
file?: string;
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
export interface LintOptions {
|
|
11
|
+
/** Check linked files against git history. Disabled in tests without a repo. */
|
|
12
|
+
git?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function lint(store: Store, opts?: LintOptions): Issue[];
|
package/dist/lint.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { lastCommitTime } from "./git.js";
|
|
4
|
+
import { scanSecrets } from "./secrets.js";
|
|
5
|
+
import { tokenize } from "./search.js";
|
|
6
|
+
function jaccard(a, b) {
|
|
7
|
+
if (!a.size || !b.size)
|
|
8
|
+
return 0;
|
|
9
|
+
let inter = 0;
|
|
10
|
+
for (const x of a)
|
|
11
|
+
if (b.has(x))
|
|
12
|
+
inter++;
|
|
13
|
+
return inter / (a.size + b.size - inter);
|
|
14
|
+
}
|
|
15
|
+
export function lint(store, opts = {}) {
|
|
16
|
+
const issues = [];
|
|
17
|
+
const { memories, errors } = store.loadAll();
|
|
18
|
+
for (const e of errors) {
|
|
19
|
+
issues.push({ severity: "error", rule: "invalid-file", file: e.file, message: e.message });
|
|
20
|
+
}
|
|
21
|
+
const byId = new Map(memories.map((m) => [m.id, m]));
|
|
22
|
+
const active = memories.filter((m) => m.status === "active");
|
|
23
|
+
for (const m of memories) {
|
|
24
|
+
const secrets = scanSecrets(m.title, m.body, ...m.tags, ...m.links);
|
|
25
|
+
if (secrets.length) {
|
|
26
|
+
issues.push({
|
|
27
|
+
severity: "error",
|
|
28
|
+
rule: "secret",
|
|
29
|
+
id: m.id,
|
|
30
|
+
message: `looks like it contains a secret (${secrets.map((s) => s.rule).join(", ")}); remove it and rotate the credential`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const m of memories) {
|
|
35
|
+
if (m.supersededBy) {
|
|
36
|
+
const next = byId.get(m.supersededBy);
|
|
37
|
+
if (!next) {
|
|
38
|
+
issues.push({ severity: "error", rule: "dangling-supersede", id: m.id, message: `superseded_by points to missing memory ${m.supersededBy}` });
|
|
39
|
+
}
|
|
40
|
+
else if (next.supersedes !== m.id && next.status !== "proposed") {
|
|
41
|
+
issues.push({ severity: "warning", rule: "supersede-mismatch", id: m.id, message: `${next.id} does not point back via supersedes` });
|
|
42
|
+
}
|
|
43
|
+
if (m.status === "active") {
|
|
44
|
+
issues.push({ severity: "warning", rule: "supersede-status", id: m.id, message: "has superseded_by but status is still active" });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (m.supersedes && !byId.has(m.supersedes)) {
|
|
48
|
+
issues.push({ severity: "error", rule: "dangling-supersede", id: m.id, message: `supersedes missing memory ${m.supersedes}` });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const m of active) {
|
|
52
|
+
for (const link of m.links) {
|
|
53
|
+
const abs = path.resolve(store.root, link);
|
|
54
|
+
if (!abs.startsWith(store.root + path.sep) && abs !== store.root) {
|
|
55
|
+
issues.push({ severity: "error", rule: "link-outside-repo", id: m.id, message: `link escapes the repository: ${link}` });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!fs.existsSync(abs)) {
|
|
59
|
+
issues.push({ severity: "warning", rule: "stale-link", id: m.id, message: `linked path no longer exists: ${link}` });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (opts.git !== false) {
|
|
63
|
+
const changed = lastCommitTime(store.root, link);
|
|
64
|
+
if (changed && changed.getTime() > Date.parse(m.updated)) {
|
|
65
|
+
issues.push({
|
|
66
|
+
severity: "warning",
|
|
67
|
+
rule: "maybe-stale",
|
|
68
|
+
id: m.id,
|
|
69
|
+
message: `${link} changed after this memory was last updated; review it`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const sets = active.map((m) => ({ m, t: new Set(tokenize(m.title)) }));
|
|
76
|
+
for (let i = 0; i < sets.length; i++) {
|
|
77
|
+
for (let j = i + 1; j < sets.length; j++) {
|
|
78
|
+
const a = sets[i];
|
|
79
|
+
const b = sets[j];
|
|
80
|
+
if (a.m.type === b.m.type && jaccard(a.t, b.t) >= 0.8) {
|
|
81
|
+
issues.push({ severity: "warning", rule: "possible-duplicate", id: a.m.id, message: `very similar to ${b.m.id}; merge or supersede one` });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return issues;
|
|
86
|
+
}
|
package/dist/mcp.d.ts
ADDED
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { handoffToMemory, resumePrompt } from "./handoff.js";
|
|
5
|
+
import { precheck } from "./precheck.js";
|
|
6
|
+
import { refOf } from "./refs.js";
|
|
7
|
+
import { recall } from "./search.js";
|
|
8
|
+
import { DURABLE_TYPES, MEMORY_TYPES } from "./types.js";
|
|
9
|
+
import { VERSION } from "./version.js";
|
|
10
|
+
const typeEnum = z.enum(MEMORY_TYPES);
|
|
11
|
+
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
12
|
+
const fail = (e) => ({
|
|
13
|
+
isError: true,
|
|
14
|
+
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
|
|
15
|
+
});
|
|
16
|
+
/** Run a tool body, turning thrown errors into MCP tool errors instead of crashing the server. */
|
|
17
|
+
function guard(fn) {
|
|
18
|
+
return (args) => {
|
|
19
|
+
try {
|
|
20
|
+
return text(fn(args));
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
return fail(e);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function createServer(store) {
|
|
28
|
+
const server = new McpServer({ name: "memoryrail", version: VERSION });
|
|
29
|
+
server.tool("recall", "Search this project's memory (decisions, constraints, gotchas, open threads, past sessions). Call this at the start of a task and before changing an area you have not touched. Returns the most relevant active memories within a token budget.", {
|
|
30
|
+
query: z.string().optional().describe("What you are about to work on. Omit for the most recent and pinned memories."),
|
|
31
|
+
types: z.array(typeEnum).optional().describe("Restrict to these memory types."),
|
|
32
|
+
limit: z.number().int().positive().max(50).optional(),
|
|
33
|
+
budget: z.number().int().positive().max(20000).optional().describe("Approximate token budget for the response."),
|
|
34
|
+
}, guard(({ query, types, limit, budget }) => {
|
|
35
|
+
const r = recall(store.list(), { query, types, limit, budget });
|
|
36
|
+
if (!r.hits.length)
|
|
37
|
+
return "No matching memories.";
|
|
38
|
+
return r.rendered + (r.truncated ? "\n\n(More results exist: raise limit or budget, or narrow the query.)" : "");
|
|
39
|
+
}));
|
|
40
|
+
server.tool("remember", "Record something future sessions must know. Use 'decision' for choices and their reasons, 'constraint' for rules to follow, 'gotcha' for traps, 'attempt' for an approach that was tried and FAILED (say why, so nobody retries it), 'thread' for unfinished work. Pass supersedes to replace an earlier memory instead of duplicating it. Never include secrets, tokens or passwords: they are rejected. If the project requires review, durable memories are saved as proposed until a human approves them.", {
|
|
41
|
+
type: typeEnum,
|
|
42
|
+
title: z.string().min(1).describe("One line. State the conclusion, not the topic."),
|
|
43
|
+
body: z.string().optional().describe("Why, and anything needed to apply it."),
|
|
44
|
+
tags: z.array(z.string()).optional(),
|
|
45
|
+
links: z.array(z.string()).optional().describe("Repo-relative files this is about, so staleness can be detected."),
|
|
46
|
+
pinned: z.boolean().optional().describe("Always surface this memory. Use sparingly."),
|
|
47
|
+
supersedes: z.string().optional().describe("Id of an earlier memory this replaces."),
|
|
48
|
+
}, guard((input) => {
|
|
49
|
+
const gated = store.config().review === "agents" && DURABLE_TYPES.includes(input.type);
|
|
50
|
+
const m = store.add({ ...input, status: gated ? "proposed" : "active" });
|
|
51
|
+
return gated
|
|
52
|
+
? `Proposed ${m.type} ${refOf(m)}: ${m.id}. It is not active until a human runs \`memoryrail approve ${refOf(m)}\`.`
|
|
53
|
+
: `Saved ${m.type} ${refOf(m)}: ${m.id}`;
|
|
54
|
+
}));
|
|
55
|
+
server.tool("precheck", "Call BEFORE you change code. Given what you plan to do and the files you will touch, returns the project's constraints, previously failed approaches, gotchas and decisions that apply. Cite what you rely on as [per REF]; if your plan conflicts with a constraint or decision, stop and ask the user.", {
|
|
56
|
+
plan: z.string().optional().describe("What you are about to do, in plain words."),
|
|
57
|
+
files: z.array(z.string()).optional().describe("Repo-relative paths you are about to change."),
|
|
58
|
+
}, guard(({ plan, files }) => precheck(store.list(), { plan, files }).rendered));
|
|
59
|
+
server.tool("list_memories", "List memory ids and titles, newest first. Use recall for content.", { types: z.array(typeEnum).optional(), include_inactive: z.boolean().optional() }, guard(({ types, include_inactive }) => {
|
|
60
|
+
const items = store
|
|
61
|
+
.list()
|
|
62
|
+
.filter((m) => (include_inactive || m.status === "active") && (!types?.length || types.includes(m.type)))
|
|
63
|
+
.sort((a, b) => Date.parse(b.updated) - Date.parse(a.updated));
|
|
64
|
+
return items.length ? items.map((m) => `${refOf(m)} [${m.type}] ${m.title}`).join("\n") : "No memories yet.";
|
|
65
|
+
}));
|
|
66
|
+
server.tool("forget", "Archive a memory that is wrong or no longer relevant. It stays in git history. For a thread that is finished, use resolve_thread instead.", { id: z.string() }, guard(({ id }) => `Archived ${store.setStatus(store.resolveId(id).id, "archived").id}`));
|
|
67
|
+
server.tool("resolve_thread", "Mark an open thread as resolved.", { id: z.string() }, guard(({ id }) => {
|
|
68
|
+
const m = store.resolveId(id);
|
|
69
|
+
if (m.type !== "thread")
|
|
70
|
+
throw new Error(`${m.id} is a ${m.type}, not a thread`);
|
|
71
|
+
return `Resolved ${store.setStatus(m.id, "resolved").id}`;
|
|
72
|
+
}));
|
|
73
|
+
server.tool("handoff", "Call at the end of a session: what you did, what is unfinished, and the next steps. The next session's resume prompt starts from this.", {
|
|
74
|
+
summary: z.string().min(1),
|
|
75
|
+
next: z.array(z.string()).optional().describe("Concrete next steps."),
|
|
76
|
+
title: z.string().optional(),
|
|
77
|
+
links: z.array(z.string()).optional(),
|
|
78
|
+
}, guard((input) => `Recorded handoff: ${store.add(handoffToMemory(input)).id}`));
|
|
79
|
+
server.tool("resume", "Get a prompt summarizing the last session, open threads, constraints, recent decisions and gotchas. Call this first in a new session.", {}, guard(() => resumePrompt(store.list())));
|
|
80
|
+
return server;
|
|
81
|
+
}
|
|
82
|
+
export async function serveStdio(store) {
|
|
83
|
+
const server = createServer(store);
|
|
84
|
+
await server.connect(new StdioServerTransport());
|
|
85
|
+
// stdout carries the protocol; log to stderr only.
|
|
86
|
+
process.stderr.write(`memoryrail MCP server ready (root: ${store.root})\n`);
|
|
87
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Memory } from "./types.js";
|
|
2
|
+
export interface Warning {
|
|
3
|
+
memory: Memory;
|
|
4
|
+
reasons: string[];
|
|
5
|
+
}
|
|
6
|
+
export interface PrecheckInput {
|
|
7
|
+
/** What you are about to do, in plain words. */
|
|
8
|
+
plan?: string;
|
|
9
|
+
/** Repo-relative paths you are about to change. */
|
|
10
|
+
files?: string[];
|
|
11
|
+
limit?: number;
|
|
12
|
+
now?: Date;
|
|
13
|
+
}
|
|
14
|
+
export interface PrecheckResult {
|
|
15
|
+
warnings: Warning[];
|
|
16
|
+
rendered: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Before acting: which rules, failed attempts, gotchas and decisions bear on
|
|
20
|
+
* this plan or these files? Surfaces what the project already learned so the
|
|
21
|
+
* agent does not repeat a dead end or break a constraint.
|
|
22
|
+
*/
|
|
23
|
+
export declare function precheck(all: Memory[], input: PrecheckInput): PrecheckResult;
|
package/dist/precheck.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { refOf } from "./refs.js";
|
|
2
|
+
import { rank } from "./search.js";
|
|
3
|
+
import { DURABLE_TYPES } from "./types.js";
|
|
4
|
+
/** Most binding first: rules, then things that already failed, then traps, then choices. */
|
|
5
|
+
const ORDER = ["constraint", "attempt", "gotcha", "decision"];
|
|
6
|
+
function normalize(p) {
|
|
7
|
+
return p.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
8
|
+
}
|
|
9
|
+
function touching(m, files) {
|
|
10
|
+
const hits = [];
|
|
11
|
+
for (const link of m.links.map(normalize)) {
|
|
12
|
+
for (const f of files)
|
|
13
|
+
if (f === link || f.startsWith(link + "/"))
|
|
14
|
+
hits.push(f);
|
|
15
|
+
}
|
|
16
|
+
return [...new Set(hits)];
|
|
17
|
+
}
|
|
18
|
+
function condense(body, max = 240) {
|
|
19
|
+
const flat = body.replace(/\s+/g, " ").trim();
|
|
20
|
+
return flat.length > max ? flat.slice(0, max - 1).trimEnd() + "…" : flat;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Before acting: which rules, failed attempts, gotchas and decisions bear on
|
|
24
|
+
* this plan or these files? Surfaces what the project already learned so the
|
|
25
|
+
* agent does not repeat a dead end or break a constraint.
|
|
26
|
+
*/
|
|
27
|
+
export function precheck(all, input) {
|
|
28
|
+
const files = (input.files ?? []).map(normalize).filter(Boolean);
|
|
29
|
+
const pool = all.filter((m) => m.status === "active" && DURABLE_TYPES.includes(m.type));
|
|
30
|
+
const plan = input.plan?.trim() ?? "";
|
|
31
|
+
const scores = new Map();
|
|
32
|
+
if (plan)
|
|
33
|
+
for (const h of rank(pool, plan, input.now))
|
|
34
|
+
if (h.score > 0.01)
|
|
35
|
+
scores.set(h.memory.id, h.score);
|
|
36
|
+
const warnings = [];
|
|
37
|
+
for (const m of pool) {
|
|
38
|
+
const reasons = [];
|
|
39
|
+
if (m.pinned && m.type === "constraint")
|
|
40
|
+
reasons.push("pinned rule");
|
|
41
|
+
const hits = touching(m, files);
|
|
42
|
+
if (hits.length)
|
|
43
|
+
reasons.push(`about ${hits.join(", ")}`);
|
|
44
|
+
if (scores.has(m.id))
|
|
45
|
+
reasons.push("matches your plan");
|
|
46
|
+
if (reasons.length)
|
|
47
|
+
warnings.push({ memory: m, reasons });
|
|
48
|
+
}
|
|
49
|
+
const weight = (w) => ORDER.indexOf(w.memory.type) * 10 - (touching(w.memory, files).length ? 5 : 0) - (scores.get(w.memory.id) ?? 0) / 100;
|
|
50
|
+
warnings.sort((a, b) => weight(a) - weight(b) || a.memory.id.localeCompare(b.memory.id));
|
|
51
|
+
const shown = warnings.slice(0, input.limit ?? 12);
|
|
52
|
+
if (!shown.length) {
|
|
53
|
+
return { warnings: [], rendered: "No relevant constraints, failed attempts, gotchas or decisions found for this plan." };
|
|
54
|
+
}
|
|
55
|
+
const lines = [
|
|
56
|
+
"Check these before you proceed. Cite what you rely on as [per REF]. If your plan conflicts with a constraint or decision, stop and ask.",
|
|
57
|
+
"",
|
|
58
|
+
...shown.map((w) => {
|
|
59
|
+
const body = condense(w.memory.body);
|
|
60
|
+
return `- [${refOf(w.memory)}] (${w.memory.type}) ${w.memory.title} — ${w.reasons.join("; ")}${body ? `\n ${body}` : ""}`;
|
|
61
|
+
}),
|
|
62
|
+
];
|
|
63
|
+
if (warnings.length > shown.length)
|
|
64
|
+
lines.push("", `(${warnings.length - shown.length} more; narrow the plan or files)`);
|
|
65
|
+
return { warnings: shown, rendered: lines.join("\n") };
|
|
66
|
+
}
|
package/dist/refs.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Memory } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* A short, stable handle for citing a memory, e.g. `DEC-a3f9`.
|
|
4
|
+
* Derived from the id, so it is never stored and two branches can never
|
|
5
|
+
* allocate the same counter value.
|
|
6
|
+
*/
|
|
7
|
+
export declare function refOf(m: Pick<Memory, "id" | "type">): string;
|
|
8
|
+
export declare const REF_PATTERN: RegExp;
|
package/dist/refs.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
const PREFIX = {
|
|
3
|
+
decision: "DEC",
|
|
4
|
+
constraint: "CON",
|
|
5
|
+
gotcha: "GOT",
|
|
6
|
+
attempt: "ATT",
|
|
7
|
+
thread: "THR",
|
|
8
|
+
session: "SES",
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* A short, stable handle for citing a memory, e.g. `DEC-a3f9`.
|
|
12
|
+
* Derived from the id, so it is never stored and two branches can never
|
|
13
|
+
* allocate the same counter value.
|
|
14
|
+
*/
|
|
15
|
+
export function refOf(m) {
|
|
16
|
+
return `${PREFIX[m.type]}-${createHash("sha1").update(m.id).digest("hex").slice(0, 4)}`;
|
|
17
|
+
}
|
|
18
|
+
export const REF_PATTERN = /^[A-Za-z]{3}-[0-9a-f]{4}$/;
|
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Memory, MemoryType } from "./types.js";
|
|
2
|
+
export declare function tokenize(text: string): string[];
|
|
3
|
+
export declare function estimateTokens(text: string): number;
|
|
4
|
+
export interface RecallOptions {
|
|
5
|
+
query?: string;
|
|
6
|
+
types?: MemoryType[];
|
|
7
|
+
limit?: number;
|
|
8
|
+
/** Approximate token budget for the rendered output. */
|
|
9
|
+
budget?: number;
|
|
10
|
+
includeInactive?: boolean;
|
|
11
|
+
now?: Date;
|
|
12
|
+
}
|
|
13
|
+
export interface Hit {
|
|
14
|
+
memory: Memory;
|
|
15
|
+
score: number;
|
|
16
|
+
}
|
|
17
|
+
export interface RecallResult {
|
|
18
|
+
hits: Hit[];
|
|
19
|
+
rendered: string;
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** BM25 over title/tags/links/body, with a mild recency boost. */
|
|
23
|
+
export declare function rank(memories: Memory[], query: string, now?: Date): Hit[];
|
|
24
|
+
export declare function renderMemory(m: Memory): string;
|
|
25
|
+
export declare function recall(all: Memory[], opts?: RecallOptions): RecallResult;
|