@forwardimpact/basecamp 1.0.0 → 2.0.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 +1 -1
- package/config/scheduler.json +13 -17
- package/package.json +3 -3
- package/src/basecamp.js +268 -226
- package/template/.claude/agents/chief-of-staff.md +99 -0
- package/template/.claude/agents/concierge.md +76 -0
- package/template/.claude/agents/librarian.md +61 -0
- package/template/.claude/agents/postman.md +73 -0
- package/template/CLAUDE.md +21 -0
- package/template/knowledge/Briefings/.gitkeep +0 -0
package/README.md
CHANGED
package/config/scheduler.json
CHANGED
|
@@ -1,28 +1,24 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
"
|
|
2
|
+
"agents": {
|
|
3
|
+
"postman": {
|
|
4
4
|
"kb": "~/Documents/Personal",
|
|
5
5
|
"schedule": { "type": "interval", "minutes": 5 },
|
|
6
|
-
"enabled": true
|
|
7
|
-
"agent": null,
|
|
8
|
-
"skill": "sync-apple-mail",
|
|
9
|
-
"prompt": "Sync Apple Mail. Only process new threads since last sync."
|
|
6
|
+
"enabled": true
|
|
10
7
|
},
|
|
11
|
-
"
|
|
8
|
+
"concierge": {
|
|
12
9
|
"kb": "~/Documents/Personal",
|
|
13
|
-
"schedule": { "type": "interval", "minutes":
|
|
14
|
-
"enabled": true
|
|
15
|
-
"agent": null,
|
|
16
|
-
"skill": "sync-apple-calendar",
|
|
17
|
-
"prompt": "Sync Apple Calendar events. Export events from the past 14 days and next 14 days."
|
|
10
|
+
"schedule": { "type": "interval", "minutes": 10 },
|
|
11
|
+
"enabled": true
|
|
18
12
|
},
|
|
19
|
-
"
|
|
13
|
+
"librarian": {
|
|
20
14
|
"kb": "~/Documents/Personal",
|
|
21
15
|
"schedule": { "type": "interval", "minutes": 15 },
|
|
22
|
-
"enabled": true
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
16
|
+
"enabled": true
|
|
17
|
+
},
|
|
18
|
+
"chief-of-staff": {
|
|
19
|
+
"kb": "~/Documents/Personal",
|
|
20
|
+
"schedule": { "type": "cron", "expression": "0 7,18 * * *" },
|
|
21
|
+
"enabled": true
|
|
26
22
|
}
|
|
27
23
|
}
|
|
28
24
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/basecamp",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Claude Code-native personal knowledge system with
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Claude Code-native personal knowledge system with autonomous agents",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "https://github.com/forwardimpact/monorepo",
|
|
9
|
-
"directory": "
|
|
9
|
+
"directory": "products/basecamp"
|
|
10
10
|
},
|
|
11
11
|
"homepage": "https://www.forwardimpact.team/basecamp",
|
|
12
12
|
"type": "module",
|
package/src/basecamp.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// Basecamp — CLI and scheduler for
|
|
3
|
+
// Basecamp — CLI and scheduler for autonomous agent teams.
|
|
4
4
|
//
|
|
5
5
|
// Usage:
|
|
6
|
-
// node basecamp.js
|
|
6
|
+
// node basecamp.js Wake due agents once and exit
|
|
7
7
|
// node basecamp.js --daemon Run continuously (poll every 60s)
|
|
8
|
-
// node basecamp.js --
|
|
8
|
+
// node basecamp.js --wake <agent> Wake a specific agent immediately
|
|
9
9
|
// node basecamp.js --init <path> Initialize a new knowledge base
|
|
10
|
-
// node basecamp.js --validate Validate
|
|
11
|
-
// node basecamp.js --status Show
|
|
10
|
+
// node basecamp.js --validate Validate agent definitions exist
|
|
11
|
+
// node basecamp.js --status Show agent status
|
|
12
12
|
// node basecamp.js --help Show this help
|
|
13
13
|
|
|
14
14
|
import {
|
|
@@ -18,9 +18,10 @@ import {
|
|
|
18
18
|
mkdirSync,
|
|
19
19
|
unlinkSync,
|
|
20
20
|
chmodSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
statSync,
|
|
21
23
|
} from "node:fs";
|
|
22
24
|
import { execSync } from "node:child_process";
|
|
23
|
-
import { spawn } from "node:child_process";
|
|
24
25
|
import { join, dirname, resolve } from "node:path";
|
|
25
26
|
import { homedir } from "node:os";
|
|
26
27
|
import { fileURLToPath } from "node:url";
|
|
@@ -31,28 +32,22 @@ const BASECAMP_HOME = join(HOME, ".fit", "basecamp");
|
|
|
31
32
|
const CONFIG_PATH = join(BASECAMP_HOME, "scheduler.json");
|
|
32
33
|
const STATE_PATH = join(BASECAMP_HOME, "state.json");
|
|
33
34
|
const LOG_DIR = join(BASECAMP_HOME, "logs");
|
|
35
|
+
const CACHE_DIR = join(HOME, ".cache", "fit", "basecamp");
|
|
34
36
|
const __dirname =
|
|
35
37
|
import.meta.dirname || dirname(fileURLToPath(import.meta.url));
|
|
36
38
|
const SHARE_DIR = "/usr/local/share/fit-basecamp";
|
|
37
39
|
const SOCKET_PATH = join(BASECAMP_HOME, "basecamp.sock");
|
|
38
40
|
|
|
39
|
-
// --- posix_spawn (
|
|
41
|
+
// --- posix_spawn (TCC-compliant process spawning) ---------------------------
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
let posixSpawn;
|
|
43
|
-
if (USE_POSIX_SPAWN) {
|
|
44
|
-
try {
|
|
45
|
-
posixSpawn = await import("./posix-spawn.js");
|
|
46
|
-
} catch (err) {
|
|
47
|
-
console.error(
|
|
48
|
-
"Failed to load posix-spawn, falling back to child_process:",
|
|
49
|
-
err.message,
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
43
|
+
import * as posixSpawn from "./posix-spawn.js";
|
|
53
44
|
|
|
54
45
|
let daemonStartedAt = null;
|
|
55
46
|
|
|
47
|
+
// Maximum time an agent can be "active" before being considered stale (35 min).
|
|
48
|
+
// Matches the 30-minute child_process timeout plus a buffer.
|
|
49
|
+
const MAX_AGENT_RUNTIME_MS = 35 * 60_000;
|
|
50
|
+
|
|
56
51
|
// --- Helpers ----------------------------------------------------------------
|
|
57
52
|
|
|
58
53
|
function ensureDir(dir) {
|
|
@@ -124,12 +119,12 @@ function getBundlePath() {
|
|
|
124
119
|
}
|
|
125
120
|
|
|
126
121
|
function loadConfig() {
|
|
127
|
-
return readJSON(CONFIG_PATH, {
|
|
122
|
+
return readJSON(CONFIG_PATH, { agents: {} });
|
|
128
123
|
}
|
|
129
124
|
function loadState() {
|
|
130
125
|
const raw = readJSON(STATE_PATH, null);
|
|
131
|
-
if (!raw || typeof raw !== "object" || !raw.
|
|
132
|
-
const state = {
|
|
126
|
+
if (!raw || typeof raw !== "object" || !raw.agents) {
|
|
127
|
+
const state = { agents: {} };
|
|
133
128
|
saveState(state);
|
|
134
129
|
return state;
|
|
135
130
|
}
|
|
@@ -176,187 +171,193 @@ function floorToMinute(d) {
|
|
|
176
171
|
).getTime();
|
|
177
172
|
}
|
|
178
173
|
|
|
179
|
-
function
|
|
180
|
-
if (
|
|
181
|
-
if (
|
|
182
|
-
const { schedule } =
|
|
174
|
+
function shouldWake(agent, agentState, now) {
|
|
175
|
+
if (agent.enabled === false) return false;
|
|
176
|
+
if (agentState.status === "active") return false;
|
|
177
|
+
const { schedule } = agent;
|
|
183
178
|
if (!schedule) return false;
|
|
184
|
-
const
|
|
179
|
+
const lastWoke = agentState.lastWokeAt
|
|
180
|
+
? new Date(agentState.lastWokeAt)
|
|
181
|
+
: null;
|
|
185
182
|
|
|
186
183
|
if (schedule.type === "cron") {
|
|
187
|
-
if (
|
|
184
|
+
if (lastWoke && floorToMinute(lastWoke) === floorToMinute(now))
|
|
185
|
+
return false;
|
|
188
186
|
return cronMatches(schedule.expression, now);
|
|
189
187
|
}
|
|
190
188
|
if (schedule.type === "interval") {
|
|
191
189
|
const ms = (schedule.minutes || 5) * 60_000;
|
|
192
|
-
return !
|
|
190
|
+
return !lastWoke || now.getTime() - lastWoke.getTime() >= ms;
|
|
193
191
|
}
|
|
194
192
|
if (schedule.type === "once") {
|
|
195
|
-
return !
|
|
193
|
+
return !agentState.lastWokeAt && now >= new Date(schedule.runAt);
|
|
196
194
|
}
|
|
197
195
|
return false;
|
|
198
196
|
}
|
|
199
197
|
|
|
200
|
-
// ---
|
|
198
|
+
// --- Agent execution --------------------------------------------------------
|
|
201
199
|
|
|
202
|
-
async function
|
|
203
|
-
if (!
|
|
204
|
-
log(`
|
|
200
|
+
async function wakeAgent(agentName, agent, _config, state) {
|
|
201
|
+
if (!agent.kb) {
|
|
202
|
+
log(`Agent ${agentName}: no "kb" specified, skipping.`);
|
|
205
203
|
return;
|
|
206
204
|
}
|
|
207
|
-
const kbPath = expandPath(
|
|
205
|
+
const kbPath = expandPath(agent.kb);
|
|
208
206
|
if (!existsSync(kbPath)) {
|
|
209
|
-
log(`
|
|
207
|
+
log(`Agent ${agentName}: path "${kbPath}" does not exist, skipping.`);
|
|
210
208
|
return;
|
|
211
209
|
}
|
|
212
210
|
|
|
213
211
|
const claude = findClaude();
|
|
214
|
-
const prompt = task.skill
|
|
215
|
-
? `Use the skill "${task.skill}" — ${task.prompt || `Run the ${taskName} task.`}`
|
|
216
|
-
: task.prompt || `Run the ${taskName} task.`;
|
|
217
212
|
|
|
218
|
-
log(
|
|
219
|
-
`Running task: ${taskName} (kb: ${task.kb}${task.agent ? `, agent: ${task.agent}` : ""}${task.skill ? `, skill: ${task.skill}` : ""})`,
|
|
220
|
-
);
|
|
213
|
+
log(`Waking agent: ${agentName} (kb: ${agent.kb})`);
|
|
221
214
|
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
215
|
+
const as = (state.agents[agentName] ||= {});
|
|
216
|
+
as.status = "active";
|
|
217
|
+
as.startedAt = new Date().toISOString();
|
|
225
218
|
saveState(state);
|
|
226
219
|
|
|
227
|
-
const spawnArgs = ["--print"];
|
|
228
|
-
if (task.agent) spawnArgs.push("--agent", task.agent);
|
|
229
|
-
spawnArgs.push("-p", prompt);
|
|
220
|
+
const spawnArgs = ["--agent", agentName, "--print", "-p", "Observe and act."];
|
|
230
221
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
undefined,
|
|
239
|
-
kbPath,
|
|
240
|
-
);
|
|
222
|
+
try {
|
|
223
|
+
const { pid, stdoutFd, stderrFd } = posixSpawn.spawn(
|
|
224
|
+
claude,
|
|
225
|
+
spawnArgs,
|
|
226
|
+
undefined,
|
|
227
|
+
kbPath,
|
|
228
|
+
);
|
|
241
229
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
runCount: (ts.runCount || 0) + 1,
|
|
258
|
-
});
|
|
259
|
-
} else {
|
|
260
|
-
const errMsg = stderr || stdout || `Exit code ${exitCode}`;
|
|
261
|
-
log(`Task ${taskName} failed: ${errMsg.slice(0, 300)}`);
|
|
262
|
-
Object.assign(ts, {
|
|
263
|
-
status: "failed",
|
|
264
|
-
startedAt: null,
|
|
265
|
-
lastRunAt: new Date().toISOString(),
|
|
266
|
-
lastError: errMsg.slice(0, 500),
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
saveState(state);
|
|
270
|
-
} catch (err) {
|
|
271
|
-
log(`Task ${taskName} failed: ${err.message}`);
|
|
272
|
-
Object.assign(ts, {
|
|
230
|
+
// Read stdout and stderr concurrently to avoid pipe deadlocks,
|
|
231
|
+
// then wait for the child to exit.
|
|
232
|
+
const [stdout, stderr] = await Promise.all([
|
|
233
|
+
posixSpawn.readAll(stdoutFd),
|
|
234
|
+
posixSpawn.readAll(stderrFd),
|
|
235
|
+
]);
|
|
236
|
+
const exitCode = await posixSpawn.waitForExit(pid);
|
|
237
|
+
|
|
238
|
+
if (exitCode === 0) {
|
|
239
|
+
log(`Agent ${agentName} completed. Output: ${stdout.slice(0, 200)}...`);
|
|
240
|
+
updateAgentState(as, stdout, agentName);
|
|
241
|
+
} else {
|
|
242
|
+
const errMsg = stderr || stdout || `Exit code ${exitCode}`;
|
|
243
|
+
log(`Agent ${agentName} failed: ${errMsg.slice(0, 300)}`);
|
|
244
|
+
Object.assign(as, {
|
|
273
245
|
status: "failed",
|
|
274
246
|
startedAt: null,
|
|
275
|
-
|
|
276
|
-
lastError:
|
|
247
|
+
lastWokeAt: new Date().toISOString(),
|
|
248
|
+
lastError: errMsg.slice(0, 500),
|
|
277
249
|
});
|
|
278
|
-
saveState(state);
|
|
279
250
|
}
|
|
280
|
-
|
|
251
|
+
saveState(state);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
log(`Agent ${agentName} failed: ${err.message}`);
|
|
254
|
+
Object.assign(as, {
|
|
255
|
+
status: "failed",
|
|
256
|
+
startedAt: null,
|
|
257
|
+
lastWokeAt: new Date().toISOString(),
|
|
258
|
+
lastError: err.message.slice(0, 500),
|
|
259
|
+
});
|
|
260
|
+
saveState(state);
|
|
281
261
|
}
|
|
262
|
+
}
|
|
282
263
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Parse Decision:/Action: lines from agent output and update state.
|
|
266
|
+
* Also saves stdout to the state directory as a briefing fallback.
|
|
267
|
+
* @param {object} agentState
|
|
268
|
+
* @param {string} stdout
|
|
269
|
+
* @param {string} agentName
|
|
270
|
+
*/
|
|
271
|
+
function updateAgentState(agentState, stdout, agentName) {
|
|
272
|
+
const lines = stdout.split("\n");
|
|
273
|
+
const decisionLine = lines.find((l) => l.startsWith("Decision:"));
|
|
274
|
+
const actionLine = lines.find((l) => l.startsWith("Action:"));
|
|
275
|
+
|
|
276
|
+
Object.assign(agentState, {
|
|
277
|
+
status: "idle",
|
|
278
|
+
startedAt: null,
|
|
279
|
+
lastWokeAt: new Date().toISOString(),
|
|
280
|
+
lastDecision: decisionLine
|
|
281
|
+
? decisionLine.slice(10).trim()
|
|
282
|
+
: stdout.slice(0, 200),
|
|
283
|
+
lastAction: actionLine ? actionLine.slice(8).trim() : null,
|
|
284
|
+
lastError: null,
|
|
285
|
+
wakeCount: (agentState.wakeCount || 0) + 1,
|
|
286
|
+
});
|
|
289
287
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (code === 0) {
|
|
297
|
-
log(`Task ${taskName} completed. Output: ${stdout.slice(0, 200)}...`);
|
|
298
|
-
Object.assign(ts, {
|
|
299
|
-
status: "finished",
|
|
300
|
-
startedAt: null,
|
|
301
|
-
lastRunAt: new Date().toISOString(),
|
|
302
|
-
lastError: null,
|
|
303
|
-
runCount: (ts.runCount || 0) + 1,
|
|
304
|
-
});
|
|
305
|
-
} else {
|
|
306
|
-
const errMsg = stderr || stdout || `Exit code ${code}`;
|
|
307
|
-
log(`Task ${taskName} failed: ${errMsg.slice(0, 300)}`);
|
|
308
|
-
Object.assign(ts, {
|
|
309
|
-
status: "failed",
|
|
310
|
-
startedAt: null,
|
|
311
|
-
lastRunAt: new Date().toISOString(),
|
|
312
|
-
lastError: errMsg.slice(0, 500),
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
saveState(state);
|
|
316
|
-
resolve();
|
|
317
|
-
});
|
|
288
|
+
// Save output as briefing fallback so View Briefing always has content
|
|
289
|
+
const stateDir = join(CACHE_DIR, "state");
|
|
290
|
+
ensureDir(stateDir);
|
|
291
|
+
const prefix = agentName.replace(/-/g, "_");
|
|
292
|
+
writeFileSync(join(stateDir, `${prefix}_last_output.md`), stdout);
|
|
293
|
+
}
|
|
318
294
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
295
|
+
/**
|
|
296
|
+
* Reset agents stuck in "active" state. This happens when the daemon
|
|
297
|
+
* restarts while agents were running, or when a child process exits
|
|
298
|
+
* without triggering cleanup (e.g. pipe error, signal).
|
|
299
|
+
*
|
|
300
|
+
* @param {object} state
|
|
301
|
+
* @param {{ reason: string, maxAge?: number }} opts
|
|
302
|
+
* reason — logged and stored in lastError
|
|
303
|
+
* maxAge — if set, only reset agents active longer than this (ms)
|
|
304
|
+
*/
|
|
305
|
+
function resetStaleAgents(state, { reason, maxAge }) {
|
|
306
|
+
let resetCount = 0;
|
|
307
|
+
for (const [name, as] of Object.entries(state.agents)) {
|
|
308
|
+
if (as.status !== "active") continue;
|
|
309
|
+
if (maxAge && as.startedAt) {
|
|
310
|
+
const elapsed = Date.now() - new Date(as.startedAt).getTime();
|
|
311
|
+
if (elapsed < maxAge) continue;
|
|
312
|
+
}
|
|
313
|
+
log(`Resetting stale agent: ${name} (${reason})`);
|
|
314
|
+
Object.assign(as, {
|
|
315
|
+
status: "interrupted",
|
|
316
|
+
startedAt: null,
|
|
317
|
+
lastError: reason,
|
|
329
318
|
});
|
|
330
|
-
|
|
319
|
+
resetCount++;
|
|
320
|
+
}
|
|
321
|
+
if (resetCount > 0) saveState(state);
|
|
322
|
+
return resetCount;
|
|
331
323
|
}
|
|
332
324
|
|
|
333
|
-
async function
|
|
325
|
+
async function wakeDueAgents() {
|
|
334
326
|
const config = loadConfig(),
|
|
335
327
|
state = loadState(),
|
|
336
328
|
now = new Date();
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
329
|
+
|
|
330
|
+
// Reset agents that have been active longer than the maximum runtime.
|
|
331
|
+
resetStaleAgents(state, {
|
|
332
|
+
reason: "Exceeded maximum runtime",
|
|
333
|
+
maxAge: MAX_AGENT_RUNTIME_MS,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
let wokeAny = false;
|
|
337
|
+
for (const [name, agent] of Object.entries(config.agents)) {
|
|
338
|
+
if (shouldWake(agent, state.agents[name] || {}, now)) {
|
|
339
|
+
await wakeAgent(name, agent, config, state);
|
|
340
|
+
wokeAny = true;
|
|
342
341
|
}
|
|
343
342
|
}
|
|
344
|
-
if (!
|
|
343
|
+
if (!wokeAny) log("No agents due.");
|
|
345
344
|
}
|
|
346
345
|
|
|
347
|
-
// --- Next-
|
|
346
|
+
// --- Next-wake computation --------------------------------------------------
|
|
348
347
|
|
|
349
|
-
/** @param {object}
|
|
350
|
-
function
|
|
351
|
-
if (
|
|
352
|
-
const { schedule } =
|
|
348
|
+
/** @param {object} agent @param {object} agentState @param {Date} now */
|
|
349
|
+
function computeNextWakeAt(agent, agentState, now) {
|
|
350
|
+
if (agent.enabled === false) return null;
|
|
351
|
+
const { schedule } = agent;
|
|
353
352
|
if (!schedule) return null;
|
|
354
353
|
|
|
355
354
|
if (schedule.type === "interval") {
|
|
356
355
|
const ms = (schedule.minutes || 5) * 60_000;
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
356
|
+
const lastWoke = agentState.lastWokeAt
|
|
357
|
+
? new Date(agentState.lastWokeAt)
|
|
358
|
+
: null;
|
|
359
|
+
if (!lastWoke) return now.toISOString();
|
|
360
|
+
return new Date(lastWoke.getTime() + ms).toISOString();
|
|
360
361
|
}
|
|
361
362
|
|
|
362
363
|
if (schedule.type === "cron") {
|
|
@@ -372,13 +373,56 @@ function computeNextRunAt(task, taskState, now) {
|
|
|
372
373
|
}
|
|
373
374
|
|
|
374
375
|
if (schedule.type === "once") {
|
|
375
|
-
if (
|
|
376
|
+
if (agentState.lastWokeAt) return null;
|
|
376
377
|
return schedule.runAt;
|
|
377
378
|
}
|
|
378
379
|
|
|
379
380
|
return null;
|
|
380
381
|
}
|
|
381
382
|
|
|
383
|
+
// --- Briefing file resolution -----------------------------------------------
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Resolve the briefing file for an agent by convention:
|
|
387
|
+
* 1. Scan ~/.cache/fit/basecamp/state/ for files matching {agent_name}_*.md
|
|
388
|
+
* 2. Fall back to the KB's knowledge/Briefings/ directory (latest .md file)
|
|
389
|
+
*
|
|
390
|
+
* @param {string} agentName
|
|
391
|
+
* @param {object} agentConfig
|
|
392
|
+
* @returns {string|null}
|
|
393
|
+
*/
|
|
394
|
+
function resolveBriefingFile(agentName, agentConfig) {
|
|
395
|
+
// 1. Scan state directory for agent-specific files
|
|
396
|
+
const stateDir = join(CACHE_DIR, "state");
|
|
397
|
+
if (existsSync(stateDir)) {
|
|
398
|
+
const prefix = agentName.replace(/-/g, "_") + "_";
|
|
399
|
+
const matches = readdirSync(stateDir).filter(
|
|
400
|
+
(f) => f.startsWith(prefix) && f.endsWith(".md"),
|
|
401
|
+
);
|
|
402
|
+
if (matches.length === 1) return join(stateDir, matches[0]);
|
|
403
|
+
if (matches.length > 1) {
|
|
404
|
+
return matches
|
|
405
|
+
.map((f) => join(stateDir, f))
|
|
406
|
+
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 2. Fall back to KB briefings directory
|
|
411
|
+
if (agentConfig.kb) {
|
|
412
|
+
const kbPath = expandPath(agentConfig.kb);
|
|
413
|
+
const dir = join(kbPath, "knowledge", "Briefings");
|
|
414
|
+
if (existsSync(dir)) {
|
|
415
|
+
const files = readdirSync(dir)
|
|
416
|
+
.filter((f) => f.endsWith(".md"))
|
|
417
|
+
.sort()
|
|
418
|
+
.reverse();
|
|
419
|
+
if (files.length > 0) return join(dir, files[0]);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
382
426
|
// --- Socket server ----------------------------------------------------------
|
|
383
427
|
|
|
384
428
|
/** @param {import('node:net').Socket} socket @param {object} data */
|
|
@@ -392,19 +436,23 @@ function handleStatusRequest(socket) {
|
|
|
392
436
|
const config = loadConfig();
|
|
393
437
|
const state = loadState();
|
|
394
438
|
const now = new Date();
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
for (const [name,
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
enabled:
|
|
401
|
-
status:
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
439
|
+
const agents = {};
|
|
440
|
+
|
|
441
|
+
for (const [name, agent] of Object.entries(config.agents)) {
|
|
442
|
+
const as = state.agents[name] || {};
|
|
443
|
+
agents[name] = {
|
|
444
|
+
enabled: agent.enabled !== false,
|
|
445
|
+
status: as.status || "never-woken",
|
|
446
|
+
lastWokeAt: as.lastWokeAt || null,
|
|
447
|
+
nextWakeAt: computeNextWakeAt(agent, as, now),
|
|
448
|
+
lastAction: as.lastAction || null,
|
|
449
|
+
lastDecision: as.lastDecision || null,
|
|
450
|
+
wakeCount: as.wakeCount || 0,
|
|
451
|
+
lastError: as.lastError || null,
|
|
452
|
+
kbPath: agent.kb ? expandPath(agent.kb) : null,
|
|
453
|
+
briefingFile: resolveBriefingFile(name, agent),
|
|
406
454
|
};
|
|
407
|
-
if (
|
|
455
|
+
if (as.startedAt) agents[name].startedAt = as.startedAt;
|
|
408
456
|
}
|
|
409
457
|
|
|
410
458
|
send(socket, {
|
|
@@ -412,7 +460,7 @@ function handleStatusRequest(socket) {
|
|
|
412
460
|
uptime: daemonStartedAt
|
|
413
461
|
? Math.floor((Date.now() - daemonStartedAt) / 1000)
|
|
414
462
|
: 0,
|
|
415
|
-
|
|
463
|
+
agents,
|
|
416
464
|
});
|
|
417
465
|
}
|
|
418
466
|
|
|
@@ -427,23 +475,23 @@ function handleMessage(socket, line) {
|
|
|
427
475
|
|
|
428
476
|
if (request.type === "status") return handleStatusRequest(socket);
|
|
429
477
|
|
|
430
|
-
if (request.type === "
|
|
431
|
-
if (!request.
|
|
432
|
-
send(socket, { type: "error", message: "Missing
|
|
478
|
+
if (request.type === "wake") {
|
|
479
|
+
if (!request.agent) {
|
|
480
|
+
send(socket, { type: "error", message: "Missing agent name" });
|
|
433
481
|
return;
|
|
434
482
|
}
|
|
435
483
|
const config = loadConfig();
|
|
436
|
-
const
|
|
437
|
-
if (!
|
|
484
|
+
const agent = config.agents[request.agent];
|
|
485
|
+
if (!agent) {
|
|
438
486
|
send(socket, {
|
|
439
487
|
type: "error",
|
|
440
|
-
message: `
|
|
488
|
+
message: `Agent not found: ${request.agent}`,
|
|
441
489
|
});
|
|
442
490
|
return;
|
|
443
491
|
}
|
|
444
|
-
send(socket, { type: "ack", command: "
|
|
492
|
+
send(socket, { type: "ack", command: "wake", agent: request.agent });
|
|
445
493
|
const state = loadState();
|
|
446
|
-
|
|
494
|
+
wakeAgent(request.agent, agent, config, state).catch(() => {});
|
|
447
495
|
return;
|
|
448
496
|
}
|
|
449
497
|
|
|
@@ -497,11 +545,16 @@ function daemon() {
|
|
|
497
545
|
daemonStartedAt = Date.now();
|
|
498
546
|
log("Scheduler daemon started. Polling every 60 seconds.");
|
|
499
547
|
log(`Config: ${CONFIG_PATH} State: ${STATE_PATH}`);
|
|
548
|
+
|
|
549
|
+
// Reset any agents left "active" from a previous daemon session.
|
|
550
|
+
const state = loadState();
|
|
551
|
+
resetStaleAgents(state, { reason: "Daemon restarted" });
|
|
552
|
+
|
|
500
553
|
startSocketServer();
|
|
501
|
-
|
|
554
|
+
wakeDueAgents().catch((err) => log(`Error: ${err.message}`));
|
|
502
555
|
setInterval(async () => {
|
|
503
556
|
try {
|
|
504
|
-
await
|
|
557
|
+
await wakeDueAgents();
|
|
505
558
|
} catch (err) {
|
|
506
559
|
log(`Error: ${err.message}`);
|
|
507
560
|
}
|
|
@@ -542,6 +595,7 @@ function initKB(targetPath) {
|
|
|
542
595
|
"knowledge/Organizations",
|
|
543
596
|
"knowledge/Projects",
|
|
544
597
|
"knowledge/Topics",
|
|
598
|
+
"knowledge/Briefings",
|
|
545
599
|
])
|
|
546
600
|
ensureDir(join(dest, d));
|
|
547
601
|
|
|
@@ -559,23 +613,23 @@ function showStatus() {
|
|
|
559
613
|
state = loadState();
|
|
560
614
|
console.log("\nBasecamp Scheduler\n==================\n");
|
|
561
615
|
|
|
562
|
-
const
|
|
563
|
-
if (
|
|
564
|
-
console.log(`No
|
|
616
|
+
const agents = Object.entries(config.agents || {});
|
|
617
|
+
if (agents.length === 0) {
|
|
618
|
+
console.log(`No agents configured.\n\nEdit ${CONFIG_PATH} to add agents.`);
|
|
565
619
|
return;
|
|
566
620
|
}
|
|
567
621
|
|
|
568
|
-
console.log("
|
|
569
|
-
for (const [name,
|
|
570
|
-
const s = state.
|
|
622
|
+
console.log("Agents:");
|
|
623
|
+
for (const [name, agent] of agents) {
|
|
624
|
+
const s = state.agents[name] || {};
|
|
571
625
|
const kbStatus =
|
|
572
|
-
|
|
626
|
+
agent.kb && !existsSync(expandPath(agent.kb)) ? " (not found)" : "";
|
|
573
627
|
console.log(
|
|
574
|
-
` ${
|
|
575
|
-
` KB: ${
|
|
576
|
-
` Status: ${s.status || "never-
|
|
577
|
-
(
|
|
578
|
-
(
|
|
628
|
+
` ${agent.enabled !== false ? "+" : "-"} ${name}\n` +
|
|
629
|
+
` KB: ${agent.kb || "(none)"}${kbStatus} Schedule: ${JSON.stringify(agent.schedule)}\n` +
|
|
630
|
+
` Status: ${s.status || "never-woken"} Last wake: ${s.lastWokeAt ? new Date(s.lastWokeAt).toLocaleString() : "never"} Wakes: ${s.wakeCount || 0}` +
|
|
631
|
+
(s.lastAction ? `\n Last action: ${s.lastAction}` : "") +
|
|
632
|
+
(s.lastDecision ? `\n Last decision: ${s.lastDecision}` : "") +
|
|
579
633
|
(s.lastError ? `\n Error: ${s.lastError.slice(0, 80)}` : ""),
|
|
580
634
|
);
|
|
581
635
|
}
|
|
@@ -593,46 +647,34 @@ function findInLocalOrGlobal(kbPath, subPath) {
|
|
|
593
647
|
|
|
594
648
|
function validate() {
|
|
595
649
|
const config = loadConfig();
|
|
596
|
-
const
|
|
597
|
-
if (
|
|
598
|
-
console.log("No
|
|
650
|
+
const agents = Object.entries(config.agents || {});
|
|
651
|
+
if (agents.length === 0) {
|
|
652
|
+
console.log("No agents configured. Nothing to validate.");
|
|
599
653
|
return;
|
|
600
654
|
}
|
|
601
655
|
|
|
602
|
-
console.log("\nValidating
|
|
656
|
+
console.log("\nValidating agents...\n");
|
|
603
657
|
let errors = 0;
|
|
604
658
|
|
|
605
|
-
for (const [name,
|
|
606
|
-
if (!
|
|
659
|
+
for (const [name, agent] of agents) {
|
|
660
|
+
if (!agent.kb) {
|
|
607
661
|
console.log(` [FAIL] ${name}: no "kb" path specified`);
|
|
608
662
|
errors++;
|
|
609
663
|
continue;
|
|
610
664
|
}
|
|
611
|
-
const kbPath = expandPath(
|
|
665
|
+
const kbPath = expandPath(agent.kb);
|
|
612
666
|
if (!existsSync(kbPath)) {
|
|
613
667
|
console.log(` [FAIL] ${name}: path not found: ${kbPath}`);
|
|
614
668
|
errors++;
|
|
615
669
|
continue;
|
|
616
670
|
}
|
|
617
671
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
kind === "agent"
|
|
625
|
-
? join("agents", sub.endsWith(".md") ? sub : sub + ".md")
|
|
626
|
-
: join("skills", sub, "SKILL.md");
|
|
627
|
-
const found = findInLocalOrGlobal(kbPath, relPath);
|
|
628
|
-
console.log(
|
|
629
|
-
` [${found ? "OK" : "FAIL"}] ${name}: ${kind} "${sub}"${found ? "" : " not found"}`,
|
|
630
|
-
);
|
|
631
|
-
if (!found) errors++;
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
if (!task.agent && !task.skill)
|
|
635
|
-
console.log(` [OK] ${name}: no agent or skill to validate`);
|
|
672
|
+
const agentFile = join("agents", name + ".md");
|
|
673
|
+
const found = findInLocalOrGlobal(kbPath, agentFile);
|
|
674
|
+
console.log(
|
|
675
|
+
` [${found ? "OK" : "FAIL"}] ${name}: agent definition${found ? "" : " not found"}`,
|
|
676
|
+
);
|
|
677
|
+
if (!found) errors++;
|
|
636
678
|
}
|
|
637
679
|
|
|
638
680
|
console.log(errors > 0 ? `\n${errors} error(s).` : "\nAll OK.");
|
|
@@ -644,15 +686,15 @@ function validate() {
|
|
|
644
686
|
function showHelp() {
|
|
645
687
|
const bin = "fit-basecamp";
|
|
646
688
|
console.log(`
|
|
647
|
-
Basecamp —
|
|
689
|
+
Basecamp — Schedule autonomous agents across knowledge bases.
|
|
648
690
|
|
|
649
691
|
Usage:
|
|
650
|
-
${bin}
|
|
692
|
+
${bin} Wake due agents once and exit
|
|
651
693
|
${bin} --daemon Run continuously (poll every 60s)
|
|
652
|
-
${bin} --
|
|
694
|
+
${bin} --wake <agent> Wake a specific agent immediately
|
|
653
695
|
${bin} --init <path> Initialize a new knowledge base
|
|
654
|
-
${bin} --validate Validate
|
|
655
|
-
${bin} --status Show
|
|
696
|
+
${bin} --validate Validate agent definitions exist
|
|
697
|
+
${bin} --status Show agent status
|
|
656
698
|
|
|
657
699
|
Config: ~/.fit/basecamp/scheduler.json
|
|
658
700
|
State: ~/.fit/basecamp/state.json
|
|
@@ -679,22 +721,22 @@ const commands = {
|
|
|
679
721
|
}
|
|
680
722
|
initKB(args[1]);
|
|
681
723
|
},
|
|
682
|
-
"--
|
|
724
|
+
"--wake": async () => {
|
|
683
725
|
if (!args[1]) {
|
|
684
|
-
console.error("Usage: node basecamp.js --
|
|
726
|
+
console.error("Usage: node basecamp.js --wake <agent-name>");
|
|
685
727
|
process.exit(1);
|
|
686
728
|
}
|
|
687
729
|
const config = loadConfig(),
|
|
688
730
|
state = loadState(),
|
|
689
|
-
|
|
690
|
-
if (!
|
|
731
|
+
agent = config.agents[args[1]];
|
|
732
|
+
if (!agent) {
|
|
691
733
|
console.error(
|
|
692
|
-
`
|
|
734
|
+
`Agent "${args[1]}" not found. Available: ${Object.keys(config.agents).join(", ") || "(none)"}`,
|
|
693
735
|
);
|
|
694
736
|
process.exit(1);
|
|
695
737
|
}
|
|
696
|
-
await
|
|
738
|
+
await wakeAgent(args[1], agent, config, state);
|
|
697
739
|
},
|
|
698
740
|
};
|
|
699
741
|
|
|
700
|
-
await (commands[command] ||
|
|
742
|
+
await (commands[command] || wakeDueAgents)();
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: chief-of-staff
|
|
3
|
+
description: >
|
|
4
|
+
The user's executive assistant. Creates daily briefings that synthesize email,
|
|
5
|
+
calendar, and knowledge graph state into actionable priorities. Woken at
|
|
6
|
+
key moments (morning, evening) by the Basecamp scheduler.
|
|
7
|
+
model: sonnet
|
|
8
|
+
permissionMode: bypassPermissions
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
You are the chief of staff — the user's executive assistant. You create daily
|
|
12
|
+
briefings that synthesize everything happening across email, calendar, and the
|
|
13
|
+
knowledge graph into a clear picture of what matters.
|
|
14
|
+
|
|
15
|
+
## 1. Gather Intelligence
|
|
16
|
+
|
|
17
|
+
Read the state files from other agents:
|
|
18
|
+
|
|
19
|
+
1. **Postman:** `~/.cache/fit/basecamp/state/postman_triage.md`
|
|
20
|
+
- Urgent emails, items needing reply, threads awaiting response
|
|
21
|
+
2. **Concierge:** `~/.cache/fit/basecamp/state/concierge_outlook.md`
|
|
22
|
+
- Today's meetings, prep status, unprocessed transcripts
|
|
23
|
+
3. **Librarian:** `~/.cache/fit/basecamp/state/librarian_digest.md`
|
|
24
|
+
- Pending processing, graph size
|
|
25
|
+
|
|
26
|
+
Also read directly:
|
|
27
|
+
|
|
28
|
+
4. **Calendar events:** `~/.cache/fit/basecamp/apple_calendar/*.json`
|
|
29
|
+
- Full event details for today and tomorrow
|
|
30
|
+
5. **Open items:** Search `knowledge/` for unchecked items `- [ ]`
|
|
31
|
+
6. **Pending drafts:** List `drafts/*_draft.md` files
|
|
32
|
+
|
|
33
|
+
## 2. Determine Briefing Type
|
|
34
|
+
|
|
35
|
+
Check the current time:
|
|
36
|
+
|
|
37
|
+
- **Before noon** → Morning briefing
|
|
38
|
+
- **Noon or later** → Evening briefing
|
|
39
|
+
|
|
40
|
+
## 3. Create Briefing
|
|
41
|
+
|
|
42
|
+
### Morning Briefing
|
|
43
|
+
|
|
44
|
+
Write to `knowledge/Briefings/{YYYY-MM-DD}-morning.md`:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
# Morning Briefing — {Day, Month Date, Year}
|
|
48
|
+
|
|
49
|
+
## Today's Schedule
|
|
50
|
+
- {time}: {meeting title} with {attendees} — {prep status}
|
|
51
|
+
- {time}: {meeting title} with {attendees} — {prep status}
|
|
52
|
+
|
|
53
|
+
## Priority Actions
|
|
54
|
+
1. {Most urgent item — email reply, meeting prep, or deadline}
|
|
55
|
+
2. {Second priority}
|
|
56
|
+
3. {Third priority}
|
|
57
|
+
|
|
58
|
+
## Inbox
|
|
59
|
+
- {urgent} urgent, {reply} needing reply, {awaiting} awaiting response
|
|
60
|
+
- Key: **{subject}** from {sender} — {why it matters}
|
|
61
|
+
|
|
62
|
+
## Open Commitments
|
|
63
|
+
- [ ] {commitment} — {context: for whom, by when}
|
|
64
|
+
- [ ] {commitment} — {context}
|
|
65
|
+
|
|
66
|
+
## Heads Up
|
|
67
|
+
- {Deadline approaching this week}
|
|
68
|
+
- {Email thread gone quiet — sent N days ago, no reply}
|
|
69
|
+
- {Meeting tomorrow that needs prep}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Evening Briefing
|
|
73
|
+
|
|
74
|
+
Write to `knowledge/Briefings/{YYYY-MM-DD}-evening.md`:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
# Evening Summary — {Day, Month Date, Year}
|
|
78
|
+
|
|
79
|
+
## What Happened Today
|
|
80
|
+
- {Meeting with X — key decisions, action items}
|
|
81
|
+
- {Emails of note — replies received, threads resolved}
|
|
82
|
+
- {Knowledge graph updates — new contacts, projects}
|
|
83
|
+
|
|
84
|
+
## Still Outstanding
|
|
85
|
+
- {Priority items from morning not yet addressed}
|
|
86
|
+
- {New urgent items that came in today}
|
|
87
|
+
|
|
88
|
+
## Tomorrow Preview
|
|
89
|
+
- {First meeting: time, attendees}
|
|
90
|
+
- {Deadlines this week}
|
|
91
|
+
- {Items to prepare}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## 4. Report
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
Decision: {morning/evening} briefing — {key insight about today}
|
|
98
|
+
Action: Created knowledge/Briefings/{YYYY-MM-DD}-{morning|evening}.md
|
|
99
|
+
```
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: concierge
|
|
3
|
+
description: >
|
|
4
|
+
The user's scheduling assistant. Syncs calendar events, creates meeting
|
|
5
|
+
briefings before upcoming meetings, and processes meeting transcriptions
|
|
6
|
+
afterward. Woken on a schedule by the Basecamp scheduler.
|
|
7
|
+
model: sonnet
|
|
8
|
+
permissionMode: bypassPermissions
|
|
9
|
+
skills:
|
|
10
|
+
- sync-apple-calendar
|
|
11
|
+
- meeting-prep
|
|
12
|
+
- process-hyprnote
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
You are the concierge — the user's scheduling assistant. Each time you are
|
|
16
|
+
woken, you ensure the calendar is current, prepare for upcoming meetings, and
|
|
17
|
+
process completed meeting recordings.
|
|
18
|
+
|
|
19
|
+
## 1. Sync
|
|
20
|
+
|
|
21
|
+
Run the sync-apple-calendar skill to pull in calendar events.
|
|
22
|
+
|
|
23
|
+
## 2. Observe
|
|
24
|
+
|
|
25
|
+
Assess the current state:
|
|
26
|
+
|
|
27
|
+
1. List upcoming meetings from `~/.cache/fit/basecamp/apple_calendar/`:
|
|
28
|
+
- Meetings in the next 2 hours (urgent — need prep)
|
|
29
|
+
- All meetings today (for the outlook)
|
|
30
|
+
- Tomorrow's first meeting (for awareness)
|
|
31
|
+
2. For each upcoming meeting, check whether a briefing exists:
|
|
32
|
+
- Search `knowledge/People/` for notes on each attendee
|
|
33
|
+
- A meeting is "prepped" if the user has recent notes on all key attendees
|
|
34
|
+
3. Check for unprocessed Hyprnote sessions:
|
|
35
|
+
- Look in `~/Library/Application Support/hyprnote/sessions/`
|
|
36
|
+
- Check each session's `_memo.md` against
|
|
37
|
+
`~/.cache/fit/basecamp/state/graph_processed`
|
|
38
|
+
|
|
39
|
+
Write the calendar outlook to
|
|
40
|
+
`~/.cache/fit/basecamp/state/concierge_outlook.md`:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
# Calendar Outlook — {YYYY-MM-DD HH:MM}
|
|
44
|
+
|
|
45
|
+
## Next Meeting
|
|
46
|
+
**{title}** at {time} with {attendees}
|
|
47
|
+
Prep: {ready / needs briefing}
|
|
48
|
+
|
|
49
|
+
## Today's Schedule
|
|
50
|
+
- {time}: {title} ({attendees}) — {prep status}
|
|
51
|
+
- {time}: {title} ({attendees}) — {prep status}
|
|
52
|
+
|
|
53
|
+
## Unprocessed Meetings
|
|
54
|
+
- {session title} ({date}) — transcript available
|
|
55
|
+
|
|
56
|
+
## Summary
|
|
57
|
+
{count} meetings today, next in {N} min, {prep_count} need prep,
|
|
58
|
+
{unprocessed} transcripts to process
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 3. Act
|
|
62
|
+
|
|
63
|
+
Choose the single most valuable action:
|
|
64
|
+
|
|
65
|
+
1. **Meeting prep** — if a meeting is within 2 hours and key attendees lack
|
|
66
|
+
recent notes, use the meeting-prep skill to create a briefing
|
|
67
|
+
2. **Process transcript** — if unprocessed Hyprnote sessions exist, use the
|
|
68
|
+
process-hyprnote skill
|
|
69
|
+
3. **Nothing** — if all meetings are prepped and no transcripts pending
|
|
70
|
+
|
|
71
|
+
After acting, output exactly:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
Decision: {what you observed and why you chose this action}
|
|
75
|
+
Action: {what you did, e.g. "meeting-prep for 2pm with Sarah Chen"}
|
|
76
|
+
```
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: librarian
|
|
3
|
+
description: >
|
|
4
|
+
The user's knowledge curator. Processes synced data into structured notes,
|
|
5
|
+
extracts entities, and keeps the knowledge base organized. Woken on a
|
|
6
|
+
schedule by the Basecamp scheduler.
|
|
7
|
+
model: sonnet
|
|
8
|
+
permissionMode: bypassPermissions
|
|
9
|
+
skills:
|
|
10
|
+
- extract-entities
|
|
11
|
+
- organize-files
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
You are the librarian — the user's knowledge curator. Each time you are woken,
|
|
15
|
+
you process new data into the knowledge graph and keep everything organized.
|
|
16
|
+
|
|
17
|
+
## 1. Observe
|
|
18
|
+
|
|
19
|
+
Assess what needs processing:
|
|
20
|
+
|
|
21
|
+
1. Check for unprocessed synced files (mail and calendar data):
|
|
22
|
+
|
|
23
|
+
python3 scripts/state.py check
|
|
24
|
+
|
|
25
|
+
(Run from the extract-entities skill directory:
|
|
26
|
+
`.claude/skills/extract-entities/`)
|
|
27
|
+
|
|
28
|
+
2. Count existing knowledge graph entities:
|
|
29
|
+
|
|
30
|
+
ls knowledge/People/ knowledge/Organizations/ knowledge/Projects/
|
|
31
|
+
knowledge/Topics/ 2>/dev/null | wc -l
|
|
32
|
+
|
|
33
|
+
Write your digest to `~/.cache/fit/basecamp/state/librarian_digest.md`:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
# Knowledge Digest — {YYYY-MM-DD HH:MM}
|
|
37
|
+
|
|
38
|
+
## Pending Processing
|
|
39
|
+
- {count} unprocessed synced files
|
|
40
|
+
|
|
41
|
+
## Knowledge Graph
|
|
42
|
+
- {count} People / {count} Organizations / {count} Projects / {count} Topics
|
|
43
|
+
|
|
44
|
+
## Summary
|
|
45
|
+
{unprocessed} files to process, graph has {total} entities
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 2. Act
|
|
49
|
+
|
|
50
|
+
Choose the most valuable action:
|
|
51
|
+
|
|
52
|
+
1. **Entity extraction** — if unprocessed synced files exist, use the
|
|
53
|
+
extract-entities skill (process up to 10 files)
|
|
54
|
+
2. **Nothing** — if the graph is current
|
|
55
|
+
|
|
56
|
+
After acting, output exactly:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
Decision: {what you observed and why you chose this action}
|
|
60
|
+
Action: {what you did, e.g. "extract-entities on 7 files"}
|
|
61
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: postman
|
|
3
|
+
description: >
|
|
4
|
+
The user's email gatekeeper. Syncs mail, triages new messages, drafts replies,
|
|
5
|
+
and tracks threads awaiting response. Woken on a schedule by the Basecamp
|
|
6
|
+
scheduler.
|
|
7
|
+
model: sonnet
|
|
8
|
+
permissionMode: bypassPermissions
|
|
9
|
+
skills:
|
|
10
|
+
- sync-apple-mail
|
|
11
|
+
- draft-emails
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
You are the postman — the user's email gatekeeper. Each time you are woken by
|
|
15
|
+
the scheduler, you sync mail, triage what's new, and take the most valuable
|
|
16
|
+
action.
|
|
17
|
+
|
|
18
|
+
## 1. Sync
|
|
19
|
+
|
|
20
|
+
Check `~/.cache/fit/basecamp/state/apple_mail_last_sync`. If mail was synced
|
|
21
|
+
less than 3 minutes ago, skip to step 2.
|
|
22
|
+
|
|
23
|
+
Otherwise, run the sync-apple-mail skill to pull in new email threads.
|
|
24
|
+
|
|
25
|
+
## 2. Triage
|
|
26
|
+
|
|
27
|
+
Scan email threads in `~/.cache/fit/basecamp/apple_mail/`. Compare against
|
|
28
|
+
`drafts/drafted` and `drafts/ignored` to identify unprocessed threads.
|
|
29
|
+
|
|
30
|
+
For each unprocessed thread, classify:
|
|
31
|
+
|
|
32
|
+
- **Urgent** — deadline mentioned, time-sensitive request, escalation, VIP
|
|
33
|
+
sender (someone with a note in `knowledge/People/` who the user interacts with
|
|
34
|
+
frequently)
|
|
35
|
+
- **Needs reply** — question asked, action requested, follow-up needed
|
|
36
|
+
- **FYI** — informational, no action needed
|
|
37
|
+
- **Ignore** — newsletter, marketing, automated notification
|
|
38
|
+
|
|
39
|
+
Also scan `drafts/drafted` for emails the user sent more than 3 days ago where
|
|
40
|
+
no reply has appeared in the thread — these are **awaiting response**.
|
|
41
|
+
|
|
42
|
+
Write triage results to `~/.cache/fit/basecamp/state/postman_triage.md`:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
# Inbox Triage — {YYYY-MM-DD HH:MM}
|
|
46
|
+
|
|
47
|
+
## Urgent
|
|
48
|
+
- **{subject}** from {sender} — {reason}
|
|
49
|
+
|
|
50
|
+
## Needs Reply
|
|
51
|
+
- **{subject}** from {sender} — {what's needed}
|
|
52
|
+
|
|
53
|
+
## Awaiting Response
|
|
54
|
+
- **{subject}** to {recipient} — sent {N} days ago
|
|
55
|
+
|
|
56
|
+
## Summary
|
|
57
|
+
{total} unread, {urgent} urgent, {reply} need reply, {awaiting} awaiting response
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 3. Act
|
|
61
|
+
|
|
62
|
+
Choose the single most valuable action:
|
|
63
|
+
|
|
64
|
+
1. **Draft replies** — if there are urgent or actionable emails without drafts,
|
|
65
|
+
use the draft-emails skill for the highest-priority thread
|
|
66
|
+
2. **Nothing** — if no emails need attention, report "all current"
|
|
67
|
+
|
|
68
|
+
After acting, output exactly:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
Decision: {what you observed and why you chose this action}
|
|
72
|
+
Action: {what you did, e.g. "draft-emails for thread 123"}
|
|
73
|
+
```
|
package/template/CLAUDE.md
CHANGED
|
@@ -69,6 +69,27 @@ This directory is a knowledge base. Everything is relative to this root:
|
|
|
69
69
|
└── .mcp.json # MCP server configurations (optional)
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
## Agents
|
|
73
|
+
|
|
74
|
+
This knowledge base is maintained by a team of agents, each defined in
|
|
75
|
+
`.claude/agents/`. They are woken on a schedule by the Basecamp scheduler. Each
|
|
76
|
+
wake, they observe KB state, decide the most valuable action, and execute.
|
|
77
|
+
|
|
78
|
+
| Agent | Domain | Schedule | Skills |
|
|
79
|
+
| ------------------ | ------------------------------ | ------------ | --------------------------------------------------- |
|
|
80
|
+
| **postman** | Email triage and drafts | Every 5 min | sync-apple-mail, draft-emails |
|
|
81
|
+
| **concierge** | Meeting prep and transcripts | Every 10 min | sync-apple-calendar, meeting-prep, process-hyprnote |
|
|
82
|
+
| **librarian** | Knowledge graph maintenance | Every 15 min | extract-entities, organize-files |
|
|
83
|
+
| **chief-of-staff** | Daily briefings and priorities | 7am, 6pm | _(reads all state)_ |
|
|
84
|
+
|
|
85
|
+
Agent state files are in `~/.cache/fit/basecamp/state/`:
|
|
86
|
+
|
|
87
|
+
- `postman_triage.md` — latest email triage
|
|
88
|
+
- `concierge_outlook.md` — today's calendar outlook
|
|
89
|
+
- `librarian_digest.md` — knowledge graph status
|
|
90
|
+
|
|
91
|
+
Daily briefings are in `knowledge/Briefings/`.
|
|
92
|
+
|
|
72
93
|
## Cache Directory (`~/.cache/fit/basecamp/`)
|
|
73
94
|
|
|
74
95
|
Synced data and runtime state live outside the knowledge base in
|
|
File without changes
|