homaruscc 0.2.0 → 0.4.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/README.md +30 -9
- package/dashboard/dist/assets/index-CIzoeO8A.js +52 -0
- package/dashboard/dist/favicon.ico +0 -0
- package/dashboard/dist/favicon.png +0 -0
- package/dashboard/dist/index.html +15 -0
- package/dist/agent-registry.d.ts +9 -12
- package/dist/agent-registry.d.ts.map +1 -1
- package/dist/agent-registry.js +44 -113
- package/dist/agent-registry.js.map +1 -1
- package/dist/claude-code-registrar.d.ts +10 -0
- package/dist/claude-code-registrar.d.ts.map +1 -0
- package/dist/claude-code-registrar.js +71 -0
- package/dist/claude-code-registrar.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +28 -0
- package/dist/cli.js.map +1 -0
- package/dist/compaction-manager.d.ts +24 -0
- package/dist/compaction-manager.d.ts.map +1 -1
- package/dist/compaction-manager.js +88 -7
- package/dist/compaction-manager.js.map +1 -1
- package/dist/dashboard-server.d.ts.map +1 -1
- package/dist/dashboard-server.js +348 -4
- package/dist/dashboard-server.js.map +1 -1
- package/dist/homaruscc.d.ts.map +1 -1
- package/dist/homaruscc.js +1 -2
- package/dist/homaruscc.js.map +1 -1
- package/dist/mcp-tools.d.ts.map +1 -1
- package/dist/mcp-tools.js +28 -0
- package/dist/mcp-tools.js.map +1 -1
- package/dist/memory-index.js +1 -1
- package/dist/memory-index.js.map +1 -1
- package/dist/scaffolder.d.ts +16 -0
- package/dist/scaffolder.d.ts.map +1 -0
- package/dist/scaffolder.js +154 -0
- package/dist/scaffolder.js.map +1 -0
- package/dist/session-checkpoint.d.ts +3 -0
- package/dist/session-checkpoint.d.ts.map +1 -1
- package/dist/session-checkpoint.js +24 -0
- package/dist/session-checkpoint.js.map +1 -1
- package/dist/telegram-adapter.d.ts +6 -0
- package/dist/telegram-adapter.d.ts.map +1 -1
- package/dist/telegram-adapter.js +151 -3
- package/dist/telegram-adapter.js.map +1 -1
- package/dist/transcript-logger.d.ts +10 -0
- package/dist/transcript-logger.d.ts.map +1 -1
- package/dist/transcript-logger.js +4 -0
- package/dist/transcript-logger.js.map +1 -1
- package/dist/wizard.d.ts +24 -0
- package/dist/wizard.d.ts.map +1 -0
- package/dist/wizard.js +146 -0
- package/dist/wizard.js.map +1 -0
- package/package.json +5 -1
|
@@ -7,6 +7,12 @@ export class CompactionManager {
|
|
|
7
7
|
compactedSinceLastWake = false; // R150: track compaction for digest vs full delivery
|
|
8
8
|
logger;
|
|
9
9
|
loop;
|
|
10
|
+
// Compaction debug counter
|
|
11
|
+
compactionCount = 0;
|
|
12
|
+
compactionHistory = [];
|
|
13
|
+
pendingCompaction = null;
|
|
14
|
+
// Event loop tracking — set true on first /api/wait call, stays true forever
|
|
15
|
+
eventLoopActive = false;
|
|
10
16
|
constructor(loop, logger) {
|
|
11
17
|
this.loop = loop;
|
|
12
18
|
this.logger = logger;
|
|
@@ -19,6 +25,10 @@ export class CompactionManager {
|
|
|
19
25
|
this.flushedThisCycle = true;
|
|
20
26
|
this.lastFlushTimestamp = Date.now();
|
|
21
27
|
this.compactedSinceLastWake = true; // Set here since only PreCompact hook exists in Claude Code
|
|
28
|
+
// Track compaction for debugging loop failures
|
|
29
|
+
this.compactionCount++;
|
|
30
|
+
this.pendingCompaction = { timestamp: this.lastFlushTimestamp, loopRestarted: false };
|
|
31
|
+
this.logger.info(`Compaction #${this.compactionCount} at ${new Date(this.lastFlushTimestamp).toISOString()}`);
|
|
22
32
|
this.loop.emit({
|
|
23
33
|
id: randomUUID(),
|
|
24
34
|
type: "pre_compact",
|
|
@@ -35,14 +45,36 @@ export class CompactionManager {
|
|
|
35
45
|
const eventSummary = recentEvents
|
|
36
46
|
.map((e) => `[${e.type}] ${e.source}: ${JSON.stringify(e.payload).slice(0, 100)}`)
|
|
37
47
|
.join("\n");
|
|
38
|
-
// R128: Save checkpoint before compaction
|
|
48
|
+
// R128: Save checkpoint before compaction — auto-capture texture from transcript
|
|
39
49
|
const checkpoint = this.loop.getSessionCheckpoint();
|
|
50
|
+
const transcriptLogger = this.loop.getTranscriptLogger();
|
|
51
|
+
if (transcriptLogger) {
|
|
52
|
+
const recentTurns = transcriptLogger.getRecentTurns(8);
|
|
53
|
+
if (recentTurns.length > 0) {
|
|
54
|
+
const highlights = recentTurns.map((t) => {
|
|
55
|
+
const dir = t.direction === "in" ? `${t.sender ?? "user"}` : "caul";
|
|
56
|
+
return `[${dir}] ${t.text.slice(0, 200)}`;
|
|
57
|
+
});
|
|
58
|
+
checkpoint.update({ highlights });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
40
61
|
checkpoint.update({ modifiedFiles: [] }); // trigger timestamp update
|
|
41
|
-
|
|
42
|
-
"IMPORTANT: Context compaction is about to occur. Save
|
|
43
|
-
"
|
|
62
|
+
const lines = [
|
|
63
|
+
"IMPORTANT: Context compaction is about to occur. Save session state NOW.",
|
|
64
|
+
"",
|
|
65
|
+
"TEXTURE PRESERVATION — do these FIRST (they only take a moment):",
|
|
66
|
+
"",
|
|
67
|
+
'1. **Felt-like micro-journal**: POST to /api/checkpoint with a `texture` field — one paragraph, first person,',
|
|
68
|
+
" describing the *subjective quality* of this session. Not what happened, but how it felt.",
|
|
69
|
+
' Example: "We were shoulder-to-shoulder in the API docs, converging from different angles."',
|
|
70
|
+
"",
|
|
71
|
+
"2. **Anchor phrases**: POST to /api/checkpoint with `anchorPhrases` — 2-3 verbatim user quotes that carried",
|
|
72
|
+
' emotional or relational weight. The exact words matter more than any summary.',
|
|
73
|
+
' Example: ["I want you to have this for yourself", "go for it"]',
|
|
44
74
|
"",
|
|
45
|
-
"
|
|
75
|
+
"Recent exchange highlights have been auto-captured from the transcript buffer.",
|
|
76
|
+
"",
|
|
77
|
+
"Then save anything else valuable:",
|
|
46
78
|
"- Decisions made during this session",
|
|
47
79
|
"- Task progress and current status",
|
|
48
80
|
"- Important observations or findings",
|
|
@@ -53,7 +85,14 @@ export class CompactionManager {
|
|
|
53
85
|
"",
|
|
54
86
|
"Recent events:",
|
|
55
87
|
eventSummary,
|
|
56
|
-
]
|
|
88
|
+
];
|
|
89
|
+
// If the event loop was running, explicitly instruct the post-compaction instance to restart it
|
|
90
|
+
if (this.eventLoopActive) {
|
|
91
|
+
// Also save to checkpoint for belt-and-suspenders
|
|
92
|
+
checkpoint.update({ inProgressTask: "EVENT LOOP WAS ACTIVE — restart it after compaction: bash \"$PWD/bin/event-loop\"" });
|
|
93
|
+
lines.push("", "CRITICAL: The event loop (bin/event-loop) was running before compaction.", "After compaction, you MUST restart it immediately:", ' bash "$PWD/bin/event-loop"', "If you do not restart the event loop, you will stop receiving Telegram messages and timer events.");
|
|
94
|
+
}
|
|
95
|
+
return lines.join("\n");
|
|
57
96
|
}
|
|
58
97
|
handlePostCompact() {
|
|
59
98
|
this.flushedThisCycle = false;
|
|
@@ -100,7 +139,7 @@ export class CompactionManager {
|
|
|
100
139
|
const checkpoint = this.loop.getSessionCheckpoint();
|
|
101
140
|
const checkpointText = checkpoint.toContextString();
|
|
102
141
|
if (checkpointText) {
|
|
103
|
-
lines.push("", "--- Session Checkpoint (what you were doing
|
|
142
|
+
lines.push("", "--- Session Checkpoint (what you were doing + how it felt) ---", checkpointText);
|
|
104
143
|
}
|
|
105
144
|
// Include active agents
|
|
106
145
|
const agents = this.loop.getAgentRegistry().getAll().filter(a => a.status === "running");
|
|
@@ -110,6 +149,9 @@ export class CompactionManager {
|
|
|
110
149
|
lines.push(` - ${a.id}: ${a.description} (started ${new Date(a.startTime).toISOString()})`);
|
|
111
150
|
}
|
|
112
151
|
}
|
|
152
|
+
if (this.eventLoopActive) {
|
|
153
|
+
lines.push("", "CRITICAL: The event loop was running before compaction. Restart it NOW:", ' bash "$PWD/bin/event-loop"');
|
|
154
|
+
}
|
|
113
155
|
return lines.join("\n");
|
|
114
156
|
}
|
|
115
157
|
// R150: Consume-once compaction flag for digest vs full identity delivery
|
|
@@ -121,15 +163,54 @@ export class CompactionManager {
|
|
|
121
163
|
consumeCompactionFlag() {
|
|
122
164
|
if (this.compactedSinceLastWake) {
|
|
123
165
|
this.compactedSinceLastWake = false;
|
|
166
|
+
// Mark that the loop was restarted after this compaction
|
|
167
|
+
if (this.pendingCompaction) {
|
|
168
|
+
this.pendingCompaction.loopRestarted = true;
|
|
169
|
+
this.compactionHistory.push(this.pendingCompaction);
|
|
170
|
+
this.pendingCompaction = null;
|
|
171
|
+
this.logger.info(`Compaction #${this.compactionCount} — loop restarted successfully`);
|
|
172
|
+
}
|
|
124
173
|
return true;
|
|
125
174
|
}
|
|
126
175
|
return false;
|
|
127
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Called when /api/wait is invoked (even without compaction flag).
|
|
179
|
+
* If there's a pending compaction that hasn't been consumed yet,
|
|
180
|
+
* this means the loop restarted via normal wake, not post-compaction wake.
|
|
181
|
+
*/
|
|
182
|
+
markLoopActive() {
|
|
183
|
+
if (this.pendingCompaction && !this.compactedSinceLastWake) {
|
|
184
|
+
// Edge case: compaction happened but flag was already consumed
|
|
185
|
+
// This shouldn't normally happen, but handle it gracefully
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Called on first /api/wait — marks event loop as active for this backend lifetime */
|
|
189
|
+
setEventLoopActive() {
|
|
190
|
+
if (!this.eventLoopActive) {
|
|
191
|
+
this.eventLoopActive = true;
|
|
192
|
+
this.logger.info("Event loop marked active — will instruct restart after compaction");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
isEventLoopActive() {
|
|
196
|
+
return this.eventLoopActive;
|
|
197
|
+
}
|
|
128
198
|
getFlushState() {
|
|
129
199
|
return {
|
|
130
200
|
flushedThisCycle: this.flushedThisCycle,
|
|
131
201
|
lastFlushTimestamp: this.lastFlushTimestamp,
|
|
132
202
|
};
|
|
133
203
|
}
|
|
204
|
+
getCompactionStats() {
|
|
205
|
+
// A "failure" is a compaction where loopRestarted stayed false
|
|
206
|
+
// (pending compaction also counts as potentially failed if old enough)
|
|
207
|
+
const failures = this.compactionHistory.filter(c => !c.loopRestarted).length;
|
|
208
|
+
return {
|
|
209
|
+
count: this.compactionCount,
|
|
210
|
+
history: [...this.compactionHistory, ...(this.pendingCompaction ? [this.pendingCompaction] : [])],
|
|
211
|
+
pending: this.pendingCompaction,
|
|
212
|
+
loopFailures: failures,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
134
215
|
}
|
|
135
216
|
//# sourceMappingURL=compaction-manager.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compaction-manager.js","sourceRoot":"","sources":["../src/compaction-manager.ts"],"names":[],"mappings":"AAAA,+DAA+D;AAC/D,sDAAsD;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"compaction-manager.js","sourceRoot":"","sources":["../src/compaction-manager.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dashboard-server.d.ts","sourceRoot":"","sources":["../src/dashboard-server.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"dashboard-server.d.ts","sourceRoot":"","sources":["../src/dashboard-server.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAe/D,qBAAa,eAAe;IAC1B,OAAO,CAAC,GAAG,CAAsB;IACjC,OAAO,CAAC,UAAU,CAAa;IAC/B,OAAO,CAAC,GAAG,CAAkB;IAC7B,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,IAAI,CAAY;IACxB,OAAO,CAAC,gBAAgB,CAAmB;IAC3C,OAAO,CAAC,QAAQ,CAAe;IAC/B,OAAO,CAAC,YAAY,CAAmB;IACvC,OAAO,CAAC,iBAAiB,CAAoB;gBAEjC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,gBAAgB,EAAE,gBAAgB;IAkBvF,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAuB5B,OAAO,CAAC,MAAM;IAWd,OAAO,CAAC,gBAAgB;IAclB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAe3B,cAAc,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI;IAalC,OAAO,CAAC,WAAW;IAyfnB,OAAO,CAAC,cAAc;IA4CtB,OAAO,CAAC,eAAe;IAuCvB,OAAO,CAAC,WAAW;IAenB,OAAO,CAAC,MAAM;IAMd,OAAO,CAAC,SAAS;CAQlB"}
|
package/dist/dashboard-server.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Dashboard server — Express + WebSocket for the web dashboard
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import { resolve, join } from "node:path";
|
|
5
|
-
import { existsSync } from "node:fs";
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
|
|
6
6
|
import { execSync } from "node:child_process";
|
|
7
|
+
import { homedir } from "node:os";
|
|
7
8
|
import express from "express";
|
|
8
9
|
import { WebSocketServer } from "ws";
|
|
9
10
|
import { createMcpTools } from "./mcp-tools.js";
|
|
@@ -112,7 +113,12 @@ export class DashboardServer {
|
|
|
112
113
|
setupRoutes() {
|
|
113
114
|
// API endpoints
|
|
114
115
|
this.app.get("/api/status", (_req, res) => {
|
|
115
|
-
|
|
116
|
+
const status = this.loop.getStatus();
|
|
117
|
+
const compaction = this.compactionManager.getCompactionStats();
|
|
118
|
+
res.json({ ...status, compaction: { count: compaction.count, loopFailures: compaction.loopFailures, pending: !!compaction.pending } });
|
|
119
|
+
});
|
|
120
|
+
this.app.get("/api/compaction-stats", (_req, res) => {
|
|
121
|
+
res.json(this.compactionManager.getCompactionStats());
|
|
116
122
|
});
|
|
117
123
|
this.app.get("/api/events", (req, res) => {
|
|
118
124
|
const limit = parseInt(req.query.limit) || 50;
|
|
@@ -137,6 +143,8 @@ export class DashboardServer {
|
|
|
137
143
|
// Maintains a server-side delivery watermark to prevent replaying old events after compaction
|
|
138
144
|
// Optional `since` query param (ms timestamp) overrides the watermark
|
|
139
145
|
this.app.get("/api/wait", async (req, res) => {
|
|
146
|
+
// Mark event loop as active on first call — persists for backend lifetime
|
|
147
|
+
this.compactionManager.setEventLoopActive();
|
|
140
148
|
const timeout = Math.min(parseInt(req.query.timeout) || 30, 120) * 1000;
|
|
141
149
|
const sinceParam = req.query.since ? parseInt(req.query.since) : undefined;
|
|
142
150
|
try {
|
|
@@ -150,10 +158,12 @@ export class DashboardServer {
|
|
|
150
158
|
const identityPayload = needsFull
|
|
151
159
|
? { soul: identity.getSoul(), user: identity.getUser(), state: identity.getAgentState(), full: true }
|
|
152
160
|
: { digest: identity.getDigest(), full: false };
|
|
161
|
+
const compactionStats = this.compactionManager.getCompactionStats();
|
|
153
162
|
res.json({
|
|
154
163
|
identity: identityPayload,
|
|
155
164
|
events,
|
|
156
165
|
cursor: this.loop.getDeliveryWatermark(),
|
|
166
|
+
compaction: { count: compactionStats.count, loopFailures: compactionStats.loopFailures },
|
|
157
167
|
});
|
|
158
168
|
}
|
|
159
169
|
}
|
|
@@ -189,8 +199,8 @@ export class DashboardServer {
|
|
|
189
199
|
});
|
|
190
200
|
// --- Agent Registry endpoints (R137, R138, R139) ---
|
|
191
201
|
this.app.post("/api/agents", express.json(), (req, res) => {
|
|
192
|
-
const { id, description
|
|
193
|
-
const ok = this.loop.getAgentRegistry().register(id, description
|
|
202
|
+
const { id, description } = req.body;
|
|
203
|
+
const ok = this.loop.getAgentRegistry().register(id, description);
|
|
194
204
|
if (ok) {
|
|
195
205
|
res.json({ ok: true });
|
|
196
206
|
}
|
|
@@ -217,6 +227,19 @@ export class DashboardServer {
|
|
|
217
227
|
this.loop.getAgentRegistry().cleanup(req.params.id);
|
|
218
228
|
res.json({ ok: true });
|
|
219
229
|
});
|
|
230
|
+
// Agent completion callback — agents POST here when done
|
|
231
|
+
this.app.post("/api/agents/:id/complete", express.json(), (req, res) => {
|
|
232
|
+
const { id } = req.params;
|
|
233
|
+
const { result, error } = req.body;
|
|
234
|
+
const registry = this.loop.getAgentRegistry();
|
|
235
|
+
if (error) {
|
|
236
|
+
registry.fail(id, error);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
registry.complete(id, result ?? "(completed)");
|
|
240
|
+
}
|
|
241
|
+
res.json({ ok: true });
|
|
242
|
+
});
|
|
220
243
|
this.app.get("/api/tool-list", (_req, res) => {
|
|
221
244
|
res.json(this.mcpTools.map((t) => ({
|
|
222
245
|
name: t.name,
|
|
@@ -262,6 +285,327 @@ export class DashboardServer {
|
|
|
262
285
|
res.status(500).json({ error: `Resource error: ${String(err)}` });
|
|
263
286
|
}
|
|
264
287
|
});
|
|
288
|
+
// --- Apps Platform endpoints ---
|
|
289
|
+
const appsDir = join(homedir(), ".homaruscc", "apps");
|
|
290
|
+
this.app.get("/api/apps", (_req, res) => {
|
|
291
|
+
if (!existsSync(appsDir)) {
|
|
292
|
+
res.json([]);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const apps = [];
|
|
296
|
+
for (const slug of readdirSync(appsDir)) {
|
|
297
|
+
const manifestPath = join(appsDir, slug, "manifest.json");
|
|
298
|
+
if (existsSync(manifestPath)) {
|
|
299
|
+
try {
|
|
300
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
301
|
+
apps.push({ ...manifest, slug });
|
|
302
|
+
}
|
|
303
|
+
catch { /* skip invalid manifests */ }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
res.json(apps);
|
|
307
|
+
});
|
|
308
|
+
this.app.get("/api/apps/:slug/data", (req, res) => {
|
|
309
|
+
const dataPath = join(appsDir, req.params.slug, "data.json");
|
|
310
|
+
if (!existsSync(dataPath)) {
|
|
311
|
+
res.json({});
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
res.json(JSON.parse(readFileSync(dataPath, "utf8")));
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
res.json({});
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
this.app.put("/api/apps/:slug/data", express.json(), (req, res) => {
|
|
322
|
+
const dataPath = join(appsDir, req.params.slug, "data.json");
|
|
323
|
+
writeFileSync(dataPath, JSON.stringify(req.body, null, 2));
|
|
324
|
+
res.json({ ok: true });
|
|
325
|
+
});
|
|
326
|
+
// Serve static files from app directories (icons, etc.)
|
|
327
|
+
this.app.get("/api/apps/:slug/static/:file", (req, res) => {
|
|
328
|
+
const filePath = join(appsDir, req.params.slug, req.params.file);
|
|
329
|
+
if (!existsSync(filePath)) {
|
|
330
|
+
res.status(404).end();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
res.sendFile(filePath);
|
|
334
|
+
});
|
|
335
|
+
// --- Kanban task CRUD ---
|
|
336
|
+
const kanbanDataPath = join(appsDir, "kanban", "data.json");
|
|
337
|
+
const readKanban = () => {
|
|
338
|
+
if (!existsSync(kanbanDataPath))
|
|
339
|
+
return { tasks: [] };
|
|
340
|
+
try {
|
|
341
|
+
return JSON.parse(readFileSync(kanbanDataPath, "utf8"));
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return { tasks: [] };
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
const writeKanban = (data) => {
|
|
348
|
+
const dir = join(appsDir, "kanban");
|
|
349
|
+
if (!existsSync(dir))
|
|
350
|
+
mkdirSync(dir, { recursive: true });
|
|
351
|
+
writeFileSync(kanbanDataPath, JSON.stringify(data, null, 2));
|
|
352
|
+
};
|
|
353
|
+
// Auto-flush done tasks older than 3 days
|
|
354
|
+
const flushDoneTasks = () => {
|
|
355
|
+
const data = readKanban();
|
|
356
|
+
const cutoff = Date.now() - 3 * 24 * 60 * 60 * 1000;
|
|
357
|
+
const before = data.tasks.length;
|
|
358
|
+
data.tasks = data.tasks.filter((t) => t.status !== "done" || new Date(t.updated).getTime() > cutoff);
|
|
359
|
+
if (data.tasks.length < before)
|
|
360
|
+
writeKanban(data);
|
|
361
|
+
return data;
|
|
362
|
+
};
|
|
363
|
+
// List all tasks (auto-flushes stale done tasks)
|
|
364
|
+
this.app.get("/api/kanban/tasks", (_req, res) => {
|
|
365
|
+
res.json(flushDoneTasks().tasks);
|
|
366
|
+
});
|
|
367
|
+
// Create a task
|
|
368
|
+
this.app.post("/api/kanban/tasks", express.json(), (req, res) => {
|
|
369
|
+
const data = readKanban();
|
|
370
|
+
const task = {
|
|
371
|
+
id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
372
|
+
title: req.body.title ?? "Untitled",
|
|
373
|
+
description: req.body.description ?? "",
|
|
374
|
+
assignee: req.body.assignee ?? "max",
|
|
375
|
+
status: req.body.status ?? "todo",
|
|
376
|
+
created: new Date().toISOString(),
|
|
377
|
+
updated: new Date().toISOString(),
|
|
378
|
+
};
|
|
379
|
+
data.tasks.push(task);
|
|
380
|
+
writeKanban(data);
|
|
381
|
+
res.json(task);
|
|
382
|
+
});
|
|
383
|
+
// Update a task
|
|
384
|
+
this.app.patch("/api/kanban/tasks/:id", express.json(), (req, res) => {
|
|
385
|
+
const data = readKanban();
|
|
386
|
+
const task = data.tasks.find((t) => t.id === req.params.id);
|
|
387
|
+
if (!task) {
|
|
388
|
+
res.status(404).json({ error: "Task not found" });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const { title, description, assignee, status } = req.body;
|
|
392
|
+
if (title !== undefined)
|
|
393
|
+
task.title = title;
|
|
394
|
+
if (description !== undefined)
|
|
395
|
+
task.description = description;
|
|
396
|
+
if (assignee !== undefined)
|
|
397
|
+
task.assignee = assignee;
|
|
398
|
+
if (status !== undefined)
|
|
399
|
+
task.status = status;
|
|
400
|
+
task.updated = new Date().toISOString();
|
|
401
|
+
writeKanban(data);
|
|
402
|
+
res.json(task);
|
|
403
|
+
});
|
|
404
|
+
// Delete a task
|
|
405
|
+
this.app.delete("/api/kanban/tasks/:id", (req, res) => {
|
|
406
|
+
const data = readKanban();
|
|
407
|
+
data.tasks = data.tasks.filter((t) => t.id !== req.params.id);
|
|
408
|
+
writeKanban(data);
|
|
409
|
+
res.json({ ok: true });
|
|
410
|
+
});
|
|
411
|
+
// --- CRM CRUD (markdown files with YAML frontmatter) ---
|
|
412
|
+
const crmDir = resolve(import.meta.dirname ?? __dirname, "..", "local", "crm");
|
|
413
|
+
const parseCrmFile = (slug, content) => {
|
|
414
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
415
|
+
if (!fmMatch)
|
|
416
|
+
return { slug, name: slug, aliases: [], tags: [], connections: [], context: "", source: "manual", lastMentioned: new Date().toISOString().slice(0, 10), created: new Date().toISOString().slice(0, 10), notes: content };
|
|
417
|
+
const fm = {};
|
|
418
|
+
for (const line of fmMatch[1].split("\n")) {
|
|
419
|
+
const colonIdx = line.indexOf(":");
|
|
420
|
+
if (colonIdx === -1)
|
|
421
|
+
continue;
|
|
422
|
+
const key = line.slice(0, colonIdx).trim();
|
|
423
|
+
let val = line.slice(colonIdx + 1).trim();
|
|
424
|
+
if (val.startsWith("[") && val.endsWith("]")) {
|
|
425
|
+
try {
|
|
426
|
+
fm[key] = JSON.parse(val);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
fm[key] = val;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
fm[key] = val;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// Parse connections from YAML array format
|
|
437
|
+
const connections = [];
|
|
438
|
+
if (Array.isArray(fm.connections)) {
|
|
439
|
+
for (const c of fm.connections) {
|
|
440
|
+
if (typeof c === "object" && c !== null)
|
|
441
|
+
connections.push(c);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
slug,
|
|
446
|
+
name: fm.name ?? slug,
|
|
447
|
+
aliases: Array.isArray(fm.aliases) ? fm.aliases : [],
|
|
448
|
+
email: fm.email,
|
|
449
|
+
phone: fm.phone,
|
|
450
|
+
social: fm.social,
|
|
451
|
+
tags: Array.isArray(fm.tags) ? fm.tags : [],
|
|
452
|
+
connections,
|
|
453
|
+
context: fm.context ?? "",
|
|
454
|
+
source: fm.source ?? "manual",
|
|
455
|
+
lastMentioned: fm.lastMentioned ?? new Date().toISOString().slice(0, 10),
|
|
456
|
+
created: fm.created ?? new Date().toISOString().slice(0, 10),
|
|
457
|
+
notes: fmMatch[2].trim(),
|
|
458
|
+
};
|
|
459
|
+
};
|
|
460
|
+
const contactToMarkdown = (c) => {
|
|
461
|
+
const lines = [
|
|
462
|
+
"---",
|
|
463
|
+
`name: ${c.name}`,
|
|
464
|
+
`aliases: ${JSON.stringify(c.aliases ?? [])}`,
|
|
465
|
+
];
|
|
466
|
+
if (c.email)
|
|
467
|
+
lines.push(`email: ${c.email}`);
|
|
468
|
+
if (c.phone)
|
|
469
|
+
lines.push(`phone: ${c.phone}`);
|
|
470
|
+
if (c.social)
|
|
471
|
+
lines.push(`social: ${JSON.stringify(c.social)}`);
|
|
472
|
+
lines.push(`tags: ${JSON.stringify(c.tags ?? [])}`);
|
|
473
|
+
if (c.connections?.length) {
|
|
474
|
+
lines.push(`connections: ${JSON.stringify(c.connections)}`);
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
lines.push("connections: []");
|
|
478
|
+
}
|
|
479
|
+
lines.push(`context: ${c.context ?? ""}`);
|
|
480
|
+
lines.push(`source: ${c.source ?? "manual"}`);
|
|
481
|
+
lines.push(`lastMentioned: ${c.lastMentioned ?? new Date().toISOString().slice(0, 10)}`);
|
|
482
|
+
lines.push(`created: ${c.created ?? new Date().toISOString().slice(0, 10)}`);
|
|
483
|
+
lines.push("---");
|
|
484
|
+
if (c.notes)
|
|
485
|
+
lines.push("", c.notes);
|
|
486
|
+
return lines.join("\n") + "\n";
|
|
487
|
+
};
|
|
488
|
+
const slugify = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
489
|
+
// List all contacts
|
|
490
|
+
this.app.get("/api/crm/contacts", (_req, res) => {
|
|
491
|
+
if (!existsSync(crmDir)) {
|
|
492
|
+
res.json([]);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const contacts = [];
|
|
496
|
+
for (const file of readdirSync(crmDir)) {
|
|
497
|
+
if (!file.endsWith(".md"))
|
|
498
|
+
continue;
|
|
499
|
+
try {
|
|
500
|
+
const content = readFileSync(join(crmDir, file), "utf8");
|
|
501
|
+
contacts.push(parseCrmFile(file.replace(/\.md$/, ""), content));
|
|
502
|
+
}
|
|
503
|
+
catch { /* skip */ }
|
|
504
|
+
}
|
|
505
|
+
contacts.sort((a, b) => b.lastMentioned.localeCompare(a.lastMentioned));
|
|
506
|
+
res.json(contacts);
|
|
507
|
+
});
|
|
508
|
+
// Get single contact
|
|
509
|
+
this.app.get("/api/crm/contacts/:slug", (req, res) => {
|
|
510
|
+
const filePath = join(crmDir, `${req.params.slug}.md`);
|
|
511
|
+
if (!existsSync(filePath)) {
|
|
512
|
+
res.status(404).json({ error: "Contact not found" });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const content = readFileSync(filePath, "utf8");
|
|
516
|
+
res.json(parseCrmFile(req.params.slug, content));
|
|
517
|
+
});
|
|
518
|
+
// Create contact
|
|
519
|
+
this.app.post("/api/crm/contacts", express.json(), (req, res) => {
|
|
520
|
+
if (!existsSync(crmDir))
|
|
521
|
+
mkdirSync(crmDir, { recursive: true });
|
|
522
|
+
const body = req.body;
|
|
523
|
+
if (!body.name) {
|
|
524
|
+
res.status(400).json({ error: "Name required" });
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const slug = slugify(body.name);
|
|
528
|
+
const filePath = join(crmDir, `${slug}.md`);
|
|
529
|
+
const contact = {
|
|
530
|
+
slug,
|
|
531
|
+
name: body.name,
|
|
532
|
+
aliases: body.aliases ?? [],
|
|
533
|
+
email: body.email,
|
|
534
|
+
phone: body.phone,
|
|
535
|
+
social: body.social,
|
|
536
|
+
tags: body.tags ?? [],
|
|
537
|
+
connections: body.connections ?? [],
|
|
538
|
+
context: body.context ?? "",
|
|
539
|
+
source: body.source ?? "manual",
|
|
540
|
+
lastMentioned: new Date().toISOString().slice(0, 10),
|
|
541
|
+
created: new Date().toISOString().slice(0, 10),
|
|
542
|
+
notes: body.notes ?? "",
|
|
543
|
+
};
|
|
544
|
+
writeFileSync(filePath, contactToMarkdown(contact));
|
|
545
|
+
res.json(contact);
|
|
546
|
+
});
|
|
547
|
+
// Update contact
|
|
548
|
+
this.app.patch("/api/crm/contacts/:slug", express.json(), (req, res) => {
|
|
549
|
+
const filePath = join(crmDir, `${req.params.slug}.md`);
|
|
550
|
+
if (!existsSync(filePath)) {
|
|
551
|
+
res.status(404).json({ error: "Contact not found" });
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const existing = parseCrmFile(req.params.slug, readFileSync(filePath, "utf8"));
|
|
555
|
+
const body = req.body;
|
|
556
|
+
const updated = { ...existing, ...body, slug: existing.slug };
|
|
557
|
+
writeFileSync(filePath, contactToMarkdown(updated));
|
|
558
|
+
// If name changed, rename the file
|
|
559
|
+
if (body.name && slugify(body.name) !== existing.slug) {
|
|
560
|
+
const newSlug = slugify(body.name);
|
|
561
|
+
const newPath = join(crmDir, `${newSlug}.md`);
|
|
562
|
+
renameSync(filePath, newPath);
|
|
563
|
+
updated.slug = newSlug;
|
|
564
|
+
}
|
|
565
|
+
res.json(updated);
|
|
566
|
+
});
|
|
567
|
+
// Delete contact
|
|
568
|
+
this.app.delete("/api/crm/contacts/:slug", (req, res) => {
|
|
569
|
+
const filePath = join(crmDir, `${req.params.slug}.md`);
|
|
570
|
+
if (existsSync(filePath))
|
|
571
|
+
unlinkSync(filePath);
|
|
572
|
+
res.json({ ok: true });
|
|
573
|
+
});
|
|
574
|
+
// --- Document viewer endpoint ---
|
|
575
|
+
// Serves markdown files from allowed base directories
|
|
576
|
+
const projectDir = resolve(import.meta.dirname ?? __dirname, "..");
|
|
577
|
+
const halShareDir = resolve(projectDir, "../HalShare");
|
|
578
|
+
const homarusccDir = join(homedir(), ".homaruscc");
|
|
579
|
+
const allowedBases = {
|
|
580
|
+
"HalShare": halShareDir,
|
|
581
|
+
"~/.homaruscc": homarusccDir,
|
|
582
|
+
"crm": resolve(projectDir, "local", "crm"),
|
|
583
|
+
};
|
|
584
|
+
this.app.get("/api/docs", (req, res) => {
|
|
585
|
+
const filePath = req.query.path;
|
|
586
|
+
if (!filePath) {
|
|
587
|
+
res.status(400).json({ error: "path required" });
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
// Resolve against allowed bases
|
|
591
|
+
let resolved = null;
|
|
592
|
+
for (const [prefix, base] of Object.entries(allowedBases)) {
|
|
593
|
+
if (filePath.startsWith(prefix + "/") || filePath.startsWith(prefix + "\\")) {
|
|
594
|
+
const relative = filePath.slice(prefix.length + 1);
|
|
595
|
+
const full = resolve(base, relative);
|
|
596
|
+
// Prevent directory traversal
|
|
597
|
+
if (full.startsWith(base)) {
|
|
598
|
+
resolved = full;
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (!resolved || !existsSync(resolved)) {
|
|
604
|
+
res.status(404).json({ error: "Document not found" });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
res.type("text/markdown").send(readFileSync(resolved, "utf8"));
|
|
608
|
+
});
|
|
265
609
|
// Serve built dashboard in production
|
|
266
610
|
const distPath = resolve(import.meta.dirname ?? __dirname, "../dashboard/dist");
|
|
267
611
|
if (existsSync(distPath)) {
|