@staix/agent-hub 0.12.22 → 0.12.24
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 +117 -0
- package/README.md +22 -9
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +18 -6
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +6 -3
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +4 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +10 -3
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +873 -30
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +9 -4
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +124 -8
- package/docs/smoke.md +354 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verified.json +34 -16
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1108 -261
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +201 -20
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +54 -13
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +94 -20
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +26 -6
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +131 -39
- package/src/cli/console.ts +73 -17
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +52 -21
- package/src/cli/identity.ts +19 -8
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +641 -193
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +404 -0
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +40 -6
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +1 -29
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-runtime.ts +56 -10
- package/src/cli/upgrade.ts +23 -5
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +224 -16
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +58 -20
- package/src/hub/child-process.ts +11 -0
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +34 -17
- package/src/hub/config-trust.ts +20 -7
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +11 -1
- package/src/hub/daemon.ts +1218 -113
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +7 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +14 -2
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +28 -8
- package/src/hub/git.ts +38 -0
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +26 -6
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/peers.ts +5 -3
- package/src/hub/permission-mode.ts +33 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +6 -5
- package/src/hub/registry.ts +2 -1
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +132 -18
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +411 -0
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +92 -16
- package/src/hub/start-mode.ts +50 -0
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +1014 -111
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +40 -14
- package/src/hub/workflow.ts +71 -0
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +50 -10
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +86 -2
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/models/mlx.ts +2 -1
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/pi/tool-receipts.ts +2 -1
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +983 -40
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
package/src/local/sandbox.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync, type Dirent } from "node:fs";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { realPath } from "../hub/project.ts";
|
|
6
|
+
import { hubGitSync } from "../hub/git.ts";
|
|
6
7
|
import { denyRegexes, hubWriteRegexes, sbplString } from "./deny.ts";
|
|
7
8
|
|
|
8
9
|
const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
@@ -40,12 +41,47 @@ function developerDir(): string | undefined {
|
|
|
40
41
|
|
|
41
42
|
/** A submodule or worktree keeps its git dir outside the project; git needs it, minus the parts that execute or reconfigure. */
|
|
42
43
|
function externalGitDirs(root: string): string[] {
|
|
43
|
-
|
|
44
|
+
// Runs in the hub process, outside the sandbox: the hardened helper keeps the repo's config from naming a program (#281).
|
|
45
|
+
const out = hubGitSync(["-C", root, "rev-parse", "--absolute-git-dir", "--git-common-dir"], { encoding: "utf8" });
|
|
44
46
|
if (out.status !== 0) return [];
|
|
45
47
|
const dirs = out.stdout.trim().split("\n").map((d) => resolve(root, d));
|
|
46
48
|
return [...new Set(dirs)].filter((d) => !d.startsWith(`${root}/`));
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The project's submodule git directories live under the common dir's `modules/` tree (the worktree case included:
|
|
53
|
+
* it is the main repo's `.git/modules`). Returns the modules root and every directory entry that must not be
|
|
54
|
+
* renamed, replaced, symlinked or recreated: each discovered git dir (a directory holding HEAD and config) and
|
|
55
|
+
* every component between it and the modules root (#281). Discovered in the hub process, outside the sandbox.
|
|
56
|
+
*/
|
|
57
|
+
function moduleGitDirs(root: string): { root: string; entries: string[] } | undefined {
|
|
58
|
+
const out = hubGitSync(["-C", root, "rev-parse", "--git-common-dir"], { encoding: "utf8" });
|
|
59
|
+
if (out.status !== 0) return undefined;
|
|
60
|
+
const modulesRoot = join(resolve(root, out.stdout.trim()), "modules");
|
|
61
|
+
const dirs: string[] = [];
|
|
62
|
+
const walk = (dir: string, depth: number): void => {
|
|
63
|
+
if (depth > 8) return; // submodules do not nest deeper in practice; the regex rules still cover the names
|
|
64
|
+
let entries: Dirent[];
|
|
65
|
+
try {
|
|
66
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
67
|
+
} catch {
|
|
68
|
+
return; // no modules tree (no submodules): nothing to discover
|
|
69
|
+
}
|
|
70
|
+
const names = new Set(entries.map((e) => e.name));
|
|
71
|
+
if (names.has("HEAD") && names.has("config")) {
|
|
72
|
+
dirs.push(dir);
|
|
73
|
+
const nested = join(dir, "modules");
|
|
74
|
+
if (names.has("modules")) walk(nested, depth + 1); // a submodule of a submodule
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const e of entries) if (e.isDirectory()) walk(join(dir, e.name), depth + 1);
|
|
78
|
+
};
|
|
79
|
+
walk(modulesRoot, 0);
|
|
80
|
+
const entries = new Set<string>([modulesRoot]);
|
|
81
|
+
for (const d of dirs) for (let p = d; p.length > modulesRoot.length; p = dirname(p)) entries.add(p);
|
|
82
|
+
return { root: modulesRoot, entries: [...entries] };
|
|
83
|
+
}
|
|
84
|
+
|
|
49
85
|
/** The system directories a deny-default profile lets commands read and run from: dyld, frameworks, toolchains. */
|
|
50
86
|
const SYSTEM_READABLE = ["/usr", "/bin", "/sbin", "/System", "/Library", "/opt", "/private/etc", "/private/var/db/timezone", "/private/var/db/dyld"];
|
|
51
87
|
/**
|
|
@@ -69,11 +105,12 @@ export type SandboxNetwork = boolean | { proxyPort: number };
|
|
|
69
105
|
* toolchain and project directories, writing the project and a temp dir of their own. The allow-default profile of
|
|
70
106
|
* 0.9 and earlier was removed in 0.12.0 (issue #83).
|
|
71
107
|
*/
|
|
72
|
-
export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = []): string {
|
|
108
|
+
export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = [], readOnly: string[] = []): string {
|
|
73
109
|
const home = homedir();
|
|
74
110
|
const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
|
|
75
111
|
const root = realPath(cwd);
|
|
76
112
|
const gitDirs = externalGitDirs(root);
|
|
113
|
+
const moduleGit = moduleGitDirs(root);
|
|
77
114
|
const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
|
|
78
115
|
const dev = developerDir();
|
|
79
116
|
const readable = [root, ...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs, ...(dev ? [dev] : [])];
|
|
@@ -101,12 +138,13 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
|
|
|
101
138
|
"(version 1)",
|
|
102
139
|
...start,
|
|
103
140
|
"(deny file-write*)",
|
|
104
|
-
`(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})
|
|
141
|
+
...(readOnly.length ? ["(allow file-write* (literal \"/dev/null\"))"] : [`(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`]),
|
|
105
142
|
// Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub. The .git and
|
|
106
143
|
// .agenthub patterns tolerate the code points HFS+ ignores in every segment, the .git name itself is refused
|
|
107
|
-
// at any depth (creation, rename, symlink, gitfile),
|
|
108
|
-
//
|
|
109
|
-
|
|
144
|
+
// at any depth (creation, rename, symlink, gitfile), a submodule git dir's config and hooks under
|
|
145
|
+
// .git/modules/ are refused at the depth submodules nest (#281), and the external git dirs get folded
|
|
146
|
+
// hooks/config rules (#270). The cost: git init, clone and worktree add no longer run inside the sandbox.
|
|
147
|
+
`(deny file-write* ${hubWriteRegexes(root, gitDirs, moduleGit).join(" ")})`,
|
|
110
148
|
`(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
|
|
111
149
|
`(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
|
|
112
150
|
// Python's own CA bundle (certifi, which pip vendors too): pip, requests and httpx read it instead of the system's (#64).
|
|
@@ -121,14 +159,16 @@ export interface ExecResult {
|
|
|
121
159
|
}
|
|
122
160
|
|
|
123
161
|
/** Run argv under seatbelt with a scrubbed environment. Never runs unsandboxed: no sandbox, no exec. */
|
|
124
|
-
export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string>; signal?: AbortSignal }): Promise<ExecResult> {
|
|
162
|
+
export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string>; signal?: AbortSignal; readOnlyData?: boolean }): Promise<ExecResult> {
|
|
163
|
+
if (opts.readOnlyData && /\(allow file-write\* \(subpath /.test(opts.profile)) return Promise.resolve({ code: null, output: "error: data.readonly command profile contains a broad write grant" });
|
|
125
164
|
if (!sandboxAvailable()) return Promise.resolve({ code: null, output: "error: command execution needs macOS sandbox-exec and is disabled on this host" });
|
|
126
165
|
if (opts.signal?.aborted) return Promise.resolve({ code: null, output: "error: command cancelled before execution" });
|
|
127
166
|
return new Promise((resolve) => {
|
|
128
167
|
// A temp dir of its own (#63): the user's is shared with every other process, and what a command reads can reach
|
|
129
168
|
// an answer shown to cloud peers. Allowed after the profile's denies, and gone when the command ends.
|
|
130
169
|
const own = realPath(mkdtempSync(join(tmpdir(), "ahub-cmd-")));
|
|
131
|
-
const sandbox = `${opts.profile}\n(allow file-read* file-write* process-exec (subpath ${q(own)}))`;
|
|
170
|
+
const sandbox = `${opts.profile}\n(allow file-read* ${opts.readOnlyData ? "" : "file-write* "}process-exec (subpath ${q(own)}))`;
|
|
171
|
+
// A path-only deny cannot contain hardlink aliases; data mode allows reads/stdout, never filesystem writes.
|
|
132
172
|
const env = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: process.env.LANG ?? "en_US.UTF-8", TERM: "dumb", ...opts.env, TMPDIR: `${own}/` };
|
|
133
173
|
// Own process group: a timeout has to take the grandchildren too, or they keep the pipes open and the project writable.
|
|
134
174
|
const child = spawn(SANDBOX_EXEC, ["-p", sandbox, ...argv], { cwd: opts.cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
package/src/local/tools.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "nod
|
|
|
2
2
|
import { dirname, join, relative, resolve } from "node:path";
|
|
3
3
|
import { realPath } from "../hub/project.ts";
|
|
4
4
|
import { isHubWritePath } from "../hub/permission-mode.ts";
|
|
5
|
+
import { matchesPolicy } from "../hub/path-policy.ts";
|
|
5
6
|
import { isDenied } from "./deny.ts";
|
|
6
7
|
import { OUTPUT_CAP, sandboxedExec } from "./sandbox.ts";
|
|
7
8
|
|
|
@@ -17,6 +18,7 @@ const approvalError = (picked: ToolApproval, denied: string): string | undefined
|
|
|
17
18
|
: picked === "aborted" ? "error: approval withdrawn because the turn ended; no operation was executed" : denied;
|
|
18
19
|
|
|
19
20
|
export interface ToolContext {
|
|
21
|
+
readOnly?: string[];
|
|
20
22
|
cwd: string;
|
|
21
23
|
/** Extra denylist entries from config: substrings of the project-relative path. */
|
|
22
24
|
deny: string[];
|
|
@@ -27,7 +29,7 @@ export interface ToolContext {
|
|
|
27
29
|
/** Turn cancellation signal, including the execution-budget wall cap. */
|
|
28
30
|
signal?: AbortSignal;
|
|
29
31
|
/** Only true permits an effect; expiry and abort remain distinct from a person's denial. */
|
|
30
|
-
permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver) => Promise<ToolApproval>;
|
|
32
|
+
permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver, originalPath?: string) => Promise<ToolApproval>;
|
|
31
33
|
/** Trusted process status, independent of what its stdout says. */
|
|
32
34
|
onProcessExit?: (code: number | null) => void;
|
|
33
35
|
/** Publish a message to other peers mid-turn. Returns a one-line receipt. */
|
|
@@ -44,7 +46,7 @@ const lexists = (p: string) => {
|
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
/** Absolute path inside cwd after resolving symlinks, or an Error. `write` also refuses .git and .agenthub. */
|
|
47
|
-
export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny">, path: string, mode: "read" | "write"): string {
|
|
49
|
+
export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny" | "readOnly">, path: string, mode: "read" | "write"): string {
|
|
48
50
|
const root = realPath(ctx.cwd);
|
|
49
51
|
const abs = resolve(root, path);
|
|
50
52
|
// Resolve symlinks on the deepest part that exists: a link pointing outside must not pass as an inside path.
|
|
@@ -64,6 +66,9 @@ export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny">, path: string,
|
|
|
64
66
|
// Not secret, but writing .git or .agenthub changes what runs outside the worker's control. Names are compared
|
|
65
67
|
// folded: a new `.GIT/config` is `.git/config` to a case-insensitive disk, and git then runs what it names.
|
|
66
68
|
if (mode === "write" && isHubWritePath(rel)) throw new Error(`${path} is not writable by the local worker`);
|
|
69
|
+
// Identity refusal is stable even when realPath names a protected hardlink alias.
|
|
70
|
+
if (mode === "write" && ctx.readOnly?.length && (lstatSync(real, { throwIfNoEntry: false })?.nlink ?? 1) > 1) throw new Error("hardlinked write target cannot be verified with data.readonly");
|
|
71
|
+
if (mode === "write" && matchesPolicy(root, path, ctx.readOnly ?? [])) throw new Error("target is protected by data.readonly");
|
|
67
72
|
return real;
|
|
68
73
|
}
|
|
69
74
|
|
|
@@ -142,7 +147,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
142
147
|
let file = guardPath(ctx, String(a.path), "write");
|
|
143
148
|
const approvedTarget = file;
|
|
144
149
|
const content = String(a.content ?? "");
|
|
145
|
-
const refused = approvalError(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`, "write", ctx.signal, file), "error: the user did not approve this write");
|
|
150
|
+
const refused = approvalError(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`, "write", ctx.signal, file, undefined, String(a.path)), "error: the user did not approve this write");
|
|
146
151
|
if (refused) return refused;
|
|
147
152
|
if (ctx.signal?.aborted) return "error: turn cancelled before write";
|
|
148
153
|
file = guardPath(ctx, String(a.path), "write");
|
|
@@ -157,7 +162,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
157
162
|
let text = readFileSync(file, "utf8");
|
|
158
163
|
let count = text.split(String(a.old)).length - 1;
|
|
159
164
|
if (!a.old || count !== 1) return `error: \`old\` must match exactly once, it matched ${count} times`;
|
|
160
|
-
const refused = approvalError(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`, "edit", ctx.signal, file), "error: the user did not approve this edit");
|
|
165
|
+
const refused = approvalError(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`, "edit", ctx.signal, file, undefined, String(a.path)), "error: the user did not approve this edit");
|
|
161
166
|
if (refused) return refused;
|
|
162
167
|
if (ctx.signal?.aborted) return "error: turn cancelled before edit";
|
|
163
168
|
// Approval can outlive another peer's edit or a path change. Apply only the approved fragment to current bytes.
|
|
@@ -176,7 +181,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
176
181
|
if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
|
|
177
182
|
const refused = approvalError(await ctx.permit(`bash: ${command}`, "bash", ctx.signal), "error: the user did not approve this command");
|
|
178
183
|
if (refused) return refused;
|
|
179
|
-
const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
184
|
+
const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, readOnlyData: !!ctx.readOnly?.length, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
180
185
|
try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
|
|
181
186
|
return `${res.output}\n(exit ${res.code})`;
|
|
182
187
|
}
|
|
@@ -191,7 +196,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
191
196
|
if (refused) return refused;
|
|
192
197
|
}
|
|
193
198
|
// Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
|
|
194
|
-
const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
199
|
+
const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, readOnlyData: !!ctx.readOnly?.length, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
195
200
|
try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
|
|
196
201
|
return `${res.output}\n(exit ${res.code})`;
|
|
197
202
|
}
|
package/src/memory/brief.ts
CHANGED
|
@@ -27,33 +27,47 @@ export async function related(client: MemoryClient, project: string, query: stri
|
|
|
27
27
|
return out;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** A native memory line for a brief (#258): `mem:<id8> <kind> <tier>: <title or text>`, fails first. */
|
|
31
|
+
export interface NativeItem { id: string; line: string; kind?: string }
|
|
32
|
+
export type NativeBrief = (task: BriefTask) => NativeItem[];
|
|
33
|
+
export interface BriefTask { id?: number; created?: number; title: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }
|
|
34
|
+
|
|
30
35
|
/**
|
|
31
|
-
* Task briefs: what memory already knows about a piece of work, handed over with it.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
36
|
+
* Task briefs: what memory already knows about a piece of work, handed over with it. The hub's own store first (#258:
|
|
37
|
+
* entries one or two hops from the task's paths, symbols, issue and task, a linked fail first, superseded entries
|
|
38
|
+
* dropped), then claude-mem: index first (search), then the neighbourhood of the top hit (timeline); details stay in
|
|
39
|
+
* the store for the receiver to fetch by id. `brief_items` caps both together. A peer is never shown the same entry
|
|
40
|
+
* twice in one hub run.
|
|
34
41
|
*/
|
|
35
42
|
export class Briefs {
|
|
36
|
-
private readonly seen = new Map<PeerId, Set<
|
|
43
|
+
private readonly seen = new Map<PeerId, Set<string>>();
|
|
37
44
|
|
|
38
45
|
constructor(
|
|
39
|
-
private readonly client: MemoryClient,
|
|
46
|
+
private readonly client: MemoryClient | undefined,
|
|
40
47
|
private readonly project: string,
|
|
41
48
|
private readonly maxItems = 8,
|
|
49
|
+
private readonly native?: NativeBrief,
|
|
50
|
+
/** Counts of what a brief carried, for the research records (#258 AC11); never its text. */
|
|
51
|
+
private readonly observe?: (peer: PeerId, task: number, counts: { native: number; fails: number; claudeMem: number }) => void,
|
|
42
52
|
) {}
|
|
43
53
|
|
|
44
|
-
async forTask(peer: PeerId, task:
|
|
45
|
-
const
|
|
46
|
-
if (!found.length) return undefined;
|
|
47
|
-
const seen = this.seen.get(peer) ?? new Set<number>();
|
|
54
|
+
async forTask(peer: PeerId, task: BriefTask): Promise<string | undefined> {
|
|
55
|
+
const seen = this.seen.get(peer) ?? new Set<string>();
|
|
48
56
|
this.seen.set(peer, seen);
|
|
57
|
+
const own: NativeItem[] = [];
|
|
58
|
+
try { for (const item of this.native?.(task) ?? []) if (!seen.has(item.id) && own.length < this.maxItems) own.push(item); } catch { /* fail-open: the store is optional */ }
|
|
59
|
+
const found = this.client && own.length < this.maxItems ? await related(this.client, this.project, [task.title, ...(task.refs.paths ?? [])].join(" ")) : [];
|
|
49
60
|
const items: BriefItem[] = [];
|
|
50
61
|
for (const item of found) {
|
|
51
|
-
if (seen.has(item.id)) continue;
|
|
62
|
+
if (seen.has(`#${item.id}`)) continue;
|
|
52
63
|
items.push(item);
|
|
53
|
-
if (items.length === this.maxItems) break;
|
|
64
|
+
if (own.length + items.length === this.maxItems) break;
|
|
54
65
|
}
|
|
55
|
-
if (!items.length) return undefined;
|
|
56
|
-
for (const item of
|
|
57
|
-
|
|
66
|
+
if (!own.length && !items.length) return undefined;
|
|
67
|
+
for (const item of own) seen.add(item.id);
|
|
68
|
+
for (const item of items) seen.add(`#${item.id}`);
|
|
69
|
+
if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: own.length, fails: own.filter((i) => i.kind === "fail").length, claudeMem: items.length }); } catch { /* telemetry never fails a brief */ }
|
|
70
|
+
const header = own.length ? "Memory brief (hub memory first, fails first: ahub memory show <mem id>; claude-mem by #id):" : "Memory brief (claude-mem; fetch details by id if you need them):";
|
|
71
|
+
return [header, ...own.map((i) => i.line), ...items.map((i) => `#${i.id} ${i.time} ${i.type} ${i.title}`)].join("\n");
|
|
58
72
|
}
|
|
59
73
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { KINDS, looksSecret, type Kind, type MemoryStore } from "./store.ts";
|
|
6
|
+
|
|
7
|
+
/** The claude-mem store is another program's: opened read-only, never written, its locks waited on briefly. */
|
|
8
|
+
export const CLAUDE_MEM_BUSY_TIMEOUT_MS = 3000;
|
|
9
|
+
/** Titles `ahub ask --remember` gives a model's answer (ask.ts ASK_NOTE_TITLE): never imported as a finding. */
|
|
10
|
+
const MODEL_ANSWER = "ahub ask (model answer)";
|
|
11
|
+
|
|
12
|
+
export interface ImportResult { read: number; added: number; skipped: { model: number; pii: number; secret: number; project: number } }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* #258 phase 3: copy the hub's own notes from claude-mem (observations whose metadata names a `peer`: `hub_remember`
|
|
16
|
+
* and the hub's task notes) into long-term hub memory as `source: claude-mem` under the hub memory key `project`, for
|
|
17
|
+
* the projects in `chain` (claude-mem files them under the repository's basename). Model answers, PII-pattern text and secret-looking text are skipped.
|
|
18
|
+
* Idempotent: an observation always maps to the same entry.
|
|
19
|
+
*/
|
|
20
|
+
export function importClaudeMem(store: MemoryStore, opts: { project: string; chain: string[]; isPii: (text: string) => boolean; nodes?: (text: string) => string[]; db?: string; limit?: number }): ImportResult {
|
|
21
|
+
const file = opts.db ?? join(homedir(), ".claude-mem", "claude-mem.db");
|
|
22
|
+
if (!existsSync(file)) throw new Error(`no claude-mem store at ${file}`);
|
|
23
|
+
const db = new Database(file, { readonly: true });
|
|
24
|
+
db.run(`PRAGMA busy_timeout = ${CLAUDE_MEM_BUSY_TIMEOUT_MS}`);
|
|
25
|
+
const result: ImportResult = { read: 0, added: 0, skipped: { model: 0, pii: 0, secret: 0, project: 0 } };
|
|
26
|
+
try {
|
|
27
|
+
const rows = db.query("SELECT id, project, title, text, narrative, created_at_epoch, metadata FROM observations WHERE metadata LIKE '%\"peer\"%' ORDER BY id LIMIT ?")
|
|
28
|
+
.all(opts.limit ?? 10_000) as { id: number; project: string | null; title: string | null; text: string | null; narrative: string | null; created_at_epoch: number | null; metadata: string | null }[];
|
|
29
|
+
// The hub files its claude-mem notes under the repository's own name (the chain's last); the names above it are other
|
|
30
|
+
// projects (a superproject) whose notes and privacy policy are not this one's. ponytail: claude-mem keys projects by
|
|
31
|
+
// basename, so two unrelated repositories with one name still share it.
|
|
32
|
+
const project = opts.project, names = new Set(opts.chain.slice(-1));
|
|
33
|
+
for (const r of rows) {
|
|
34
|
+
result.read++;
|
|
35
|
+
let meta: Record<string, unknown>;
|
|
36
|
+
try { meta = JSON.parse(r.metadata ?? "{}"); } catch { continue; }
|
|
37
|
+
if (typeof meta.peer !== "string" || !meta.peer) continue;
|
|
38
|
+
if (!r.project || !names.has(r.project)) { result.skipped.project++; continue; }
|
|
39
|
+
const title = r.title ?? "", text = (r.text ?? r.narrative ?? "").trim();
|
|
40
|
+
if (!text) continue;
|
|
41
|
+
if (meta.source === "ahub ask" || title.startsWith(MODEL_ANSWER)) { result.skipped.model++; continue; }
|
|
42
|
+
if (opts.isPii(`${title}\n${text}`)) { result.skipped.pii++; continue; }
|
|
43
|
+
if (looksSecret(`${title}\n${text}`)) { result.skipped.secret++; continue; }
|
|
44
|
+
const kind: Kind = KINDS.includes(meta.kind as Kind) ? meta.kind as Kind : "finding";
|
|
45
|
+
const at = new Date(r.created_at_epoch ?? 0).toISOString();
|
|
46
|
+
const { added } = store.importNote({ op: "note", project, scope: "project", tier: "long", kind, peer: meta.peer.slice(0, 32), ...(title ? { title } : {}), text,
|
|
47
|
+
// No task node: claude-mem keeps no proposal time, so a bare task number would join unrelated tasks. The text's
|
|
48
|
+
// own paths and symbols place the note in the graph.
|
|
49
|
+
about: opts.nodes?.(`${title}\n${text}`) ?? [], source: "claude-mem" }, at);
|
|
50
|
+
if (added) result.added++;
|
|
51
|
+
}
|
|
52
|
+
} finally { db.close(); }
|
|
53
|
+
store.refresh();
|
|
54
|
+
return result;
|
|
55
|
+
}
|
package/src/memory/recall.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
2
|
import { basename, dirname, resolve } from "node:path";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import type { PeerId } from "../hub/envelope.ts";
|
|
5
|
+
import { hubGitSync } from "../hub/git.ts";
|
|
6
|
+
import { realPath } from "../hub/project.ts";
|
|
5
7
|
import type { MemoryClient } from "./client.ts";
|
|
8
|
+
import { detectSignals, type Routing } from "../hub/routing.ts";
|
|
9
|
+
import { matchesPolicy } from "../hub/path-policy.ts";
|
|
10
|
+
import { looksSecret, type PromotionScreen } from "./store.ts";
|
|
6
11
|
|
|
7
12
|
const PLATFORMS = ["claude", "codex", "kimi", "agent-hub"];
|
|
8
13
|
/** Peers whose own hooks already inject their own platform's context at session start. */
|
|
@@ -14,7 +19,7 @@ const HEADER =
|
|
|
14
19
|
|
|
15
20
|
/** Project names from the outermost superproject down to this repo. */
|
|
16
21
|
export function projectChain(cwd: string): string[] {
|
|
17
|
-
const git = (dir: string, arg: string) =>
|
|
22
|
+
const git = (dir: string, arg: string) => hubGitSync(["-C", dir, "rev-parse", arg], { encoding: "utf8" }).stdout?.trim() ?? "";
|
|
18
23
|
const top = git(cwd, "--show-toplevel");
|
|
19
24
|
if (!top) return [basename(cwd)];
|
|
20
25
|
|
|
@@ -76,3 +81,82 @@ export async function recallFor(peer: PeerId, client: MemoryClient, chain: strin
|
|
|
76
81
|
const share = Math.floor(tokens / found.length);
|
|
77
82
|
return `${HEADER}\n\n${found.map((b) => trimToTokens(b, share)).join("\n\n")}`;
|
|
78
83
|
}
|
|
84
|
+
|
|
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
|
+
/**
|
|
92
|
+
* #258: the name hub memory files a project's entries under. The project's `origin` remote, normalized to host and
|
|
93
|
+
* path (`github.com/owner/repo`), is the same on every machine and unique; without one, the git chain plus a hash of
|
|
94
|
+
* the directory keeps two unrelated repositories that share a name apart (that key is this machine's only).
|
|
95
|
+
*/
|
|
96
|
+
export function memoryProjectKey(cwd: string): string {
|
|
97
|
+
const url = hubGitSync(["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8" }).stdout?.trim() ?? "";
|
|
98
|
+
// A URL's default port is not part of the repository (`ssh://git@github.com:22/o/r` is `github.com/o/r`); any other
|
|
99
|
+
// port is, since another service may run there. An scp-style `host:path` has no port.
|
|
100
|
+
const u = /^([a-z+]+):\/\/(?:[^@/]+@)?(\[[^\]/]+\]|[^/:]+)(?::(\d*))?\/(.+?)(?:\.git)?\/?$/i.exec(url); // an IPv6 host is bracketed
|
|
101
|
+
const defaults: Record<string, string> = { ssh: "22", "git+ssh": "22", "ssh+git": "22", https: "443", http: "80", git: "9418" };
|
|
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)}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Every path-like word of a text (`dir/file`, `file.ext`), whether or not it exists or a denylist hides it from the
|
|
119
|
+
* graph: what a private-path policy is checked against.
|
|
120
|
+
*/
|
|
121
|
+
export const pathCandidates = (text: string): string[] => [...new Set(text.normalize("NFC").match(/\/?[\p{L}\p{M}\p{N}_.~-]+(?:\/[\p{L}\p{M}\p{N}_.~-]+)+|[\p{L}\p{M}\p{N}_-]+\.\p{L}[\p{L}\p{M}\p{N}_]{0,9}/gu) ?? [])]; // any script, combining marks included; an absolute path keeps its slash
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Whether a note is about a path the `pii.paths` policy makes private: a stored node's path, a path-like word of its
|
|
125
|
+
* text, or, for a policy entry with no glob that a word split cannot find (a space in it), that entry written out.
|
|
126
|
+
*/
|
|
127
|
+
export function mentionsPrivatePath(text: string, about: string[], policy: string[], cwd: string): boolean {
|
|
128
|
+
if (!policy.length) return false;
|
|
129
|
+
const nfc = (v: string) => v.normalize("NFC"), body = nfc(text), entries = policy.map(nfc);
|
|
130
|
+
const paths = [...about.map(nodePath), ...pathCandidates(body)].filter((p): p is string => p !== undefined).map(nfc);
|
|
131
|
+
if (paths.some((p) => matchesPolicy(cwd, p, entries, true))) return true;
|
|
132
|
+
// The entry's literal part (up to its first glob character) written out: catches a path a word split breaks (a space).
|
|
133
|
+
return entries.some((entry) => { const lit = entry.replace(/^\.\//, "").split(/[*?[\]{}]/)[0]!.replace(/\/+$/, ""); return /[/\s]/.test(lit) && body.includes(lit); });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The file a graph node names: `path:<p>` and `symbol:<name>@<p>`; nothing for other nodes. */
|
|
137
|
+
export const nodePath = (n: string): string | undefined => n.startsWith("path:") ? n.slice(5) : n.startsWith("symbol:") && n.includes("@") ? n.slice(n.lastIndexOf("@") + 1) : undefined;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Notes whose title, text, nodes, task key or project key match the PII patterns of `routing`, or that are about a path
|
|
141
|
+
* the project's `pii.paths` (`policy`) makes private, left out: what may go to a model.
|
|
142
|
+
*/
|
|
143
|
+
export function withoutPii<T extends { title: string | null; text: string; about?: string[]; task?: string | null; project?: string }>(notes: T[], routing: Routing, cwd: string, policy: string[] = []): T[] {
|
|
144
|
+
// A task key's `#<id>@<created ms>` is the hub's own numbers, not note content: a digit-run pattern must not withhold a
|
|
145
|
+
// clean note for it. The project part of the key stays screened.
|
|
146
|
+
const keyText = (k: string) => k.replace(/#\d+(?:@\d+)?$/, "");
|
|
147
|
+
return notes.filter((v) => !mentionsPrivatePath(`${v.title ?? ""}\n${v.text}`, v.about ?? [], policy, cwd) && !detectSignals({ title: v.title ?? "",
|
|
148
|
+
detail: [v.text, ...(v.about ?? []).map((n) => n.startsWith("task:") ? keyText(n) : n), keyText(v.task ?? ""), v.project ?? ""].join("\n"), refs: {} }, routing, cwd).includes("pii"));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The promotion screen for one project: another project's notes are left to that project's own processes; this one's
|
|
153
|
+
* move only when no secret, PII pattern or private path (`policy()`, read when asked) is in them.
|
|
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
|
+
};
|
|
162
|
+
}
|