agentp 1.14.0 → 2.0.1
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/CONTRIBUTING.md +80 -83
- package/README.md +165 -51
- package/bin/agentp +525 -84
- package/bin/ocmux +1376 -580
- package/docs/specification.md +12 -454
- package/docs/specification_v2.md +652 -0
- package/lib/ocmux.js +123 -197
- package/lib/opencode.js +363 -657
- package/lib/project-state.js +213 -0
- package/package.json +1 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Project/session state for the agentp v2 model ("sessions live in projects").
|
|
4
|
+
//
|
|
5
|
+
// A project is a directory that owns a `.ocmux.json` state file recording the
|
|
6
|
+
// target session ID (and optionally the server URL). This module is the single
|
|
7
|
+
// place that reads/writes that file so `agentp`, `ocmux` and `tgagentp` agree
|
|
8
|
+
// on the schema and its legacy migration rules.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const STATE_BASENAME = '.ocmux.json';
|
|
14
|
+
const ANNOTATIONS_BASENAME = 'annotations.json';
|
|
15
|
+
|
|
16
|
+
// Unit-test escape hatch: disable all filesystem discovery so CLI behavior is
|
|
17
|
+
// deterministic regardless of where the test process happens to run.
|
|
18
|
+
function discoveryDisabled() {
|
|
19
|
+
const v = process.env.AGENTP_NO_STATE;
|
|
20
|
+
return v === '1' || v === 'true';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function statefileFor(dir) {
|
|
24
|
+
return path.join(dir, STATE_BASENAME);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Walk upward from startDir looking for `.ocmux.json`. Returns the absolute
|
|
28
|
+
// file path or null. Never throws.
|
|
29
|
+
function findStatefile(startDir) {
|
|
30
|
+
if (discoveryDisabled()) return null;
|
|
31
|
+
let dir = path.resolve(startDir == null ? process.cwd() : startDir);
|
|
32
|
+
for (;;) {
|
|
33
|
+
const candidate = statefileFor(dir);
|
|
34
|
+
try {
|
|
35
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
36
|
+
} catch {
|
|
37
|
+
// not a file — keep walking upward
|
|
38
|
+
}
|
|
39
|
+
const parent = path.dirname(dir);
|
|
40
|
+
if (parent === dir) return null;
|
|
41
|
+
dir = parent;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Normalize a parsed state object (v2 or legacy) into the canonical shape.
|
|
46
|
+
// `statefile` is used as the fallback directory when `directory` is missing.
|
|
47
|
+
function normalizeState(raw, statefile) {
|
|
48
|
+
const fileDir = path.dirname(statefile);
|
|
49
|
+
const state = raw && typeof raw === 'object' ? raw : {};
|
|
50
|
+
const directory =
|
|
51
|
+
typeof state.directory === 'string' && state.directory && path.isAbsolute(state.directory)
|
|
52
|
+
? state.directory
|
|
53
|
+
: fileDir;
|
|
54
|
+
const session = typeof state.session === 'string' && state.session ? state.session : null;
|
|
55
|
+
const server =
|
|
56
|
+
(typeof state.server === 'string' && state.server) ||
|
|
57
|
+
(typeof state.url === 'string' && state.url) ||
|
|
58
|
+
null;
|
|
59
|
+
const status = typeof state.status === 'string' && state.status ? state.status : null;
|
|
60
|
+
const annotations = state.annotations && typeof state.annotations === 'object' && !Array.isArray(state.annotations)
|
|
61
|
+
? state.annotations
|
|
62
|
+
: {};
|
|
63
|
+
const broadcast = Array.isArray(state.broadcast)
|
|
64
|
+
? state.broadcast.filter(x => typeof x === 'string' && x)
|
|
65
|
+
: null;
|
|
66
|
+
return {
|
|
67
|
+
file: statefile,
|
|
68
|
+
version: typeof state.version === 'number' ? state.version : 1,
|
|
69
|
+
directory,
|
|
70
|
+
session,
|
|
71
|
+
server,
|
|
72
|
+
status,
|
|
73
|
+
annotations,
|
|
74
|
+
broadcast,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Read + normalize a state file. Returns null when the file is missing/invalid.
|
|
79
|
+
function readProjectState(statefile) {
|
|
80
|
+
if (!statefile) return null;
|
|
81
|
+
let raw = null;
|
|
82
|
+
try {
|
|
83
|
+
raw = JSON.parse(fs.readFileSync(statefile, 'utf8'));
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return normalizeState(raw, statefile);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Read the state of the project containing `startDir` (upward search).
|
|
91
|
+
function readProject(startDir) {
|
|
92
|
+
const sf = findStatefile(startDir);
|
|
93
|
+
return sf ? readProjectState(sf) : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Atomic merge-write of a v2 state file. `patch` may carry directory, session
|
|
97
|
+
// and/or server. Returns the normalized new state.
|
|
98
|
+
function writeProjectState(statefile, patch) {
|
|
99
|
+
let existing = {};
|
|
100
|
+
try {
|
|
101
|
+
existing = JSON.parse(fs.readFileSync(statefile, 'utf8'));
|
|
102
|
+
} catch {
|
|
103
|
+
// start fresh
|
|
104
|
+
}
|
|
105
|
+
const next = {
|
|
106
|
+
...existing,
|
|
107
|
+
version: 2,
|
|
108
|
+
directory:
|
|
109
|
+
patch.directory !== undefined
|
|
110
|
+
? patch.directory
|
|
111
|
+
: existing.directory || path.dirname(statefile),
|
|
112
|
+
};
|
|
113
|
+
if (patch.session !== undefined) next.session = patch.session;
|
|
114
|
+
if (patch.server !== undefined) next.server = patch.server;
|
|
115
|
+
if (patch.status !== undefined) next.status = patch.status;
|
|
116
|
+
if (patch.broadcast !== undefined) {
|
|
117
|
+
if (Array.isArray(patch.broadcast) && patch.broadcast.length >= 2) next.broadcast = [...patch.broadcast];
|
|
118
|
+
else delete next.broadcast;
|
|
119
|
+
}
|
|
120
|
+
const tmp = statefile + '.tmp';
|
|
121
|
+
fs.writeFileSync(tmp, JSON.stringify(next) + '\n');
|
|
122
|
+
fs.renameSync(tmp, statefile);
|
|
123
|
+
return normalizeState(next, statefile);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Resolve the project context for `startDir`:
|
|
127
|
+
// { statefile, state, directory, session, server }
|
|
128
|
+
// All fields are null when no state file applies, so callers fall back to their
|
|
129
|
+
// existing (pre-v2) behavior seamlessly.
|
|
130
|
+
function resolveContext({ startDir, statefile } = {}) {
|
|
131
|
+
const sf = statefile !== undefined ? statefile : findStatefile(startDir);
|
|
132
|
+
if (!sf) {
|
|
133
|
+
return { statefile: null, state: null, directory: null, session: null, server: null };
|
|
134
|
+
}
|
|
135
|
+
const state = readProjectState(sf);
|
|
136
|
+
if (!state) {
|
|
137
|
+
return { statefile: sf, state: null, directory: null, session: null, server: null };
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
statefile: sf,
|
|
141
|
+
state,
|
|
142
|
+
directory: state.directory,
|
|
143
|
+
session: state.session,
|
|
144
|
+
server: state.server,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Per-session reminders (annotations) ────────────────────────────
|
|
149
|
+
// Stored as an `annotations` map (sessionID → text) INSIDE `.ocmux.json`, so a
|
|
150
|
+
// project keeps all of its state in one file. `agentp` prepends the reminder to
|
|
151
|
+
// every prompt sent to that session. Empty text removes the entry.
|
|
152
|
+
//
|
|
153
|
+
// (Older versions used a separate `annotations.json` sidecar; it is still read
|
|
154
|
+
// for back-compat and removed on the next write.)
|
|
155
|
+
|
|
156
|
+
function annotationsPath(statefile) {
|
|
157
|
+
return path.join(path.dirname(statefile), ANNOTATIONS_BASENAME);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readStateRaw(statefile) {
|
|
161
|
+
try {
|
|
162
|
+
const raw = JSON.parse(fs.readFileSync(statefile, 'utf8'));
|
|
163
|
+
return raw && typeof raw === 'object' ? raw : {};
|
|
164
|
+
} catch {
|
|
165
|
+
return {};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function readAnnotations(statefile) {
|
|
170
|
+
const raw = readStateRaw(statefile);
|
|
171
|
+
if (raw.annotations && typeof raw.annotations === 'object' && !Array.isArray(raw.annotations)) {
|
|
172
|
+
return raw.annotations;
|
|
173
|
+
}
|
|
174
|
+
// Back-compat: legacy sidecar.
|
|
175
|
+
try {
|
|
176
|
+
const legacy = JSON.parse(fs.readFileSync(annotationsPath(statefile), 'utf8'));
|
|
177
|
+
if (legacy && typeof legacy === 'object') return legacy;
|
|
178
|
+
} catch {}
|
|
179
|
+
return {};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Returns the saved text, or null when removed.
|
|
183
|
+
function writeAnnotation(statefile, sessionId, text) {
|
|
184
|
+
const raw = readStateRaw(statefile);
|
|
185
|
+
const all = (raw.annotations && typeof raw.annotations === 'object' && !Array.isArray(raw.annotations))
|
|
186
|
+
? { ...raw.annotations }
|
|
187
|
+
: readAnnotations(statefile);
|
|
188
|
+
if (text) all[sessionId] = text;
|
|
189
|
+
else delete all[sessionId];
|
|
190
|
+
raw.annotations = all;
|
|
191
|
+
raw.version = 2;
|
|
192
|
+
if (!raw.directory) raw.directory = path.dirname(statefile);
|
|
193
|
+
const tmp = statefile + '.tmp';
|
|
194
|
+
fs.writeFileSync(tmp, JSON.stringify(raw) + '\n');
|
|
195
|
+
fs.renameSync(tmp, statefile);
|
|
196
|
+
// Drop the legacy sidecar now that the data lives in the state file.
|
|
197
|
+
try { fs.unlinkSync(annotationsPath(statefile)); } catch {}
|
|
198
|
+
return text || null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
STATE_BASENAME,
|
|
203
|
+
statefileFor,
|
|
204
|
+
findStatefile,
|
|
205
|
+
normalizeState,
|
|
206
|
+
readProjectState,
|
|
207
|
+
readProject,
|
|
208
|
+
writeProjectState,
|
|
209
|
+
resolveContext,
|
|
210
|
+
annotationsPath,
|
|
211
|
+
readAnnotations,
|
|
212
|
+
writeAnnotation,
|
|
213
|
+
};
|