u-foo 1.8.9 → 1.9.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/bin/uclaude.js +12 -2
- package/bin/ucodex.js +12 -2
- package/bin/ufoo.js +1 -1
- package/package.json +1 -1
- package/src/agent/defaultBootstrap.js +144 -0
- package/src/agent/launcher.js +45 -31
- package/src/agent/ufooAgent.js +1 -2
- package/src/bus/daemon.js +26 -1
- package/src/bus/index.js +23 -3
- package/src/chat/commandExecutor.js +103 -4
- package/src/chat/commands.js +20 -2
- package/src/chat/completionController.js +43 -4
- package/src/chat/daemonReconnect.js +6 -5
- package/src/chat/daemonTransport.js +7 -1
- package/src/chat/index.js +13 -5
- package/src/cli.js +191 -0
- package/src/daemon/groupOrchestrator.js +7 -0
- package/src/daemon/index.js +64 -39
- package/src/daemon/promptRequest.js +1 -1
- package/src/group/bootstrap.js +14 -0
- package/src/history/inputTimeline.js +601 -0
- package/src/{globalMode.js → projects/identity.js} +1 -1
- package/src/projects/index.js +11 -0
- package/src/solo/commands.js +31 -0
- package/src/ufoo/paths.js +2 -0
- package/templates/groups/build-ultra.json +8 -2
- /package/src/{chat/projectRuntimes.js → projects/runtimes.js} +0 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { getUfooPaths } = require("../ufoo/paths");
|
|
7
|
+
const { loadAgentsData } = require("../ufoo/agentsStore");
|
|
8
|
+
|
|
9
|
+
const HISTORY_DEBUG = process.env.UFOO_HISTORY_DEBUG === "1";
|
|
10
|
+
const debugLog = (...args) => { if (HISTORY_DEBUG) console.error("[history]", ...args); };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Input Timeline — aggregates all agent inputs into a unified chat-like history.
|
|
14
|
+
*
|
|
15
|
+
* Sources:
|
|
16
|
+
* 1. Bus events (message/targeted) — inter-agent messages, appended in real-time
|
|
17
|
+
* 2. Claude Code session JSONL — manual user inputs, synced by daemon every ~30s
|
|
18
|
+
* 3. Codex session rollout files — manual user inputs, synced by daemon every ~30s
|
|
19
|
+
*
|
|
20
|
+
* Incremental builds use a watermark file tracking:
|
|
21
|
+
* - busLastSeq: last processed bus event seq number
|
|
22
|
+
* - lastTs: last processed timestamp (used to skip session file records + mtime filter)
|
|
23
|
+
* - entryCount: maintained count (avoids full-scan just to count)
|
|
24
|
+
* - builtAt: when last build ran
|
|
25
|
+
*
|
|
26
|
+
* Output format (JSONL per entry):
|
|
27
|
+
* {
|
|
28
|
+
* ts: ISO timestamp,
|
|
29
|
+
* source: "bus" | "manual",
|
|
30
|
+
* from: display label (nickname or "user"),
|
|
31
|
+
* fromId: subscriber ID or "user",
|
|
32
|
+
* to: display label,
|
|
33
|
+
* toId: subscriber ID or "",
|
|
34
|
+
* message: string
|
|
35
|
+
* }
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Paths (all derived from getUfooPaths to avoid hardcoding)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function getHistoryDir(projectRoot) {
|
|
43
|
+
return getUfooPaths(projectRoot).historyDir;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getTimelineFile(projectRoot) {
|
|
47
|
+
return path.join(getHistoryDir(projectRoot), "input-timeline.jsonl");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getWatermarkFile(projectRoot) {
|
|
51
|
+
return path.join(getHistoryDir(projectRoot), "watermark.json");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Watermark
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
const WATERMARK_LOCK_STALE_MS = 10000;
|
|
59
|
+
|
|
60
|
+
function readWatermark(projectRoot) {
|
|
61
|
+
try {
|
|
62
|
+
const file = getWatermarkFile(projectRoot);
|
|
63
|
+
if (fs.existsSync(file)) {
|
|
64
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// corrupted — treat as fresh
|
|
68
|
+
}
|
|
69
|
+
return { busLastSeq: 0, lastTs: "", entryCount: 0 };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Synchronous non-blocking file lock for watermark writes.
|
|
74
|
+
* Returns lock handle on success, null if lock is held (caller skips update).
|
|
75
|
+
*/
|
|
76
|
+
function acquireWatermarkLock(projectRoot) {
|
|
77
|
+
const lockFile = path.join(getHistoryDir(projectRoot), "watermark.lock");
|
|
78
|
+
try {
|
|
79
|
+
const fd = fs.openSync(lockFile, "wx");
|
|
80
|
+
fs.writeSync(fd, `${process.pid}\n`);
|
|
81
|
+
return { fd, lockFile };
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err && err.code === "EEXIST") {
|
|
84
|
+
try {
|
|
85
|
+
const stat = fs.statSync(lockFile);
|
|
86
|
+
if (Date.now() - stat.mtimeMs > WATERMARK_LOCK_STALE_MS) {
|
|
87
|
+
fs.unlinkSync(lockFile);
|
|
88
|
+
const fd = fs.openSync(lockFile, "wx");
|
|
89
|
+
fs.writeSync(fd, `${process.pid}\n`);
|
|
90
|
+
return { fd, lockFile };
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// give up
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function releaseWatermarkLock(lock) {
|
|
101
|
+
if (!lock) return;
|
|
102
|
+
try { fs.closeSync(lock.fd); } catch { /* ignore */ }
|
|
103
|
+
try { fs.unlinkSync(lock.lockFile); } catch { /* ignore */ }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function writeWatermark(projectRoot, watermark) {
|
|
107
|
+
fs.mkdirSync(getHistoryDir(projectRoot), { recursive: true });
|
|
108
|
+
fs.writeFileSync(getWatermarkFile(projectRoot), JSON.stringify(watermark, null, 2) + "\n", "utf8");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// JSONL helpers
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Stream-parse a JSONL file line by line.
|
|
117
|
+
* Calls fn(record) for each valid line; stops early if fn returns false.
|
|
118
|
+
*/
|
|
119
|
+
function streamJSONL(filePath, fn) {
|
|
120
|
+
if (!fs.existsSync(filePath)) return;
|
|
121
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
122
|
+
let start = 0;
|
|
123
|
+
while (start < raw.length) {
|
|
124
|
+
let end = raw.indexOf("\n", start);
|
|
125
|
+
if (end === -1) end = raw.length;
|
|
126
|
+
const line = raw.slice(start, end).trim();
|
|
127
|
+
start = end + 1;
|
|
128
|
+
if (!line) continue;
|
|
129
|
+
try {
|
|
130
|
+
const record = JSON.parse(line);
|
|
131
|
+
if (fn(record) === false) return;
|
|
132
|
+
} catch {
|
|
133
|
+
// skip malformed
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read the last N records from a JSONL file (tail-read, avoids full load).
|
|
140
|
+
*/
|
|
141
|
+
function readTailJSONL(filePath, limit = 50) {
|
|
142
|
+
if (!fs.existsSync(filePath)) return [];
|
|
143
|
+
const stat = fs.statSync(filePath);
|
|
144
|
+
if (stat.size === 0) return [];
|
|
145
|
+
|
|
146
|
+
if (stat.size < 512 * 1024) {
|
|
147
|
+
const results = [];
|
|
148
|
+
streamJSONL(filePath, (r) => { results.push(r); });
|
|
149
|
+
return results.slice(-limit);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const chunkSize = Math.min(stat.size, limit * 2048);
|
|
153
|
+
const buf = Buffer.alloc(chunkSize);
|
|
154
|
+
const fd = fs.openSync(filePath, "r");
|
|
155
|
+
try {
|
|
156
|
+
const offset = Math.max(0, stat.size - chunkSize);
|
|
157
|
+
fs.readSync(fd, buf, 0, chunkSize, offset);
|
|
158
|
+
const lines = buf.toString("utf8").split(/\r?\n/).filter(Boolean);
|
|
159
|
+
const startIdx = offset > 0 ? 1 : 0; // skip possible partial first line
|
|
160
|
+
const results = [];
|
|
161
|
+
for (let i = startIdx; i < lines.length; i++) {
|
|
162
|
+
try { results.push(JSON.parse(lines[i])); } catch { /* skip */ }
|
|
163
|
+
}
|
|
164
|
+
return results.slice(-limit);
|
|
165
|
+
} finally {
|
|
166
|
+
fs.closeSync(fd);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Lookups
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
function buildNicknameLookup(projectRoot) {
|
|
175
|
+
const data = loadAgentsData(getUfooPaths(projectRoot).agentsFile);
|
|
176
|
+
const lookup = new Map();
|
|
177
|
+
for (const [id, meta] of Object.entries(data.agents || {})) {
|
|
178
|
+
if (meta && meta.nickname) lookup.set(id, meta.nickname);
|
|
179
|
+
}
|
|
180
|
+
return lookup;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildSessionLookup(projectRoot) {
|
|
184
|
+
const data = loadAgentsData(getUfooPaths(projectRoot).agentsFile);
|
|
185
|
+
const lookup = new Map();
|
|
186
|
+
for (const [id, meta] of Object.entries(data.agents || {})) {
|
|
187
|
+
if (meta && meta.provider_session_id) {
|
|
188
|
+
lookup.set(meta.provider_session_id, {
|
|
189
|
+
subscriberId: id,
|
|
190
|
+
nickname: meta.nickname || id,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return lookup;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Derive the Claude projects directory for this project root.
|
|
199
|
+
* Claude stores sessions at: ~/.claude/projects/<path-with-dashes>/<sessionId>.jsonl
|
|
200
|
+
*/
|
|
201
|
+
function getClaudeProjectDir(projectRoot) {
|
|
202
|
+
const slug = path.resolve(projectRoot).replace(/\//g, "-");
|
|
203
|
+
return path.join(os.homedir(), ".claude", "projects", slug);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Text extraction helpers
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
function extractUserText(record) {
|
|
211
|
+
const msg = record.message;
|
|
212
|
+
if (!msg || typeof msg !== "object") return "";
|
|
213
|
+
const content = msg.content;
|
|
214
|
+
if (typeof content === "string") return content.replace(/<[^>]+>/g, "").trim();
|
|
215
|
+
if (Array.isArray(content)) {
|
|
216
|
+
return content
|
|
217
|
+
.map((c) => (typeof c === "string" ? c : c && c.text ? c.text : ""))
|
|
218
|
+
.join("")
|
|
219
|
+
.replace(/<[^>]+>/g, "")
|
|
220
|
+
.trim();
|
|
221
|
+
}
|
|
222
|
+
return "";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isProbeMarker(text) {
|
|
226
|
+
return /^\/ufoo\s+\S+$/.test(text) || /^\$ufoo\s+\S+$/.test(text);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Collectors
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Collect new bus events since watermark.busLastSeq.
|
|
235
|
+
* Skips event files whose date is strictly before the watermark date.
|
|
236
|
+
*/
|
|
237
|
+
function collectBusMessages(projectRoot, watermark = {}) {
|
|
238
|
+
const eventsDir = getUfooPaths(projectRoot).busEventsDir;
|
|
239
|
+
if (!fs.existsSync(eventsDir)) return { entries: [], maxSeq: watermark.busLastSeq || 0 };
|
|
240
|
+
|
|
241
|
+
const minSeq = watermark.busLastSeq || 0;
|
|
242
|
+
const nicknames = buildNicknameLookup(projectRoot);
|
|
243
|
+
const entries = [];
|
|
244
|
+
let maxSeq = minSeq;
|
|
245
|
+
const watermarkDate = watermark.lastTs ? watermark.lastTs.slice(0, 10) : "";
|
|
246
|
+
|
|
247
|
+
const files = fs.readdirSync(eventsDir).filter((f) => f.endsWith(".jsonl")).sort();
|
|
248
|
+
for (const file of files) {
|
|
249
|
+
if (watermarkDate && file < `${watermarkDate}.jsonl`) continue;
|
|
250
|
+
streamJSONL(path.join(eventsDir, file), (evt) => {
|
|
251
|
+
if (!evt.seq || evt.seq <= minSeq) return;
|
|
252
|
+
if (evt.type !== "message/targeted" || evt.event !== "message") return;
|
|
253
|
+
if (!evt.data || !evt.data.message) return;
|
|
254
|
+
if (evt.seq > maxSeq) maxSeq = evt.seq;
|
|
255
|
+
entries.push({
|
|
256
|
+
ts: evt.timestamp,
|
|
257
|
+
source: "bus",
|
|
258
|
+
from: nicknames.get(evt.publisher) || evt.publisher,
|
|
259
|
+
fromId: evt.publisher,
|
|
260
|
+
to: nicknames.get(evt.target) || evt.target,
|
|
261
|
+
toId: evt.target,
|
|
262
|
+
message: evt.data.message,
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { entries, maxSeq };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Collect new manual user inputs from Claude Code session files.
|
|
272
|
+
* Uses mtime to skip unmodified files; within modified files filters by timestamp.
|
|
273
|
+
*/
|
|
274
|
+
function collectClaudeManualInputs(projectRoot, watermark = {}) {
|
|
275
|
+
const claudeProjectDir = getClaudeProjectDir(projectRoot);
|
|
276
|
+
if (!fs.existsSync(claudeProjectDir)) return [];
|
|
277
|
+
|
|
278
|
+
const sessionLookup = buildSessionLookup(projectRoot);
|
|
279
|
+
const entries = [];
|
|
280
|
+
const cutoffMs = watermark.lastTs ? new Date(watermark.lastTs).getTime() : 0;
|
|
281
|
+
|
|
282
|
+
const sessionToAgent = new Map();
|
|
283
|
+
for (const [sessionId, info] of sessionLookup) {
|
|
284
|
+
if (info.subscriberId.startsWith("claude-code:")) {
|
|
285
|
+
sessionToAgent.set(sessionId, info);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let sessionFiles;
|
|
290
|
+
if (sessionToAgent.size > 0) {
|
|
291
|
+
sessionFiles = [];
|
|
292
|
+
for (const sessionId of sessionToAgent.keys()) {
|
|
293
|
+
const filePath = path.join(claudeProjectDir, `${sessionId}.jsonl`);
|
|
294
|
+
if (fs.existsSync(filePath)) sessionFiles.push({ filePath, sessionId });
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
try {
|
|
298
|
+
sessionFiles = fs.readdirSync(claudeProjectDir)
|
|
299
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
300
|
+
.map((f) => ({ filePath: path.join(claudeProjectDir, f), sessionId: f.replace(".jsonl", "") }));
|
|
301
|
+
} catch {
|
|
302
|
+
return entries;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
for (const { filePath, sessionId } of sessionFiles) {
|
|
307
|
+
if (cutoffMs > 0) {
|
|
308
|
+
try {
|
|
309
|
+
if (fs.statSync(filePath).mtimeMs <= cutoffMs) continue;
|
|
310
|
+
} catch { continue; }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const agent = sessionToAgent.get(sessionId);
|
|
314
|
+
const toLabel = agent ? agent.nickname : `session:${sessionId.slice(0, 8)}`;
|
|
315
|
+
const toId = agent ? agent.subscriberId : "";
|
|
316
|
+
|
|
317
|
+
streamJSONL(filePath, (record) => {
|
|
318
|
+
if (record.type !== "user") return;
|
|
319
|
+
if (cutoffMs > 0 && record.timestamp) {
|
|
320
|
+
if (new Date(record.timestamp).getTime() <= cutoffMs) return;
|
|
321
|
+
}
|
|
322
|
+
const text = extractUserText(record);
|
|
323
|
+
if (!text || isProbeMarker(text)) return;
|
|
324
|
+
entries.push({
|
|
325
|
+
ts: record.timestamp || "",
|
|
326
|
+
source: "manual",
|
|
327
|
+
from: "user",
|
|
328
|
+
fromId: "user",
|
|
329
|
+
to: toLabel,
|
|
330
|
+
toId,
|
|
331
|
+
message: text,
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return entries;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Collect new manual user inputs from Codex session rollouts.
|
|
341
|
+
* Skips date directories older than watermark date; skips files by mtime.
|
|
342
|
+
*/
|
|
343
|
+
function collectCodexManualInputs(projectRoot, watermark = {}) {
|
|
344
|
+
const sessionLookup = buildSessionLookup(projectRoot);
|
|
345
|
+
if (sessionLookup.size === 0) return [];
|
|
346
|
+
|
|
347
|
+
const entries = [];
|
|
348
|
+
const sessionsBase = path.join(os.homedir(), ".codex", "sessions");
|
|
349
|
+
if (!fs.existsSync(sessionsBase)) return entries;
|
|
350
|
+
|
|
351
|
+
const cutoffMs = watermark.lastTs ? new Date(watermark.lastTs).getTime() : 0;
|
|
352
|
+
const cutoffDate = watermark.lastTs ? watermark.lastTs.slice(0, 10) : "";
|
|
353
|
+
|
|
354
|
+
const codexSessions = new Map();
|
|
355
|
+
for (const [sessionId, info] of sessionLookup) {
|
|
356
|
+
if (info.subscriberId.startsWith("codex:")) codexSessions.set(sessionId, info);
|
|
357
|
+
}
|
|
358
|
+
if (codexSessions.size === 0) return entries;
|
|
359
|
+
|
|
360
|
+
let years;
|
|
361
|
+
try { years = fs.readdirSync(sessionsBase).filter((d) => /^\d{4}$/.test(d)); } catch { return entries; }
|
|
362
|
+
|
|
363
|
+
for (const y of years) {
|
|
364
|
+
if (cutoffDate && y < cutoffDate.slice(0, 4)) continue;
|
|
365
|
+
const yDir = path.join(sessionsBase, y);
|
|
366
|
+
let months;
|
|
367
|
+
try { months = fs.readdirSync(yDir).filter((d) => /^\d{2}$/.test(d)); } catch { continue; }
|
|
368
|
+
for (const m of months) {
|
|
369
|
+
if (cutoffDate && `${y}-${m}` < cutoffDate.slice(0, 7)) continue;
|
|
370
|
+
const mDir = path.join(yDir, m);
|
|
371
|
+
let days;
|
|
372
|
+
try { days = fs.readdirSync(mDir).filter((d) => /^\d{2}$/.test(d)); } catch { continue; }
|
|
373
|
+
for (const d of days) {
|
|
374
|
+
if (cutoffDate && `${y}-${m}-${d}` < cutoffDate) continue;
|
|
375
|
+
const dDir = path.join(mDir, d);
|
|
376
|
+
let files;
|
|
377
|
+
try {
|
|
378
|
+
files = fs.readdirSync(dDir).filter((f) => f.startsWith("rollout-") && f.endsWith(".jsonl"));
|
|
379
|
+
} catch { continue; }
|
|
380
|
+
|
|
381
|
+
for (const file of files) {
|
|
382
|
+
const filePath = path.join(dDir, file);
|
|
383
|
+
if (cutoffMs > 0) {
|
|
384
|
+
try { if (fs.statSync(filePath).mtimeMs <= cutoffMs) continue; } catch { continue; }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let sessionId = "";
|
|
388
|
+
streamJSONL(filePath, (rec) => {
|
|
389
|
+
if (rec.type === "session_meta" && rec.payload?.id) {
|
|
390
|
+
sessionId = rec.payload.id;
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const agent = codexSessions.get(sessionId);
|
|
396
|
+
if (!agent) continue;
|
|
397
|
+
|
|
398
|
+
streamJSONL(filePath, (rec) => {
|
|
399
|
+
if (rec.type !== "message" || rec.role !== "user") return;
|
|
400
|
+
if (cutoffMs > 0 && rec.timestamp) {
|
|
401
|
+
if (new Date(rec.timestamp).getTime() <= cutoffMs) return;
|
|
402
|
+
}
|
|
403
|
+
const content = typeof rec.content === "string"
|
|
404
|
+
? rec.content
|
|
405
|
+
: Array.isArray(rec.content)
|
|
406
|
+
? rec.content.map((c) => c.text || "").join("")
|
|
407
|
+
: "";
|
|
408
|
+
if (!content) return;
|
|
409
|
+
entries.push({
|
|
410
|
+
ts: rec.timestamp ? new Date(rec.timestamp).toISOString() : new Date().toISOString(),
|
|
411
|
+
source: "manual",
|
|
412
|
+
from: "user",
|
|
413
|
+
fromId: "user",
|
|
414
|
+
to: agent.nickname,
|
|
415
|
+
toId: agent.subscriberId,
|
|
416
|
+
message: content,
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return entries;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// Real-time append (called from EventBus.send)
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Append a single bus message to the timeline immediately on send.
|
|
433
|
+
* Uses file lock to safely advance the watermark; if lock is contended,
|
|
434
|
+
* skips the watermark update (next build will catch up — no data lost).
|
|
435
|
+
*/
|
|
436
|
+
function appendBusEntry(projectRoot, { seq, timestamp, publisher, target, message, nicknames = null }) {
|
|
437
|
+
try {
|
|
438
|
+
fs.mkdirSync(getHistoryDir(projectRoot), { recursive: true });
|
|
439
|
+
const timelineFile = getTimelineFile(projectRoot);
|
|
440
|
+
|
|
441
|
+
const nicknameMap = nicknames || buildNicknameLookup(projectRoot);
|
|
442
|
+
const entry = {
|
|
443
|
+
ts: timestamp,
|
|
444
|
+
source: "bus",
|
|
445
|
+
from: nicknameMap.get(publisher) || publisher,
|
|
446
|
+
fromId: publisher,
|
|
447
|
+
to: nicknameMap.get(target) || target,
|
|
448
|
+
toId: target,
|
|
449
|
+
message,
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
fs.appendFileSync(timelineFile, JSON.stringify(entry) + "\n", "utf8");
|
|
453
|
+
|
|
454
|
+
if (seq) {
|
|
455
|
+
const lock = acquireWatermarkLock(projectRoot);
|
|
456
|
+
if (lock) {
|
|
457
|
+
try {
|
|
458
|
+
const watermark = readWatermark(projectRoot);
|
|
459
|
+
if (seq > (watermark.busLastSeq || 0)) {
|
|
460
|
+
watermark.busLastSeq = seq;
|
|
461
|
+
if (timestamp && (!watermark.lastTs || timestamp > watermark.lastTs)) {
|
|
462
|
+
watermark.lastTs = timestamp;
|
|
463
|
+
}
|
|
464
|
+
watermark.entryCount = (watermark.entryCount || 0) + 1;
|
|
465
|
+
writeWatermark(projectRoot, watermark);
|
|
466
|
+
}
|
|
467
|
+
} finally {
|
|
468
|
+
releaseWatermarkLock(lock);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// lock contended → watermark update skipped; next build will reprocess
|
|
472
|
+
}
|
|
473
|
+
} catch (err) {
|
|
474
|
+
debugLog("appendBusEntry failed:", err.message);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
// Incremental build
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Build the timeline incrementally (or fully with force=true).
|
|
484
|
+
* Reads watermark → collects only new entries → appends → updates watermark.
|
|
485
|
+
* entryCount is maintained in the watermark to avoid full-file counting.
|
|
486
|
+
*/
|
|
487
|
+
function buildTimeline(projectRoot, { force = false } = {}) {
|
|
488
|
+
fs.mkdirSync(getHistoryDir(projectRoot), { recursive: true });
|
|
489
|
+
const timelineFile = getTimelineFile(projectRoot);
|
|
490
|
+
|
|
491
|
+
const watermark = force ? { busLastSeq: 0, lastTs: "", entryCount: 0 } : readWatermark(projectRoot);
|
|
492
|
+
|
|
493
|
+
const busResult = collectBusMessages(projectRoot, watermark);
|
|
494
|
+
const claudeEntries = collectClaudeManualInputs(projectRoot, watermark);
|
|
495
|
+
const codexEntries = collectCodexManualInputs(projectRoot, watermark);
|
|
496
|
+
|
|
497
|
+
const newEntries = [...busResult.entries, ...claudeEntries, ...codexEntries];
|
|
498
|
+
newEntries.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
|
|
499
|
+
|
|
500
|
+
if (newEntries.length === 0 && !force) {
|
|
501
|
+
return { count: watermark.entryCount || 0, newCount: 0, file: timelineFile };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const lock = acquireWatermarkLock(projectRoot);
|
|
505
|
+
try {
|
|
506
|
+
if (force) {
|
|
507
|
+
const content = newEntries.map((e) => JSON.stringify(e)).join("\n") + (newEntries.length > 0 ? "\n" : "");
|
|
508
|
+
fs.writeFileSync(timelineFile, content, "utf8");
|
|
509
|
+
} else {
|
|
510
|
+
fs.appendFileSync(timelineFile, newEntries.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf8");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const prevCount = force ? 0 : (watermark.entryCount || 0);
|
|
514
|
+
const lastEntry = newEntries[newEntries.length - 1];
|
|
515
|
+
const newWatermark = {
|
|
516
|
+
busLastSeq: busResult.maxSeq,
|
|
517
|
+
lastTs: lastEntry ? lastEntry.ts : watermark.lastTs,
|
|
518
|
+
entryCount: prevCount + newEntries.length,
|
|
519
|
+
builtAt: new Date().toISOString(),
|
|
520
|
+
};
|
|
521
|
+
writeWatermark(projectRoot, newWatermark);
|
|
522
|
+
return { count: newWatermark.entryCount, newCount: newEntries.length, file: timelineFile };
|
|
523
|
+
} catch (err) {
|
|
524
|
+
debugLog("buildTimeline failed:", err.message);
|
|
525
|
+
throw err;
|
|
526
|
+
} finally {
|
|
527
|
+
releaseWatermarkLock(lock);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
// Read / format / render
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
function readTimeline(projectRoot, limit = 50) {
|
|
536
|
+
return readTailJSONL(getTimelineFile(projectRoot), limit);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function formatEntry(entry) {
|
|
540
|
+
if (entry.source === "bus") {
|
|
541
|
+
const label = entry.fromId && entry.fromId !== entry.from
|
|
542
|
+
? `${entry.fromId}(${entry.from})` : entry.from;
|
|
543
|
+
return `[ufoo]<from:${label}> ${entry.message}`;
|
|
544
|
+
}
|
|
545
|
+
// manual: focus on who received it, not who sent (always user)
|
|
546
|
+
const toLabel = entry.toId && entry.toId !== entry.to
|
|
547
|
+
? `${entry.toId}(${entry.to})` : entry.to;
|
|
548
|
+
return `[manual]<to:${toLabel}> ${entry.message}`;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function renderTimelineForPrompt(projectRoot, limit = 30) {
|
|
552
|
+
const entries = readTimeline(projectRoot, limit);
|
|
553
|
+
if (entries.length === 0) return "";
|
|
554
|
+
|
|
555
|
+
const lines = entries.map((entry) => {
|
|
556
|
+
const time = entry.ts ? entry.ts.slice(0, 16).replace("T", " ") : "?";
|
|
557
|
+
const prefix = entry.source === "bus"
|
|
558
|
+
? `[ufoo]<from:${entry.from}>`
|
|
559
|
+
: `[manual]<to:${entry.to}>`;
|
|
560
|
+
const msg = entry.message.length > 200 ? entry.message.slice(0, 200) + "..." : entry.message;
|
|
561
|
+
return `${time} ${prefix} ${msg}`;
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
return [
|
|
565
|
+
"## Team Activity (recent agent inputs)",
|
|
566
|
+
"",
|
|
567
|
+
"This shows recent prompts sent to agents. Use it to understand what each agent is working on.",
|
|
568
|
+
"",
|
|
569
|
+
...lines,
|
|
570
|
+
].join("\n");
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function showTimeline(projectRoot, limit = 50) {
|
|
574
|
+
const entries = readTimeline(projectRoot, limit);
|
|
575
|
+
if (entries.length === 0) {
|
|
576
|
+
console.log("No timeline entries found. Run `ufoo history build` first.");
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
console.log(`=== Input Timeline (${entries.length} entries) ===\n`);
|
|
580
|
+
for (const entry of entries) {
|
|
581
|
+
const time = entry.ts ? entry.ts.slice(0, 19).replace("T", " ") : "?";
|
|
582
|
+
console.log(`[${time}] ${formatEntry(entry)}`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
module.exports = {
|
|
587
|
+
getHistoryDir,
|
|
588
|
+
getTimelineFile,
|
|
589
|
+
getWatermarkFile,
|
|
590
|
+
buildTimeline,
|
|
591
|
+
appendBusEntry,
|
|
592
|
+
readTimeline,
|
|
593
|
+
readWatermark,
|
|
594
|
+
formatEntry,
|
|
595
|
+
renderTimelineForPrompt,
|
|
596
|
+
showTimeline,
|
|
597
|
+
getClaudeProjectDir,
|
|
598
|
+
collectBusMessages,
|
|
599
|
+
collectClaudeManualInputs,
|
|
600
|
+
collectCodexManualInputs,
|
|
601
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const os = require("os");
|
|
2
2
|
const path = require("path");
|
|
3
|
-
const { canonicalProjectRoot, trimTrailingSlashes } = require("./
|
|
3
|
+
const { canonicalProjectRoot, trimTrailingSlashes } = require("./projectId");
|
|
4
4
|
|
|
5
5
|
function normalizeProjectRoot(projectRoot) {
|
|
6
6
|
const input = String(projectRoot || "").trim();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Projects module: unified identity + registry + runtimes interface
|
|
2
|
+
module.exports = {
|
|
3
|
+
// Identity functions (path canonicalization, global mode detection)
|
|
4
|
+
...require("./identity"),
|
|
5
|
+
// Project ID generation
|
|
6
|
+
...require("./projectId"),
|
|
7
|
+
// Project registry (CRUD runtime state)
|
|
8
|
+
...require("./registry"),
|
|
9
|
+
// Project runtimes utilities (filtering, sorting, formatting)
|
|
10
|
+
...require("./runtimes"),
|
|
11
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
function asTrimmedString(value) {
|
|
4
|
+
return typeof value === "string" ? value.trim() : "";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function resolveSoloAgentType(config = {}, requestedAgent = "") {
|
|
8
|
+
const requested = asTrimmedString(requestedAgent).toLowerCase();
|
|
9
|
+
if (requested === "claude" || requested === "uclaude" || requested === "claude-code") return "claude";
|
|
10
|
+
if (requested === "codex" || requested === "ucodex" || requested === "openai") return "codex";
|
|
11
|
+
if (requested === "ucode" || requested === "ufoo" || requested === "ufoo-code") return "ucode";
|
|
12
|
+
|
|
13
|
+
const provider = asTrimmedString(config && config.agentProvider).toLowerCase();
|
|
14
|
+
if (provider === "claude-cli") return "claude";
|
|
15
|
+
if (provider === "ucode") return "ucode";
|
|
16
|
+
return "codex";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildPromptProfileCandidates(registry = null) {
|
|
20
|
+
const profiles = Array.isArray(registry && registry.profiles) ? registry.profiles : [];
|
|
21
|
+
return profiles.map((item) => ({
|
|
22
|
+
cmd: item.id,
|
|
23
|
+
desc: [item.summary || "", item.source || ""].filter(Boolean).join(" · "),
|
|
24
|
+
source: item.source || "",
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = {
|
|
29
|
+
resolveSoloAgentType,
|
|
30
|
+
buildPromptProfileCandidates,
|
|
31
|
+
};
|
package/src/ufoo/paths.js
CHANGED
|
@@ -18,6 +18,7 @@ function getUfooPaths(projectRoot) {
|
|
|
18
18
|
|
|
19
19
|
const runDir = path.join(ufooDir, "run");
|
|
20
20
|
const groupsDir = path.join(ufooDir, "groups");
|
|
21
|
+
const historyDir = path.join(ufooDir, "history");
|
|
21
22
|
const ufooDaemonPid = path.join(runDir, "ufoo-daemon.pid");
|
|
22
23
|
const ufooDaemonLog = path.join(runDir, "ufoo-daemon.log");
|
|
23
24
|
const ufooSock = path.join(runDir, "ufoo.sock");
|
|
@@ -37,6 +38,7 @@ function getUfooPaths(projectRoot) {
|
|
|
37
38
|
busDaemonCountsDir,
|
|
38
39
|
runDir,
|
|
39
40
|
groupsDir,
|
|
41
|
+
historyDir,
|
|
40
42
|
ufooDaemonPid,
|
|
41
43
|
ufooDaemonLog,
|
|
42
44
|
ufooSock,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"defaults": {
|
|
9
9
|
"launch_mode": "auto",
|
|
10
|
-
"start_timeout_ms":
|
|
10
|
+
"start_timeout_ms": 30000
|
|
11
11
|
},
|
|
12
12
|
"agents": [
|
|
13
13
|
{
|
|
@@ -16,7 +16,13 @@
|
|
|
16
16
|
"type": "auto",
|
|
17
17
|
"role": "coordinate builders, track progress, enforce delivery cadence",
|
|
18
18
|
"prompt_profile": "pmo-coordinator",
|
|
19
|
-
"accept_from": [
|
|
19
|
+
"accept_from": [
|
|
20
|
+
"builder-1",
|
|
21
|
+
"builder-2",
|
|
22
|
+
"builder-3",
|
|
23
|
+
"builder-4",
|
|
24
|
+
"reviewer"
|
|
25
|
+
],
|
|
20
26
|
"report_to": [
|
|
21
27
|
"builder-1",
|
|
22
28
|
"builder-2",
|
|
File without changes
|