@staix/agent-hub 0.12.29 → 0.12.30
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/CHANGELOG.md +18 -0
- package/README.md +79 -219
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +1 -1
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +10 -0
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +9 -0
- package/docs/agent-notes/tests.md +1 -1
- package/docs/memory.md +70 -152
- package/docs/operations.md +199 -14
- package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
- package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
- package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
- package/docs/plans/amem-cases.json +482 -0
- package/docs/quickstart.md +9 -2
- package/docs/security.md +13 -1
- package/docs/smoke.md +11 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +105 -4
- package/docs/verification/2026-10-17-0.12.30.md +70 -0
- package/docs/verified.json +13 -13
- package/package.json +5 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +64 -44
- package/src/cli/console-state.ts +92 -20
- package/src/cli/console.ts +22 -5
- package/src/cli/help.ts +3 -2
- package/src/cli/identity.ts +1 -1
- package/src/cli/main.js +2 -2
- package/src/cli/main.ts +36 -14
- package/src/cli/memory.ts +87 -187
- package/src/cli/output.ts +6 -0
- package/src/cli/recovery-package.ts +59 -10
- package/src/cli/upgrade-runtime.ts +54 -17
- package/src/cli/upgrade.ts +8 -0
- package/src/hub/board.ts +2 -1
- package/src/hub/bus.ts +33 -12
- package/src/hub/daemon.ts +134 -27
- package/src/hub/envelope.ts +30 -0
- package/src/hub/git.ts +2 -38
- package/src/hub/hub-tools.ts +5 -1
- package/src/hub/manager.ts +1 -1
- package/src/hub/memory-candidates.ts +103 -0
- package/src/hub/memory-fail.ts +25 -0
- package/src/hub/memory-observations.ts +47 -0
- package/src/hub/memory-plan-guard.ts +33 -0
- package/src/hub/memory-policy.ts +42 -0
- package/src/hub/memory-profile-check.ts +170 -0
- package/src/hub/memory-profile.ts +192 -0
- package/src/hub/memory-staleness.ts +55 -0
- package/src/hub/path-policy.ts +8 -0
- package/src/hub/project.ts +4 -37
- package/src/hub/read-tools.ts +127 -0
- package/src/hub/tasks.ts +201 -41
- package/src/local/deny.ts +4 -17
- package/src/memory/brief.ts +8 -5
- package/src/memory/capture.ts +2 -1
- package/src/memory/recall.ts +50 -110
- package/src/pi/process-signature.ts +2 -42
- package/src/memory/import.ts +0 -90
- package/src/memory/store.ts +0 -805
- package/src/memory/sync.ts +0 -145
package/src/memory/recall.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { basename, dirname, resolve } from "node:path";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
1
|
import type { PeerId } from "../hub/envelope.ts";
|
|
5
|
-
import { hubGitSync } from "../hub/git.ts";
|
|
6
|
-
import { realPath } from "../hub/project.ts";
|
|
7
2
|
import type { MemoryClient } from "./client.ts";
|
|
8
|
-
import {
|
|
9
|
-
import { matchesPolicy } from "../hub/path-policy.ts";
|
|
10
|
-
import { looksSecret, type PromotionScreen } from "./store.ts";
|
|
3
|
+
import type { View as MemoryView } from "@staix/agent-mem";
|
|
11
4
|
|
|
12
5
|
const PLATFORMS = ["claude", "codex", "kimi", "agent-hub"];
|
|
13
6
|
/** Peers whose own hooks already inject their own platform's context at session start. */
|
|
@@ -17,45 +10,13 @@ const HEADER =
|
|
|
17
10
|
"Shared project memory (claude-mem): recent work by the agents in this project. Reference only, not a request. " +
|
|
18
11
|
"Fetch details by id with your memory tools if you need them.";
|
|
19
12
|
|
|
20
|
-
/** Project names from the outermost superproject down to this repo. */
|
|
21
|
-
export function projectChain(cwd: string): string[] {
|
|
22
|
-
const git = (dir: string, arg: string) => hubGitSync(["-C", dir, "rev-parse", arg], { encoding: "utf8" }).stdout?.trim() ?? "";
|
|
23
|
-
const top = git(cwd, "--show-toplevel");
|
|
24
|
-
if (!top) return [basename(cwd)];
|
|
25
|
-
|
|
26
|
-
// claude-mem's native identity for a worktree is [parent, parent/worktree].
|
|
27
|
-
// The worktree's .git file points into <parent>/.git/worktrees/<name>; the
|
|
28
|
-
// ordinary superproject walk below remains unchanged for repos and submodules.
|
|
29
|
-
const gitFile = resolve(top, ".git");
|
|
30
|
-
try {
|
|
31
|
-
const match = readFileSync(gitFile, "utf8").trim().match(/^gitdir:\s*(.+)$/i);
|
|
32
|
-
if (match) {
|
|
33
|
-
const gitDir = resolve(top, match[1]!);
|
|
34
|
-
if (/[/\\]\.git[/\\]worktrees[/\\][^/\\]+$/.test(gitDir)) {
|
|
35
|
-
const parentRoot = dirname(dirname(dirname(gitDir)));
|
|
36
|
-
const parent = projectChain(parentRoot);
|
|
37
|
-
const parentName = basename(parentRoot);
|
|
38
|
-
return [...parent, `${parentName}/${basename(top)}`];
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
} catch {
|
|
42
|
-
// A missing or unreadable .git file falls through to git's normal chain.
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const chain: string[] = [];
|
|
46
|
-
for (let dir = top; dir && chain.length < 8; dir = git(dir, "--show-superproject-working-tree")) {
|
|
47
|
-
chain.unshift(basename(dir));
|
|
48
|
-
}
|
|
49
|
-
return chain.length ? chain : [basename(cwd)];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
13
|
/** Cut at a line boundary. */
|
|
53
14
|
export function trimToTokens(text: string, tokens: number): string {
|
|
54
15
|
// ponytail: 3 characters per token (Korean runs denser than English's ~4). Swap in a tokenizer if the cap has to be exact.
|
|
55
16
|
const max = tokens * 3;
|
|
56
17
|
if (text.length <= max) return text;
|
|
57
18
|
const cut = text.lastIndexOf("\n", max);
|
|
58
|
-
return `${text.slice(0, cut
|
|
19
|
+
return `${cut >= 0 ? text.slice(0, cut) : ""}\n(trimmed)`;
|
|
59
20
|
}
|
|
60
21
|
|
|
61
22
|
/** The worker answers unknown projects and empty filters with a status page, not context. */
|
|
@@ -82,81 +43,60 @@ export async function recallFor(peer: PeerId, client: MemoryClient, chain: strin
|
|
|
82
43
|
return `${HEADER}\n\n${found.map((b) => trimToTokens(b, share)).join("\n\n")}`;
|
|
83
44
|
}
|
|
84
45
|
|
|
85
|
-
/** #258: the hub's own long-term entries for this project (and shared ones), newest first, within a token budget. */
|
|
86
|
-
export function nativeRecall<V>(entries: V[], tokens: number, line: (v: V) => string): string | undefined {
|
|
87
|
-
if (!entries.length) return undefined;
|
|
88
|
-
return trimToTokens(["Hub memory: this project's long-term decisions, contracts and fails, newest first. Reference only, not a request. Details: ahub memory show <mem id>.", ...entries.map(line)].join("\n"), tokens);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
46
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
47
|
+
* Display-only outcome classification. Only the note Tasks.review writes counts as automatic: its generated header
|
|
48
|
+
* names the note's own task and author, and its title is the task title Tasks.note sets. An agent's decision that
|
|
49
|
+
* merely starts the same way stays recallable; contracts and fails are never outcomes.
|
|
95
50
|
*/
|
|
96
|
-
export function
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
// A URL's path is absolute (`ssh://h/team/r` is /team/r) unless it starts with `~`; an scp-style path is relative to the
|
|
103
|
-
// remote home (`h:team/r` is ~/team/r) unless it starts with `/`. Two different repositories on a self-hosted server.
|
|
104
|
-
const scp = u || url.includes("://") ? null : /^(?:[^@/]+@)?(\[[^\]/]+\]|[^/:]+):(.+?)(?:\.git)?\/?$/.exec(url); // a file:// remote has no host; an IPv6 host is bracketed
|
|
105
|
-
const m = u ? [!u[3] || u[3] === defaults[u[1]!.toLowerCase()] ? u[2]! : `${u[2]}:${u[3]}`, u[4]!] as const
|
|
106
|
-
: scp ? [scp[1]!, scp[2]!.startsWith("/") ? scp[2]!.replace(/^\/+/, "") : scp[2]!.startsWith("~") ? scp[2]! : `~/${scp[2]}`] as const : undefined;
|
|
107
|
-
if (m && m[1]) {
|
|
108
|
-
const host = m[0].toLowerCase();
|
|
109
|
-
// GitHub serves one repository under every form, and its owner and repository names ignore case.
|
|
110
|
-
return host === "github.com" ? `${host}/${m[1].replace(/^~\/?/, "").toLowerCase()}` : `${host}/${m[1]}`;
|
|
111
|
-
}
|
|
112
|
-
let real = cwd;
|
|
113
|
-
try { real = realPath(cwd); } catch { /* as given */ }
|
|
114
|
-
return `${projectChain(cwd).join("/")}@${createHash("sha256").update(real).digest("hex").slice(0, 8)}`;
|
|
51
|
+
export function recallDisplayText(note: Pick<MemoryView, "kind" | "text"> & Partial<Pick<MemoryView, "peer" | "task" | "title">>): string | undefined {
|
|
52
|
+
if (note.kind !== "decision") return note.text;
|
|
53
|
+
const [first = "", ...rest] = note.text.split(/\r?\n/);
|
|
54
|
+
const m = /^Task #(\d+) (approved|changes requested) by (\S+): /.exec(first);
|
|
55
|
+
if (!m || m[3] !== note.peer || !note.task?.match(new RegExp(`#${m[1]}@\\d+$`)) || !note.title?.startsWith(`agent-hub task #${m[1]}: `)) return note.text;
|
|
56
|
+
return m[2] === "approved" ? undefined : rest.join("\n").trim() || undefined;
|
|
115
57
|
}
|
|
116
58
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
*/
|
|
121
|
-
export
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
return entries.some((entry) => { const lit = entry.replace(/^\.\//, "").split(/[*?[\]{}]/)[0]!.replace(/\/+$/, ""); return /[/\s]/.test(lit) && body.includes(lit); });
|
|
59
|
+
export const RECALL_LINE_CHARS = 200;
|
|
60
|
+
type RecallNote = Pick<MemoryView, "id" | "kind" | "tier" | "title" | "text"> & Partial<Pick<MemoryView, "peer" | "task" | "about">>;
|
|
61
|
+
|
|
62
|
+
/** A complete-word excerpt with its memory reference intact, capped in Unicode code points. */
|
|
63
|
+
export function recallMemoryLine(note: RecallNote, idChars = 8, stale = false): string | undefined {
|
|
64
|
+
const text = recallDisplayText(note);
|
|
65
|
+
if (text === undefined) return undefined;
|
|
66
|
+
// A rejection's title is another outcome header; only its actionable detail belongs in this view.
|
|
67
|
+
const body = `${stale ? "(stale?) " : ""}${text === note.text && note.title ? `${note.title}: ` : ""}${text}`.replace(/\s+/g, " ").trim();
|
|
68
|
+
const header = `mem:${note.id.slice(0, idChars)} ${note.kind} ${note.tier}: `;
|
|
69
|
+
const chars = Array.from(body), available = RECALL_LINE_CHARS - header.length;
|
|
70
|
+
if (chars.length <= available) return header + body;
|
|
71
|
+
const count = available - 4;
|
|
72
|
+
const prefix = chars.slice(0, count).join("");
|
|
73
|
+
const whole = chars[count] === " " || prefix.endsWith(" ") ? prefix.trimEnd() : prefix.slice(0, Math.max(0, prefix.lastIndexOf(" ")));
|
|
74
|
+
return header + (whole ? `${whole} ...` : "...");
|
|
134
75
|
}
|
|
135
76
|
|
|
136
|
-
/**
|
|
137
|
-
export
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
77
|
+
/** Filter before the display cap, so outcome noise cannot displace an older relevant contract or fail. */
|
|
78
|
+
export function recallMemoryLines(notes: readonly RecallNote[], limit = 30, stale: (about: readonly string[]) => boolean = () => false): string[] {
|
|
79
|
+
const prefixes = new Map<string, string | null>();
|
|
80
|
+
for (const note of notes) {
|
|
81
|
+
const prefix = note.id.slice(0, 8), previous = prefixes.get(prefix);
|
|
82
|
+
prefixes.set(prefix, previous === undefined || previous === note.id ? note.id : null);
|
|
83
|
+
}
|
|
84
|
+
const ids = new Set<string>(), lines: string[] = [];
|
|
85
|
+
for (const note of notes) {
|
|
86
|
+
if (ids.has(note.id)) continue;
|
|
87
|
+
// A colliding short prefix is not an identity; its full id keeps both entries visible.
|
|
88
|
+
const idChars = prefixes.get(note.id.slice(0, 8)) === null ? note.id.length : 8;
|
|
89
|
+
let line = recallMemoryLine(note, idChars);
|
|
90
|
+
if (line === undefined) continue;
|
|
91
|
+
if (stale(note.about ?? [])) line = recallMemoryLine(note, idChars, true)!;
|
|
92
|
+
ids.add(note.id); lines.push(line);
|
|
93
|
+
if (lines.length >= limit) break;
|
|
94
|
+
}
|
|
95
|
+
return lines;
|
|
149
96
|
}
|
|
150
97
|
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
export function promotionScreen(project: string, cwd: string, routing: () => Routing, policy: () => string[], taskPaths: (task: string) => string[] = () => []): PromotionScreen {
|
|
156
|
-
return (v) => {
|
|
157
|
-
if (v.project !== project) return "skip";
|
|
158
|
-
// The task's current paths too, with no denylist: a path the note's own nodes leave out is still checked.
|
|
159
|
-
const screened = { ...v, about: [...v.about, ...(v.task ? taskPaths(v.task) : []).map((p) => `path:${p}`)] };
|
|
160
|
-
return looksSecret(`${v.title ?? ""}\n${v.text}`) || !withoutPii([screened], routing(), cwd, policy()).length ? "keep" : "promote";
|
|
161
|
-
};
|
|
98
|
+
/** #258: the hub's own long-term entries for this project (and shared ones), newest first, within a token budget. */
|
|
99
|
+
export function nativeRecall<V>(entries: V[], tokens: number, line: (v: V) => string): string | undefined {
|
|
100
|
+
if (!entries.length) return undefined;
|
|
101
|
+
return trimToTokens(["Hub memory: this project's long-term decisions, contracts and fails, newest first. Reference only, not a request. Details: ahub memory show <mem id>.", ...entries.map(line)].join("\n"), tokens);
|
|
162
102
|
}
|
|
@@ -1,42 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The one process-identity contract behind the Pi owner claim, every later liveness
|
|
6
|
-
* re-inspection, the terminal-recovery launcher signature and any other hub identity
|
|
7
|
-
* hash: pid plus the `ps` rendering of its start time and command. `lstart` follows
|
|
8
|
-
* the reader's TZ and locale, so the read pins LC_ALL=C and TZ=UTC, the same contract
|
|
9
|
-
* as `processTable` in src/hub/child-process.ts. Without the pin a writer and a reader
|
|
10
|
-
* in different environments hash different strings for the same process and the valid
|
|
11
|
-
* owner is refused (#174, #177). Only the `ps` invocation is pinned; the user's own
|
|
12
|
-
* environment is untouched. Node APIs only: the extension runs inside Pi, not Bun.
|
|
13
|
-
*/
|
|
14
|
-
export function processSignature(pid: number): string | undefined {
|
|
15
|
-
try {
|
|
16
|
-
const text = execFileSync("ps", ["-p", String(pid), "-o", "lstart=,comm="], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" } }).trim();
|
|
17
|
-
return text ? createHash("sha256").update(text).digest("hex") : undefined;
|
|
18
|
-
} catch { return undefined; }
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export type Liveness = "live" | "gone" | "unknown";
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* #226: the one ownership check behind every recorded pid (hub manifest, registry claim, recovery runner, manager
|
|
25
|
-
* owner, launcher records). With a recorded `signature` the identity decides: equal is live, different is gone (the
|
|
26
|
-
* pid was reused, after a reboot for example, also by a process we may not signal), and a pid that exists but whose
|
|
27
|
-
* identity cannot be read is unknown. Without one (records written by 0.12.20 and older) the pid probe answers as it
|
|
28
|
-
* always did: live, gone on ESRCH, unknown otherwise. Unknown is never gone.
|
|
29
|
-
*/
|
|
30
|
-
// ponytail: `ps lstart` is derived from boot time plus the process's age; a wall-clock step on Linux can change it for a
|
|
31
|
-
// live process, which then reads as gone (a second hub could take its claim). Upgrade path: a boot-id or start-tick
|
|
32
|
-
// (Linux /proc/<pid>/stat field 22) component in the signature.
|
|
33
|
-
export function processLiveness(pid: unknown, signature?: string | null, identity: (pid: number) => string | undefined = processSignature): Liveness {
|
|
34
|
-
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return "unknown";
|
|
35
|
-
if (signature) {
|
|
36
|
-
let current: string | undefined;
|
|
37
|
-
try { current = identity(pid); } catch { /* unreadable: the pid probe below decides between gone and unknown */ }
|
|
38
|
-
if (current) return current === signature ? "live" : "gone";
|
|
39
|
-
}
|
|
40
|
-
try { process.kill(pid, 0); return signature ? "unknown" : "live"; }
|
|
41
|
-
catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH" ? "gone" : "unknown"; }
|
|
42
|
-
}
|
|
1
|
+
// Pi ownership and memory sync locks share the same Node-safe core identity contract.
|
|
2
|
+
export { processSignature, processLiveness, type Liveness } from "@staix/agent-mem/platform";
|
package/src/memory/import.ts
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { Database } from "bun:sqlite";
|
|
2
|
-
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
-
import { realPath } from "../hub/project.ts";
|
|
5
|
-
import { homedir } from "node:os";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import { KINDS, looksSecret, type Kind, type MemoryStore } from "./store.ts";
|
|
8
|
-
|
|
9
|
-
/** The claude-mem store is another program's: opened read-only, never written, its locks waited on briefly. */
|
|
10
|
-
export const CLAUDE_MEM_BUSY_TIMEOUT_MS = 3000;
|
|
11
|
-
/** Titles `ahub ask --remember` gives a model's answer (ask.ts ASK_NOTE_TITLE): never imported as a finding. */
|
|
12
|
-
const MODEL_ANSWER = "ahub ask (model answer)";
|
|
13
|
-
|
|
14
|
-
export interface ImportResult { read: number; added: number; skipped: { model: number; pii: number; secret: number }; more: boolean; cursor: number }
|
|
15
|
-
|
|
16
|
-
/** Machine-local progress is disposable; malformed/unreadable state simply replays the idempotent import. */
|
|
17
|
-
function readCursors(file: string): Record<string, number> {
|
|
18
|
-
try {
|
|
19
|
-
const value: unknown = JSON.parse(readFileSync(file, "utf8"));
|
|
20
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
21
|
-
return Object.fromEntries(Object.entries(value).filter(([, id]) => Number.isSafeInteger(id) && id >= 0));
|
|
22
|
-
} catch { return {}; }
|
|
23
|
-
}
|
|
24
|
-
function writeCursor(file: string, key: string, cursor: number, log: (line: string) => void): void {
|
|
25
|
-
const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
26
|
-
try {
|
|
27
|
-
writeFileSync(temp, JSON.stringify({ ...readCursors(file), [key]: cursor }) + "\n", { mode: 0o600, flag: "wx" });
|
|
28
|
-
renameSync(temp, file);
|
|
29
|
-
} catch { log("claude-mem import progress could not be saved; the next run may replay handled rows"); }
|
|
30
|
-
finally { try { rmSync(temp, { force: true }); } catch { /* disposable progress */ } }
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* #258 phase 3: copy the hub's own notes from claude-mem (observations whose metadata names a `peer`: `hub_remember`
|
|
35
|
-
* and the hub's task notes) into long-term hub memory as `source: claude-mem` under the hub memory key `project`, for
|
|
36
|
-
* the projects in `chain` (claude-mem files them under the repository's basename). Model answers, PII-pattern text and secret-looking text are skipped.
|
|
37
|
-
* Idempotent: an observation always maps to the same entry.
|
|
38
|
-
*/
|
|
39
|
-
export function importClaudeMem(store: MemoryStore, opts: { project: string; chain: string[]; isPii: (text: string) => boolean; nodes?: (text: string) => string[]; db?: string; limit?: number; restart?: boolean; log?: (line: string) => void }): ImportResult {
|
|
40
|
-
const file = opts.db ?? join(homedir(), ".claude-mem", "claude-mem.db");
|
|
41
|
-
if (!existsSync(file)) throw new Error(`no claude-mem store at ${file}`);
|
|
42
|
-
const limit = opts.limit ?? 10_000;
|
|
43
|
-
if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("--limit takes a positive integer");
|
|
44
|
-
const state = join(store.dir, "import-state.json");
|
|
45
|
-
const name = opts.chain.at(-1) ?? "";
|
|
46
|
-
const key = createHash("sha256").update(JSON.stringify([realPath(file), name, opts.project])).digest("hex");
|
|
47
|
-
let cursor = opts.restart ? 0 : readCursors(state)[key] ?? 0;
|
|
48
|
-
const db = new Database(file, { readonly: true });
|
|
49
|
-
db.run(`PRAGMA busy_timeout = ${CLAUDE_MEM_BUSY_TIMEOUT_MS}`);
|
|
50
|
-
const result: ImportResult = { read: 0, added: 0, skipped: { model: 0, pii: 0, secret: 0 }, more: false, cursor };
|
|
51
|
-
try {
|
|
52
|
-
const max = db.query("SELECT MAX(id) AS id FROM observations WHERE project = ? AND metadata LIKE '%\"peer\"%'").get(name) as { id: number | null };
|
|
53
|
-
if ((max.id ?? 0) < cursor) cursor = 0;
|
|
54
|
-
const rows = db.query("SELECT id, project, title, text, narrative, created_at_epoch, metadata FROM observations WHERE project = ? AND id > ? AND metadata LIKE '%\"peer\"%' ORDER BY id LIMIT ?")
|
|
55
|
-
.all(name, cursor, limit) as { id: number; project: string | null; title: string | null; text: string | null; narrative: string | null; created_at_epoch: number | null; metadata: string | null }[];
|
|
56
|
-
// The hub files its claude-mem notes under the repository's own name (the chain's last); the names above it are other
|
|
57
|
-
// projects (a superproject) whose notes and privacy policy are not this one's. ponytail: claude-mem keys projects by
|
|
58
|
-
// basename, so two unrelated repositories with one name still share it.
|
|
59
|
-
const project = opts.project;
|
|
60
|
-
result.read = rows.length;
|
|
61
|
-
result.more = rows.length === limit;
|
|
62
|
-
const handle = (r: typeof rows[number]) => {
|
|
63
|
-
let meta: Record<string, unknown>;
|
|
64
|
-
try { meta = JSON.parse(r.metadata ?? "{}"); } catch { return; }
|
|
65
|
-
if (!meta || typeof meta !== "object" || typeof meta.peer !== "string" || !meta.peer) return;
|
|
66
|
-
const title = r.title ?? "", text = (r.text ?? r.narrative ?? "").trim();
|
|
67
|
-
if (!text) return;
|
|
68
|
-
if (meta.source === "ahub ask" || title.startsWith(MODEL_ANSWER)) { result.skipped.model++; return; }
|
|
69
|
-
if (opts.isPii(`${title}\n${text}`)) { result.skipped.pii++; return; }
|
|
70
|
-
if (looksSecret(`${title}\n${text}`)) { result.skipped.secret++; return; }
|
|
71
|
-
const kind: Kind = KINDS.includes(meta.kind as Kind) ? meta.kind as Kind : "finding";
|
|
72
|
-
const at = new Date(r.created_at_epoch ?? 0).toISOString();
|
|
73
|
-
const { added } = store.importNote({ op: "note", project, scope: "project", tier: "long", kind, peer: meta.peer.slice(0, 32), ...(title ? { title } : {}), text,
|
|
74
|
-
// No task node: claude-mem keeps no proposal time, so a bare task number would join unrelated tasks. The text's
|
|
75
|
-
// own paths and symbols place the note in the graph.
|
|
76
|
-
about: opts.nodes?.(`${title}\n${text}`) ?? [], source: "claude-mem" }, at);
|
|
77
|
-
if (added) result.added++;
|
|
78
|
-
};
|
|
79
|
-
for (const row of rows) {
|
|
80
|
-
handle(row);
|
|
81
|
-
cursor = row.id; // only fully handled rows, including every skip
|
|
82
|
-
}
|
|
83
|
-
} finally {
|
|
84
|
-
db.close();
|
|
85
|
-
result.cursor = cursor;
|
|
86
|
-
writeCursor(state, key, cursor, opts.log ?? console.error);
|
|
87
|
-
}
|
|
88
|
-
store.refresh();
|
|
89
|
-
return result;
|
|
90
|
-
}
|