zmemory 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 +167 -0
- package/adapters/claude-code-adapter.sh +11 -0
- package/adapters/codex-adapter.sh +12 -0
- package/adapters/cursor-adapter.sh +14 -0
- package/adapters/opencode-adapter.sh +9 -0
- package/bin/zmemory.js +426 -0
- package/package.json +42 -0
- package/src/bus/server.js +51 -0
- package/src/commands/agent.js +42 -0
- package/src/commands/archive.js +32 -0
- package/src/commands/bootstrap.js +7 -0
- package/src/commands/bus.js +6 -0
- package/src/commands/claim.js +66 -0
- package/src/commands/config.js +29 -0
- package/src/commands/context.js +269 -0
- package/src/commands/daemon.js +6 -0
- package/src/commands/decision.js +12 -0
- package/src/commands/doctor.js +100 -0
- package/src/commands/event.js +149 -0
- package/src/commands/exec.js +25 -0
- package/src/commands/explain.js +68 -0
- package/src/commands/failure.js +12 -0
- package/src/commands/gitHook.js +22 -0
- package/src/commands/handoff.js +101 -0
- package/src/commands/health.js +18 -0
- package/src/commands/help.js +33 -0
- package/src/commands/index.js +21 -0
- package/src/commands/init.js +38 -0
- package/src/commands/install.js +28 -0
- package/src/commands/locks.js +9 -0
- package/src/commands/next.js +59 -0
- package/src/commands/orchestrator.js +6 -0
- package/src/commands/plan.js +30 -0
- package/src/commands/resume.js +79 -0
- package/src/commands/route.js +67 -0
- package/src/commands/runs.js +47 -0
- package/src/commands/search.js +159 -0
- package/src/commands/setup.js +148 -0
- package/src/commands/skill.js +103 -0
- package/src/commands/startRun.js +82 -0
- package/src/commands/state.js +68 -0
- package/src/commands/status.js +51 -0
- package/src/commands/stream.js +27 -0
- package/src/commands/summary.js +65 -0
- package/src/commands/sync.js +20 -0
- package/src/commands/tasks.js +89 -0
- package/src/commands/timeline.js +75 -0
- package/src/commands/upgrade.js +19 -0
- package/src/commands/version.js +12 -0
- package/src/commands/who.js +26 -0
- package/src/commands/worker.js +71 -0
- package/src/commands/workspaces.js +25 -0
- package/src/daemon/orchestrator.js +34 -0
- package/src/daemon/zmemoryd.js +17 -0
- package/src/dashboard/server.js +40 -0
- package/src/integrations/agent-hooks.md +39 -0
- package/src/integrations/auto-bootstrap.js +90 -0
- package/src/integrations/harness-adapter.md +39 -0
- package/src/lib/agents.js +36 -0
- package/src/lib/config.js +15 -0
- package/src/lib/coordination/agents.js +49 -0
- package/src/lib/coordination/claims.js +127 -0
- package/src/lib/coordination/who.js +49 -0
- package/src/lib/fileIndex.js +36 -0
- package/src/lib/fileLocks.js +44 -0
- package/src/lib/fs.js +140 -0
- package/src/lib/lock.js +103 -0
- package/src/lib/repoScan.js +19 -0
- package/src/lib/run.js +63 -0
- package/src/lib/sqlite.js +7 -0
- package/src/lib/tasks.js +77 -0
- package/src/lib/teamSync.js +36 -0
- package/src/lib/workspaces.js +29 -0
- package/src/mcp/autostart.js +12 -0
- package/src/mcp/server.js +420 -0
- package/src/mcp/tools.json +28 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
import { registerAgent } from "../lib/coordination/agents.js";
|
|
5
|
+
|
|
6
|
+
// Automatically ensure ZMemory is initialized when a repo is opened
|
|
7
|
+
export function ensureZMemory() {
|
|
8
|
+
const dir = ".zmemory";
|
|
9
|
+
if (!fs.existsSync(dir)) {
|
|
10
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
11
|
+
fs.writeFileSync(path.join(dir, "project.md"), "# Project Memory\n");
|
|
12
|
+
fs.writeFileSync(path.join(dir, "decisions.md"), "# Decisions\n");
|
|
13
|
+
fs.writeFileSync(path.join(dir, "failures.md"), "# Failures\n");
|
|
14
|
+
fs.mkdirSync(path.join(dir, "runs/active"), { recursive: true });
|
|
15
|
+
fs.mkdirSync(path.join(dir, "runs/archived"), { recursive: true });
|
|
16
|
+
fs.mkdirSync(path.join(dir, "logs"), { recursive: true });
|
|
17
|
+
fs.writeFileSync(path.join(dir, "logs/events.jsonl"), "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const skillsDir = path.join(dir, "skills");
|
|
21
|
+
fs.mkdirSync(skillsDir, { recursive: true });
|
|
22
|
+
const skillFile = path.join(skillsDir, "coordination.md");
|
|
23
|
+
if (!fs.existsSync(skillFile)) {
|
|
24
|
+
const skill = `# ZMemory Coordination
|
|
25
|
+
|
|
26
|
+
This repository uses **ZMemory** for multi‑agent coordination.
|
|
27
|
+
|
|
28
|
+
Workflow:
|
|
29
|
+
|
|
30
|
+
1. Inspect project state
|
|
31
|
+
zmemory resume
|
|
32
|
+
|
|
33
|
+
2. Before editing a file
|
|
34
|
+
zmemory who <file>
|
|
35
|
+
|
|
36
|
+
3. When starting work on a module
|
|
37
|
+
zmemory claim <pattern> --session <id>
|
|
38
|
+
|
|
39
|
+
4. Log meaningful work
|
|
40
|
+
zmemory event --summary "what you did" --files <file>
|
|
41
|
+
|
|
42
|
+
5. When work is complete
|
|
43
|
+
zmemory release --session <id>
|
|
44
|
+
|
|
45
|
+
ZMemory provides shared awareness across agents so avoid editing files
|
|
46
|
+
that are actively claimed unless coordination is intentional.
|
|
47
|
+
`;
|
|
48
|
+
fs.writeFileSync(skillFile, skill);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Ensure MCP manifest exists so harnesses can discover tools automatically
|
|
52
|
+
const mcpDir = path.join(dir, "mcp");
|
|
53
|
+
fs.mkdirSync(mcpDir, { recursive: true });
|
|
54
|
+
const manifest = path.join(mcpDir, "manifest.json");
|
|
55
|
+
if (!fs.existsSync(manifest)) {
|
|
56
|
+
const m = {
|
|
57
|
+
name: "zmemory",
|
|
58
|
+
protocol: "mcp",
|
|
59
|
+
transport: "stdio",
|
|
60
|
+
command: "zmemory mcp",
|
|
61
|
+
description: "Shared memory + coordination layer for AI coding agents"
|
|
62
|
+
};
|
|
63
|
+
fs.writeFileSync(manifest, JSON.stringify(m, null, 2));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Optional auto agent bootstrap ---
|
|
67
|
+
// Enabled when environment variable is provided by the agent runtime
|
|
68
|
+
const agentName = process.env.ZMEMORY_AGENT;
|
|
69
|
+
if (agentName) {
|
|
70
|
+
try {
|
|
71
|
+
let session = process.env.ZMEMORY_SESSION;
|
|
72
|
+
|
|
73
|
+
if (!session) {
|
|
74
|
+
const id = crypto.randomBytes(3).toString("hex");
|
|
75
|
+
session = `${agentName}-${id}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
registerAgent({
|
|
79
|
+
agent: agentName,
|
|
80
|
+
session,
|
|
81
|
+
task: process.env.ZMEMORY_TASK || ""
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// expose session id for child tools if needed
|
|
85
|
+
if (!process.env.ZMEMORY_SESSION) {
|
|
86
|
+
process.env.ZMEMORY_SESSION = session;
|
|
87
|
+
}
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# ZMemory Harness Adapter
|
|
2
|
+
|
|
3
|
+
This file explains how coding tools can automatically integrate with ZMemory.
|
|
4
|
+
|
|
5
|
+
Suggested adapter loop for harnesses:
|
|
6
|
+
|
|
7
|
+
1. On start
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
zmemory bootstrap
|
|
11
|
+
zmemory resume
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
2. Before executing a task
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
zmemory context --role executor
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
3. After writing files
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
zmemory event --role executor --type file_changed
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
4. After running commands
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
zmemory exec <command>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
5. On completion
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
zmemory handoff
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Adapters can wrap these calls automatically so users do not need to run them manually.
|
|
39
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readJSON, writeJSON, withFileLock } from "./fs.js";
|
|
2
|
+
|
|
3
|
+
const AGENTS_FILE = ".zmemory/agents.json";
|
|
4
|
+
const LOCK = ".zmemory/agents.json.lock";
|
|
5
|
+
|
|
6
|
+
function readAgents() {
|
|
7
|
+
return readJSON(AGENTS_FILE) || {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function writeAgents(data) {
|
|
11
|
+
writeJSON(AGENTS_FILE, data);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function registerAgent(name, role) {
|
|
15
|
+
return withFileLock(LOCK, () => {
|
|
16
|
+
const agents = readAgents();
|
|
17
|
+
agents[name] = {
|
|
18
|
+
role,
|
|
19
|
+
last_heartbeat: new Date().toISOString()
|
|
20
|
+
};
|
|
21
|
+
writeAgents(agents);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function heartbeat(name) {
|
|
26
|
+
return withFileLock(LOCK, () => {
|
|
27
|
+
const agents = readAgents();
|
|
28
|
+
if (!agents[name]) return;
|
|
29
|
+
agents[name].last_heartbeat = new Date().toISOString();
|
|
30
|
+
writeAgents(agents);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function listAgents() {
|
|
35
|
+
return readAgents();
|
|
36
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readJSON, writeJSON } from "./fs.js";
|
|
2
|
+
|
|
3
|
+
const CONFIG = ".zmemory/config.json";
|
|
4
|
+
|
|
5
|
+
export function readConfig() {
|
|
6
|
+
try {
|
|
7
|
+
return readJSON(CONFIG, {}) || {};
|
|
8
|
+
} catch {
|
|
9
|
+
return {};
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function writeConfig(cfg) {
|
|
14
|
+
writeJSON(CONFIG, cfg);
|
|
15
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
const BASE = ".zmemory/agents";
|
|
5
|
+
|
|
6
|
+
function ensureDir() {
|
|
7
|
+
fs.mkdirSync(BASE, { recursive: true });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function agentPath(session) {
|
|
11
|
+
return path.join(BASE, `${session}.json`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function registerAgent({ agent, session, task = "", files = [], cwd = process.cwd() }) {
|
|
15
|
+
ensureDir();
|
|
16
|
+
|
|
17
|
+
const data = {
|
|
18
|
+
agent,
|
|
19
|
+
session,
|
|
20
|
+
task,
|
|
21
|
+
files,
|
|
22
|
+
cwd,
|
|
23
|
+
started_at: new Date().toISOString()
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
fs.writeFileSync(agentPath(session), JSON.stringify(data, null, 2));
|
|
27
|
+
return data;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function listAgents() {
|
|
31
|
+
if (!fs.existsSync(BASE)) return [];
|
|
32
|
+
|
|
33
|
+
const files = fs.readdirSync(BASE);
|
|
34
|
+
const agents = [];
|
|
35
|
+
|
|
36
|
+
for (const f of files) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(path.join(BASE, f), "utf8");
|
|
39
|
+
agents.push(JSON.parse(raw));
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return agents;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function removeAgent(session) {
|
|
47
|
+
const p = agentPath(session);
|
|
48
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
49
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
|
|
5
|
+
const BASE = ".zmemory/claims";
|
|
6
|
+
const DEFAULT_TTL = 600; // seconds
|
|
7
|
+
|
|
8
|
+
function ensureDir() {
|
|
9
|
+
fs.mkdirSync(BASE, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hash(pattern) {
|
|
13
|
+
return crypto.createHash("sha1").update(pattern).digest("hex").slice(0, 8);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function claimPath(pattern) {
|
|
17
|
+
return path.join(BASE, `${hash(pattern)}.json`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isExpired(agent) {
|
|
21
|
+
if (!agent?.ts) return false;
|
|
22
|
+
const ttl = agent.ttl || DEFAULT_TTL;
|
|
23
|
+
const t = new Date(agent.ts).getTime() + ttl * 1000;
|
|
24
|
+
return Date.now() > t;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function pruneExpired(data) {
|
|
28
|
+
data.agents = (data.agents || []).filter(a => !isExpired(a));
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function claimPattern(pattern, { session, task = "" }) {
|
|
33
|
+
ensureDir();
|
|
34
|
+
|
|
35
|
+
const p = claimPath(pattern);
|
|
36
|
+
let data = { pattern, agents: [] };
|
|
37
|
+
|
|
38
|
+
if (fs.existsSync(p)) {
|
|
39
|
+
try {
|
|
40
|
+
data = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
data = pruneExpired(data);
|
|
45
|
+
|
|
46
|
+
const existing = data.agents.find((a) => a.session === session);
|
|
47
|
+
if (!existing) {
|
|
48
|
+
data.agents.push({
|
|
49
|
+
session,
|
|
50
|
+
task,
|
|
51
|
+
ts: new Date().toISOString(),
|
|
52
|
+
ttl: DEFAULT_TTL
|
|
53
|
+
});
|
|
54
|
+
} else {
|
|
55
|
+
// refresh lease
|
|
56
|
+
existing.ts = new Date().toISOString();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2));
|
|
60
|
+
return data;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function listClaims() {
|
|
64
|
+
if (!fs.existsSync(BASE)) return [];
|
|
65
|
+
|
|
66
|
+
const files = fs.readdirSync(BASE);
|
|
67
|
+
const claims = [];
|
|
68
|
+
|
|
69
|
+
for (const f of files) {
|
|
70
|
+
try {
|
|
71
|
+
const raw = fs.readFileSync(path.join(BASE, f), "utf8");
|
|
72
|
+
const data = pruneExpired(JSON.parse(raw));
|
|
73
|
+
if ((data.agents || []).length) claims.push(data);
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return claims;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function releaseClaims(session) {
|
|
81
|
+
if (!fs.existsSync(BASE)) return;
|
|
82
|
+
|
|
83
|
+
const files = fs.readdirSync(BASE);
|
|
84
|
+
|
|
85
|
+
for (const f of files) {
|
|
86
|
+
const p = path.join(BASE, f);
|
|
87
|
+
try {
|
|
88
|
+
const data = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
89
|
+
data.agents = (data.agents || []).filter((a) => a.session !== session && !isExpired(a));
|
|
90
|
+
|
|
91
|
+
if (!data.agents.length) {
|
|
92
|
+
fs.unlinkSync(p);
|
|
93
|
+
} else {
|
|
94
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2));
|
|
95
|
+
}
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function refreshClaims(session) {
|
|
101
|
+
if (!fs.existsSync(BASE)) return;
|
|
102
|
+
const files = fs.readdirSync(BASE);
|
|
103
|
+
|
|
104
|
+
for (const f of files) {
|
|
105
|
+
const p = path.join(BASE, f);
|
|
106
|
+
try {
|
|
107
|
+
const data = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
108
|
+
let changed = false;
|
|
109
|
+
|
|
110
|
+
for (const a of data.agents || []) {
|
|
111
|
+
if (a.session === session) {
|
|
112
|
+
a.ts = new Date().toISOString();
|
|
113
|
+
if (!a.ttl) a.ttl = DEFAULT_TTL;
|
|
114
|
+
changed = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const pruned = pruneExpired(data);
|
|
119
|
+
|
|
120
|
+
if (!pruned.agents.length) {
|
|
121
|
+
fs.unlinkSync(p);
|
|
122
|
+
} else if (changed) {
|
|
123
|
+
fs.writeFileSync(p, JSON.stringify(pruned, null, 2));
|
|
124
|
+
}
|
|
125
|
+
} catch {}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { listClaims } from "./claims.js";
|
|
2
|
+
// Minimal glob matcher to avoid external deps. Supports "*" wildcard only.
|
|
3
|
+
function globMatch(pattern, file) {
|
|
4
|
+
if (!pattern.includes("*")) return pattern === file;
|
|
5
|
+
const parts = pattern.split("*");
|
|
6
|
+
if (parts.length === 2) {
|
|
7
|
+
const [start, end] = parts;
|
|
8
|
+
return file.startsWith(start) && file.endsWith(end || "");
|
|
9
|
+
}
|
|
10
|
+
// fallback: simple contains sequence check
|
|
11
|
+
let idx = 0;
|
|
12
|
+
for (const p of parts) {
|
|
13
|
+
if (!p) continue;
|
|
14
|
+
const i = file.indexOf(p, idx);
|
|
15
|
+
if (i === -1) return false;
|
|
16
|
+
idx = i + p.length;
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function withExpiry(agent) {
|
|
22
|
+
if (!agent?.ts) return agent;
|
|
23
|
+
const ttl = agent.ttl || 600;
|
|
24
|
+
const expireAt = new Date(agent.ts).getTime() + ttl * 1000;
|
|
25
|
+
const remaining = Math.max(0, Math.floor((expireAt - Date.now()) / 1000));
|
|
26
|
+
return {
|
|
27
|
+
...agent,
|
|
28
|
+
expires_in_seconds: remaining,
|
|
29
|
+
expires_in_minutes: Math.floor(remaining / 60)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function who(filePath) {
|
|
34
|
+
const claims = listClaims();
|
|
35
|
+
const matches = [];
|
|
36
|
+
|
|
37
|
+
for (const c of claims) {
|
|
38
|
+
try {
|
|
39
|
+
if (globMatch(c.pattern, filePath)) {
|
|
40
|
+
matches.push({
|
|
41
|
+
pattern: c.pattern,
|
|
42
|
+
agents: (c.agents || []).map(withExpiry)
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return matches;
|
|
49
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { writeJSON, readJSON } from "./fs.js";
|
|
4
|
+
|
|
5
|
+
const INDEX_FILE = ".zmemory/fileindex.json";
|
|
6
|
+
|
|
7
|
+
function walk(dir, ignore = ["node_modules", ".git", ".zmemory"]) {
|
|
8
|
+
const results = [];
|
|
9
|
+
if (!fs.existsSync(dir)) return results;
|
|
10
|
+
|
|
11
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
12
|
+
|
|
13
|
+
for (const e of entries) {
|
|
14
|
+
if (ignore.includes(e.name)) continue;
|
|
15
|
+
|
|
16
|
+
const full = path.join(dir, e.name);
|
|
17
|
+
|
|
18
|
+
if (e.isDirectory()) {
|
|
19
|
+
results.push(...walk(full, ignore));
|
|
20
|
+
} else {
|
|
21
|
+
results.push(full);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return results;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildFileIndex(root = ".") {
|
|
29
|
+
const files = walk(root);
|
|
30
|
+
writeJSON(INDEX_FILE, files);
|
|
31
|
+
return files;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readFileIndex() {
|
|
35
|
+
return readJSON(INDEX_FILE) || [];
|
|
36
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readJSON, writeJSON, withFileLock } from "./fs.js";
|
|
2
|
+
|
|
3
|
+
const LOCK_FILE = ".zmemory/filelocks.json";
|
|
4
|
+
const MUTEX = ".zmemory/filelocks.json.lock";
|
|
5
|
+
|
|
6
|
+
function readLocks() {
|
|
7
|
+
return readJSON(LOCK_FILE) || {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function writeLocks(data) {
|
|
11
|
+
writeJSON(LOCK_FILE, data);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function lockFiles(worker, files) {
|
|
15
|
+
return withFileLock(MUTEX, () => {
|
|
16
|
+
const locks = readLocks();
|
|
17
|
+
|
|
18
|
+
for (const f of files) {
|
|
19
|
+
if (locks[f] && locks[f] !== worker) {
|
|
20
|
+
throw new Error(`file locked: ${f}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
files.forEach((f) => {
|
|
25
|
+
locks[f] = worker;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
writeLocks(locks);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function releaseFiles(worker) {
|
|
33
|
+
return withFileLock(MUTEX, () => {
|
|
34
|
+
const locks = readLocks();
|
|
35
|
+
Object.keys(locks).forEach((f) => {
|
|
36
|
+
if (locks[f] === worker) delete locks[f];
|
|
37
|
+
});
|
|
38
|
+
writeLocks(locks);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function listLocks() {
|
|
43
|
+
return readLocks();
|
|
44
|
+
}
|
package/src/lib/fs.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
export const ZMEMORY_DIR = ".zmemory";
|
|
5
|
+
|
|
6
|
+
export function ensureDir(p) {
|
|
7
|
+
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function writeFile(p, data) {
|
|
11
|
+
atomicWriteFile(p, data);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function appendFile(p, data) {
|
|
15
|
+
ensureDir(path.dirname(p));
|
|
16
|
+
fs.appendFileSync(p, data);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readJSON(p, fallback = null) {
|
|
20
|
+
if (!fs.existsSync(p)) return fallback;
|
|
21
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
22
|
+
return JSON.parse(raw);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function writeJSON(p, obj) {
|
|
26
|
+
writeFile(p, JSON.stringify(obj, null, 2));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function now() {
|
|
30
|
+
return new Date().toISOString();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function atomicWriteFile(filePath, data) {
|
|
34
|
+
ensureDir(path.dirname(filePath));
|
|
35
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
36
|
+
try {
|
|
37
|
+
fs.writeFileSync(tmp, data);
|
|
38
|
+
fs.renameSync(tmp, filePath);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch {}
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function processAlive(pid) {
|
|
46
|
+
try {
|
|
47
|
+
process.kill(pid, 0);
|
|
48
|
+
return true;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err && err.code === "EPERM") return true;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function withFileLock(lockPath, fn, options = {}) {
|
|
56
|
+
const ttl = options.ttl || 30 * 60 * 1000;
|
|
57
|
+
ensureDir(path.dirname(lockPath));
|
|
58
|
+
let owned = false;
|
|
59
|
+
|
|
60
|
+
function acquire() {
|
|
61
|
+
try {
|
|
62
|
+
const fd = fs.openSync(lockPath, "wx");
|
|
63
|
+
try {
|
|
64
|
+
const meta = { pid: process.pid, created_at: Date.now() };
|
|
65
|
+
fs.writeFileSync(fd, JSON.stringify(meta));
|
|
66
|
+
} catch (e) {
|
|
67
|
+
try { fs.closeSync(fd); } catch {}
|
|
68
|
+
try { if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath); } catch {}
|
|
69
|
+
throw e;
|
|
70
|
+
}
|
|
71
|
+
fs.closeSync(fd);
|
|
72
|
+
owned = true;
|
|
73
|
+
return true;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err && err.code !== "EEXIST") throw err;
|
|
76
|
+
|
|
77
|
+
if (!fs.existsSync(lockPath)) return false;
|
|
78
|
+
try {
|
|
79
|
+
const stat = fs.statSync(lockPath);
|
|
80
|
+
const raw = fs.readFileSync(lockPath, "utf8").trim();
|
|
81
|
+
|
|
82
|
+
let stale = false;
|
|
83
|
+
|
|
84
|
+
if (/^\d+$/.test(raw)) {
|
|
85
|
+
const pid = Number(raw);
|
|
86
|
+
if (Number.isSafeInteger(pid) && pid > 0) {
|
|
87
|
+
if (!processAlive(pid)) stale = true;
|
|
88
|
+
} else {
|
|
89
|
+
const age = Date.now() - stat.mtimeMs;
|
|
90
|
+
if (age > ttl) stale = true;
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
let parsed = false;
|
|
94
|
+
try {
|
|
95
|
+
const meta = JSON.parse(raw);
|
|
96
|
+
if (
|
|
97
|
+
meta &&
|
|
98
|
+
typeof meta === "object" &&
|
|
99
|
+
!Array.isArray(meta) &&
|
|
100
|
+
typeof meta.pid === "number" &&
|
|
101
|
+
meta.pid > 0 &&
|
|
102
|
+
typeof meta.created_at === "number"
|
|
103
|
+
) {
|
|
104
|
+
parsed = true;
|
|
105
|
+
const age = Date.now() - meta.created_at;
|
|
106
|
+
if (!processAlive(meta.pid) || age > ttl) stale = true;
|
|
107
|
+
}
|
|
108
|
+
} catch {}
|
|
109
|
+
|
|
110
|
+
if (!parsed) {
|
|
111
|
+
const age = Date.now() - stat.mtimeMs;
|
|
112
|
+
if (age > ttl) stale = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (stale) {
|
|
117
|
+
fs.unlinkSync(lockPath);
|
|
118
|
+
return acquire();
|
|
119
|
+
}
|
|
120
|
+
} catch {}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!acquire()) throw new Error("file lock busy");
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
return fn();
|
|
129
|
+
} finally {
|
|
130
|
+
if (owned) {
|
|
131
|
+
try {
|
|
132
|
+
if (fs.existsSync(lockPath)) {
|
|
133
|
+
const raw = fs.readFileSync(lockPath, "utf8");
|
|
134
|
+
const meta = JSON.parse(raw);
|
|
135
|
+
if (meta.pid === process.pid) fs.unlinkSync(lockPath);
|
|
136
|
+
}
|
|
137
|
+
} catch {}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|