@staix/agent-mem 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 +144 -2
- package/bin/amem.js +9 -0
- package/package.json +31 -4
- package/src/cli.ts +58 -0
- package/src/commands-table.ts +123 -0
- package/src/completion.ts +240 -0
- package/src/index.ts +7 -0
- package/src/memory/commands.ts +203 -0
- package/src/memory/home.ts +10 -0
- package/src/memory/import.ts +90 -0
- package/src/memory/platform.ts +131 -0
- package/src/memory/project.ts +72 -0
- package/src/memory/store.ts +805 -0
- package/src/memory/sync.ts +144 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { memoryGitArgv, memoryGitEnv, processLiveness, processSignature } from "./platform.ts";
|
|
4
|
+
import type { MemoryStore } from "./store.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* #258 phase 2: hub memory between a person's machines through a git remote they configure. The memory directory is a
|
|
8
|
+
* git repository that tracks only `log/*.jsonl`; each machine commits only its own segment, so a merge of another
|
|
9
|
+
* machine's history never conflicts. Pull, then push; fail-open: a failure is a result with one reason, never a throw.
|
|
10
|
+
*/
|
|
11
|
+
export type Visibility = "public" | "private" | "unknown";
|
|
12
|
+
export interface SyncResult { ok: boolean; pushed: boolean; entries: number; warning?: string; error?: string }
|
|
13
|
+
|
|
14
|
+
const GITIGNORE = "/*\n!/.gitignore\n!/log/\n/log/*\n!/log/*.jsonl\n";
|
|
15
|
+
const STEP_MS = 60_000;
|
|
16
|
+
|
|
17
|
+
async function git(dir: string, args: string[], signal?: AbortSignal, ms = STEP_MS): Promise<{ code: number; out: string; err: string }> {
|
|
18
|
+
if (signal?.aborted) return { code: -1, out: "", err: "stopped" };
|
|
19
|
+
const p = Bun.spawn(memoryGitArgv(["-C", dir, ...args], { credentials: true }), { stdout: "pipe", stderr: "pipe", stdin: "ignore", env: memoryGitEnv() });
|
|
20
|
+
// SIGTERM first, so git removes its lock files; SIGKILL only if it is still there after a grace period.
|
|
21
|
+
let hard: ReturnType<typeof setTimeout> | undefined;
|
|
22
|
+
const kill = () => { p.kill("SIGTERM"); hard ??= setTimeout(() => p.kill("SIGKILL"), 2_000); };
|
|
23
|
+
const timer = setTimeout(kill, ms);
|
|
24
|
+
signal?.addEventListener("abort", kill, { once: true });
|
|
25
|
+
try {
|
|
26
|
+
const [code, out, err] = await Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
27
|
+
return { code, out: out.trim(), err: err.trim() };
|
|
28
|
+
} finally { clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** GitHub remotes are asked through `gh`; any other host, or a `gh` that cannot answer, is unknown. */
|
|
32
|
+
export async function remoteVisibility(url: string): Promise<Visibility> {
|
|
33
|
+
const repo = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(url)?.[1];
|
|
34
|
+
if (!repo) return "unknown";
|
|
35
|
+
try {
|
|
36
|
+
const p = Bun.spawn(["gh", "repo", "view", repo, "--json", "visibility", "--jq", ".visibility"], { stdout: "pipe", stderr: "ignore", stdin: "ignore" });
|
|
37
|
+
const timer = setTimeout(() => p.kill("SIGKILL"), 15_000);
|
|
38
|
+
const [code, out] = await Promise.all([p.exited, new Response(p.stdout).text()]);
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
const v = out.trim().toLowerCase();
|
|
41
|
+
return code === 0 && (v === "private" || v === "internal") ? "private" : code === 0 && v === "public" ? "public" : "unknown";
|
|
42
|
+
} catch { return "unknown"; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One sync per machine at a time: hubs of several projects share the store. A lock whose owner is gone is taken over. */
|
|
46
|
+
function lock(dir: string): (() => void) | undefined {
|
|
47
|
+
const file = join(dir, "sync.lock");
|
|
48
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
49
|
+
try {
|
|
50
|
+
const fd = openSync(file, "wx", 0o600);
|
|
51
|
+
writeSync(fd, `${process.pid} ${processSignature(process.pid) ?? ""}\n`);
|
|
52
|
+
closeSync(fd);
|
|
53
|
+
return () => rmSync(file, { force: true });
|
|
54
|
+
} catch {
|
|
55
|
+
let owner = "";
|
|
56
|
+
try { owner = readFileSync(file, "utf8").trim(); } catch { continue; }
|
|
57
|
+
const [pid, ...sig] = owner.split(" ");
|
|
58
|
+
if (processLiveness(Number(pid), sig.join(" ") || undefined) !== "gone") return undefined;
|
|
59
|
+
rmSync(file, { force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SyncOptions {
|
|
66
|
+
/** Set (or replace) the remote first: a person's choice. */
|
|
67
|
+
remote?: string;
|
|
68
|
+
visibility?: (url: string) => Promise<Visibility>;
|
|
69
|
+
/** A stopping hub aborts: the running git step is killed and the sync ends without touching the store. */
|
|
70
|
+
signal?: AbortSignal;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Promise<SyncResult> {
|
|
74
|
+
const dir = store.dir;
|
|
75
|
+
const fail = (error: string): SyncResult => ({ ok: false, pushed: false, entries: 0, error });
|
|
76
|
+
if (opts.signal?.aborted) return fail("stopped");
|
|
77
|
+
const noRemote = "no sync remote: a person sets one with ahub memory sync --remote <private git url>";
|
|
78
|
+
if (!opts.remote && !existsSync(join(dir, ".git"))) return fail(noRemote); // never set up: nothing to do, nothing created
|
|
79
|
+
const release = lock(dir);
|
|
80
|
+
if (!release) return fail("another sync is running on this machine");
|
|
81
|
+
try {
|
|
82
|
+
// Holding sync.lock, no other git runs in this repository: what a killed one left behind is stale.
|
|
83
|
+
rmSync(join(dir, ".git", "index.lock"), { force: true });
|
|
84
|
+
if (existsSync(join(dir, ".git", "MERGE_HEAD"))) await git(dir, ["merge", "--abort"]);
|
|
85
|
+
if (!existsSync(join(dir, ".git"))) {
|
|
86
|
+
const init = await git(dir, ["init", "-q"], opts.signal);
|
|
87
|
+
if (init.code !== 0) return fail(`git init failed: ${init.err.slice(0, 200)}`);
|
|
88
|
+
await git(dir, ["symbolic-ref", "HEAD", "refs/heads/main"], opts.signal);
|
|
89
|
+
}
|
|
90
|
+
if (!existsSync(join(dir, ".gitignore")) || readFileSync(join(dir, ".gitignore"), "utf8") !== GITIGNORE) writeFileSync(join(dir, ".gitignore"), GITIGNORE, { mode: 0o600 });
|
|
91
|
+
if (opts.remote) {
|
|
92
|
+
await git(dir, ["remote", "remove", "origin"], opts.signal);
|
|
93
|
+
const add = await git(dir, ["remote", "add", "origin", opts.remote], opts.signal);
|
|
94
|
+
if (add.code !== 0) return fail(`the remote could not be set: ${add.err.slice(0, 200)}`);
|
|
95
|
+
}
|
|
96
|
+
const url = (await git(dir, ["remote", "get-url", "origin"], opts.signal)).out;
|
|
97
|
+
if (opts.signal?.aborted) return fail("stopped");
|
|
98
|
+
if (!url) return fail(noRemote);
|
|
99
|
+
// Long-term entries hold note text: a public remote would publish them.
|
|
100
|
+
const visibility = await (opts.visibility ?? remoteVisibility)(url);
|
|
101
|
+
if (visibility === "public") return fail("the sync remote is public; hub memory syncs only to a private remote");
|
|
102
|
+
const warning = visibility === "unknown" ? "the sync remote's visibility is unknown; keep it private" : undefined;
|
|
103
|
+
|
|
104
|
+
const own = `log/${store.machine.id}.jsonl`;
|
|
105
|
+
const who = ["-c", "user.name=agent-hub", "-c", `user.email=agent-hub@${store.machine.id}.invalid`];
|
|
106
|
+
const staged = await git(dir, ["add", "--", ".gitignore", ...(existsSync(join(dir, own)) ? [own] : [])], opts.signal);
|
|
107
|
+
if (staged.code !== 0) return fail(`git add failed: ${staged.err.slice(0, 200)}`); // never a push that left this machine's entries out
|
|
108
|
+
if ((await git(dir, ["diff", "--cached", "--quiet"], opts.signal)).code === 1) {
|
|
109
|
+
const commit = await git(dir, [...who, "commit", "-q", "--no-verify", "-m", `memory: ${store.machine.label}`], opts.signal);
|
|
110
|
+
if (commit.code !== 0) return fail(`commit failed: ${commit.err.slice(0, 200)}`);
|
|
111
|
+
}
|
|
112
|
+
const pull = async (): Promise<string | undefined> => {
|
|
113
|
+
const fetch = await git(dir, ["fetch", "-q", "origin"], opts.signal);
|
|
114
|
+
if (fetch.code !== 0) return `fetch failed: ${fetch.err.slice(0, 200)}`;
|
|
115
|
+
if ((await git(dir, ["rev-parse", "--verify", "-q", "refs/remotes/origin/main"], opts.signal)).code !== 0) return undefined; // an empty remote
|
|
116
|
+
if ((await git(dir, ["rev-parse", "--verify", "-q", "HEAD"], opts.signal)).code !== 0) {
|
|
117
|
+
const checkout = await git(dir, ["checkout", "-q", "-B", "main", "refs/remotes/origin/main"], opts.signal);
|
|
118
|
+
return checkout.code === 0 ? undefined : `checkout failed: ${checkout.err.slice(0, 200)}`;
|
|
119
|
+
}
|
|
120
|
+
// Each machine writes only its own segment, and every machine starts its own history: unrelated, never conflicting.
|
|
121
|
+
const merge = await git(dir, [...who, "merge", "-q", "--no-edit", "--no-verify", "--allow-unrelated-histories", "refs/remotes/origin/main"], opts.signal);
|
|
122
|
+
if (merge.code !== 0) { await git(dir, ["merge", "--abort"]); return `merge failed: ${merge.err.slice(0, 200)}`; } // cleanup runs even when stopping
|
|
123
|
+
return undefined;
|
|
124
|
+
};
|
|
125
|
+
const pulled = await pull();
|
|
126
|
+
if (pulled) return fail(pulled);
|
|
127
|
+
let pushed = false;
|
|
128
|
+
if ((await git(dir, ["rev-parse", "--verify", "-q", "HEAD"], opts.signal)).code === 0) {
|
|
129
|
+
let push = await git(dir, ["push", "-q", "origin", "HEAD:refs/heads/main"], opts.signal);
|
|
130
|
+
if (push.code !== 0) { // another machine pushed in between: take its history, then push once more
|
|
131
|
+
const again = await pull();
|
|
132
|
+
if (again) return fail(again);
|
|
133
|
+
push = await git(dir, ["push", "-q", "origin", "HEAD:refs/heads/main"], opts.signal);
|
|
134
|
+
}
|
|
135
|
+
if (push.code !== 0) return fail(`push failed: ${push.err.slice(0, 200)}`);
|
|
136
|
+
pushed = true;
|
|
137
|
+
}
|
|
138
|
+
if (opts.signal?.aborted) return fail("stopped");
|
|
139
|
+
store.refresh();
|
|
140
|
+
return { ok: true, pushed, entries: store.counts().long, ...(warning ? { warning } : {}) };
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return fail((e as Error).message.slice(0, 200));
|
|
143
|
+
} finally { release(); }
|
|
144
|
+
}
|