@forwardimpact/outpost 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +15 -0
- package/bin/fit-outpost.js +3 -0
- package/config/scheduler.json +34 -0
- package/package.json +63 -0
- package/src/agent-runner.js +189 -0
- package/src/index.js +5 -0
- package/src/kb-manager.js +228 -0
- package/src/outpost.js +417 -0
- package/src/scheduler.js +195 -0
- package/src/socket-server.js +336 -0
- package/src/state-manager.js +126 -0
- package/templates/.claude/agents/chief-of-staff.md +58 -0
- package/templates/.claude/agents/concierge.md +46 -0
- package/templates/.claude/agents/head-hunter.md +57 -0
- package/templates/.claude/agents/librarian.md +60 -0
- package/templates/.claude/agents/postman.md +47 -0
- package/templates/.claude/agents/recruiter.md +63 -0
- package/templates/.claude/settings.json +88 -0
- package/templates/.claude/skills/candidate-report/SKILL.md +139 -0
- package/templates/.claude/skills/candidate-report/references/report-template.html +203 -0
- package/templates/.claude/skills/candidate-report/references/report.css +438 -0
- package/templates/.claude/skills/candidate-report/references/rubric.md +76 -0
- package/templates/.claude/skills/candidate-report/scripts/render-pdf.mjs +48 -0
- package/templates/.claude/skills/deck-create/SKILL.md +75 -0
- package/templates/.claude/skills/deck-create/references/slide.css +35 -0
- package/templates/.claude/skills/deck-create/scripts/convert-to-pdf.mjs +47 -0
- package/templates/.claude/skills/deck-summarize/SKILL.md +139 -0
- package/templates/.claude/skills/deck-summarize/references/brief-template.md +85 -0
- package/templates/.claude/skills/deck-summarize/scripts/extract-pptx.mjs +210 -0
- package/templates/.claude/skills/doc-collab/SKILL.md +112 -0
- package/templates/.claude/skills/doc-create/SKILL.md +105 -0
- package/templates/.claude/skills/doc-create/scripts/convert-to-pdf.mjs +50 -0
- package/templates/.claude/skills/draft-emails/SKILL.md +141 -0
- package/templates/.claude/skills/draft-emails/references/template.md +26 -0
- package/templates/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
- package/templates/.claude/skills/draft-emails/scripts/send-email.mjs +139 -0
- package/templates/.claude/skills/extract-entities/SKILL.md +187 -0
- package/templates/.claude/skills/extract-entities/references/TEMPLATES.md +14 -0
- package/templates/.claude/skills/extract-entities/references/conditions.md +55 -0
- package/templates/.claude/skills/extract-entities/references/content.md +104 -0
- package/templates/.claude/skills/extract-entities/references/links.md +48 -0
- package/templates/.claude/skills/extract-entities/references/recruitment.md +57 -0
- package/templates/.claude/skills/extract-entities/references/resolution.md +71 -0
- package/templates/.claude/skills/extract-entities/references/sources.md +70 -0
- package/templates/.claude/skills/extract-entities/references/templates-conditions.md +36 -0
- package/templates/.claude/skills/extract-entities/references/templates-goals-priorities.md +63 -0
- package/templates/.claude/skills/extract-entities/references/templates-people-orgs.md +67 -0
- package/templates/.claude/skills/extract-entities/references/templates-projects-topics.md +68 -0
- package/templates/.claude/skills/extract-entities/scripts/state.mjs +131 -0
- package/templates/.claude/skills/hyprnote-follow/SKILL.md +174 -0
- package/templates/.claude/skills/hyprnote-follow/references/coaching.md +118 -0
- package/templates/.claude/skills/hyprnote-follow/references/debrief.md +59 -0
- package/templates/.claude/skills/hyprnote-follow/references/meeting-types.md +14 -0
- package/templates/.claude/skills/hyprnote-follow/scripts/follow.mjs +243 -0
- package/templates/.claude/skills/hyprnote-process/SKILL.md +147 -0
- package/templates/.claude/skills/hyprnote-process/references/extraction.md +84 -0
- package/templates/.claude/skills/hyprnote-process/references/sessions.md +81 -0
- package/templates/.claude/skills/hyprnote-process/scripts/scan.mjs +246 -0
- package/templates/.claude/skills/hyprnote-trim/SKILL.md +177 -0
- package/templates/.claude/skills/manage-tasks/SKILL.md +152 -0
- package/templates/.claude/skills/manage-tasks/references/format.md +73 -0
- package/templates/.claude/skills/meeting-prep/SKILL.md +180 -0
- package/templates/.claude/skills/organize-files/SKILL.md +146 -0
- package/templates/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
- package/templates/.claude/skills/organize-files/scripts/summarize.mjs +91 -0
- package/templates/.claude/skills/req-assess/SKILL.md +137 -0
- package/templates/.claude/skills/req-assess/references/interview-template.md +62 -0
- package/templates/.claude/skills/req-assess/references/panel-template.md +117 -0
- package/templates/.claude/skills/req-assess/references/rubric.md +65 -0
- package/templates/.claude/skills/req-decide/SKILL.md +163 -0
- package/templates/.claude/skills/req-decide/references/rubric.md +63 -0
- package/templates/.claude/skills/req-decide/references/template.md +118 -0
- package/templates/.claude/skills/req-forget/SKILL.md +154 -0
- package/templates/.claude/skills/req-forget/references/classify.md +23 -0
- package/templates/.claude/skills/req-forget/references/locations.md +59 -0
- package/templates/.claude/skills/req-forget/references/report-template.md +67 -0
- package/templates/.claude/skills/req-scan/SKILL.md +145 -0
- package/templates/.claude/skills/req-scan/references/fallbacks.md +76 -0
- package/templates/.claude/skills/req-scan/references/filters.md +83 -0
- package/templates/.claude/skills/req-scan/references/sources.md +76 -0
- package/templates/.claude/skills/req-scan/references/state.md +54 -0
- package/templates/.claude/skills/req-scan/references/template.md +36 -0
- package/templates/.claude/skills/req-scan/scripts/state.mjs +396 -0
- package/templates/.claude/skills/req-screen/SKILL.md +151 -0
- package/templates/.claude/skills/req-screen/references/rubric.md +94 -0
- package/templates/.claude/skills/req-screen/references/template.md +91 -0
- package/templates/.claude/skills/req-track/SKILL.md +189 -0
- package/templates/.claude/skills/req-track/references/fields.md +114 -0
- package/templates/.claude/skills/req-track/references/signals.md +42 -0
- package/templates/.claude/skills/req-track/references/statuses.md +32 -0
- package/templates/.claude/skills/req-track/references/templates.md +127 -0
- package/templates/.claude/skills/req-workday/SKILL.md +162 -0
- package/templates/.claude/skills/req-workday/references/field-mapping.md +40 -0
- package/templates/.claude/skills/req-workday/references/status-mapping.md +38 -0
- package/templates/.claude/skills/req-workday/references/templates.md +126 -0
- package/templates/.claude/skills/req-workday/references/xlsx-format.md +56 -0
- package/templates/.claude/skills/req-workday/scripts/parse-workday.mjs +313 -0
- package/templates/.claude/skills/send-chat/SKILL.md +170 -0
- package/templates/.claude/skills/sync-apple-calendar/SKILL.md +145 -0
- package/templates/.claude/skills/sync-apple-calendar/references/SCHEMA.md +80 -0
- package/templates/.claude/skills/sync-apple-calendar/scripts/query.mjs +307 -0
- package/templates/.claude/skills/sync-apple-calendar/scripts/sync.mjs +332 -0
- package/templates/.claude/skills/sync-apple-mail/SKILL.md +151 -0
- package/templates/.claude/skills/sync-apple-mail/references/SCHEMA.md +115 -0
- package/templates/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +389 -0
- package/templates/.claude/skills/sync-apple-mail/scripts/sync-helpers.mjs +372 -0
- package/templates/.claude/skills/sync-apple-mail/scripts/sync.mjs +239 -0
- package/templates/.claude/skills/sync-teams/SKILL.md +177 -0
- package/templates/.claude/skills/sync-teams/scripts/idb-reader.mjs +147 -0
- package/templates/.claude/skills/sync-teams/scripts/leveldb-reader.mjs +288 -0
- package/templates/.claude/skills/sync-teams/scripts/sync.mjs +381 -0
- package/templates/.claude/skills/upstream-skill/SKILL.md +130 -0
- package/templates/.claude/skills/upstream-skill/references/examples.md +80 -0
- package/templates/.claude/skills/weekly-update/SKILL.md +141 -0
- package/templates/.claude/skills/weekly-update/references/template.md +55 -0
- package/templates/CLAUDE.md +134 -0
- package/templates/USER.md +3 -0
- package/templates/apm.yml +5 -0
- package/templates/knowledge/Briefings/.gitkeep +0 -0
package/src/scheduler.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler — cron matching, shouldWake logic, wake orchestration.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Maximum time an agent can be "active" before being considered stale (35 min). */
|
|
6
|
+
const MAX_AGENT_RUNTIME_MS = 35 * 60_000;
|
|
7
|
+
|
|
8
|
+
// --- Cron matching (pure functions) ------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} field
|
|
12
|
+
* @param {number} value
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
export function matchField(field, value) {
|
|
16
|
+
if (field === "*") return true;
|
|
17
|
+
if (field.startsWith("*/")) return value % parseInt(field.slice(2)) === 0;
|
|
18
|
+
return field.split(",").some((part) => {
|
|
19
|
+
if (part.includes("-")) {
|
|
20
|
+
const [lo, hi] = part.split("-").map(Number);
|
|
21
|
+
return value >= lo && value <= hi;
|
|
22
|
+
}
|
|
23
|
+
return parseInt(part) === value;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} expr
|
|
29
|
+
* @param {Date} d
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
export function cronMatches(expr, d) {
|
|
33
|
+
const [min, hour, dom, month, dow] = expr.trim().split(/\s+/);
|
|
34
|
+
return (
|
|
35
|
+
matchField(min, d.getMinutes()) &&
|
|
36
|
+
matchField(hour, d.getHours()) &&
|
|
37
|
+
matchField(dom, d.getDate()) &&
|
|
38
|
+
matchField(month, d.getMonth() + 1) &&
|
|
39
|
+
matchField(dow, d.getDay())
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {Date} d
|
|
45
|
+
* @returns {number}
|
|
46
|
+
*/
|
|
47
|
+
export function floorToMinute(d) {
|
|
48
|
+
const t = d.getTime();
|
|
49
|
+
return t - (t % 60_000);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {Object} agent
|
|
54
|
+
* @param {Object} agentState
|
|
55
|
+
* @param {Date} now
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
export function shouldWake(agent, agentState, now) {
|
|
59
|
+
if (agent.enabled === false) return false;
|
|
60
|
+
if (agentState.status === "active") return false;
|
|
61
|
+
const { schedule } = agent;
|
|
62
|
+
if (!schedule) return false;
|
|
63
|
+
const lastWoke = agentState.lastWokeAt
|
|
64
|
+
? new Date(agentState.lastWokeAt)
|
|
65
|
+
: null;
|
|
66
|
+
|
|
67
|
+
if (schedule.type === "cron") {
|
|
68
|
+
if (lastWoke && floorToMinute(lastWoke) === floorToMinute(now))
|
|
69
|
+
return false;
|
|
70
|
+
return cronMatches(schedule.expression, now);
|
|
71
|
+
}
|
|
72
|
+
if (schedule.type === "interval") {
|
|
73
|
+
const ms = (schedule.minutes || 5) * 60_000;
|
|
74
|
+
return !lastWoke || now.getTime() - lastWoke.getTime() >= ms;
|
|
75
|
+
}
|
|
76
|
+
if (schedule.type === "once") {
|
|
77
|
+
return !agentState.lastWokeAt && now >= new Date(schedule.runAt);
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {Object} schedule
|
|
84
|
+
* @param {Object} agentState
|
|
85
|
+
* @param {Date} now
|
|
86
|
+
* @returns {string|null}
|
|
87
|
+
*/
|
|
88
|
+
function nextWakeInterval(schedule, agentState, now) {
|
|
89
|
+
const ms = (schedule.minutes || 5) * 60_000;
|
|
90
|
+
const lastWoke = agentState.lastWokeAt
|
|
91
|
+
? new Date(agentState.lastWokeAt)
|
|
92
|
+
: null;
|
|
93
|
+
if (!lastWoke) return now.toISOString();
|
|
94
|
+
return new Date(lastWoke.getTime() + ms).toISOString();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {Object} schedule
|
|
99
|
+
* @param {Date} now
|
|
100
|
+
* @returns {string|null}
|
|
101
|
+
*/
|
|
102
|
+
function nextWakeCron(schedule, now) {
|
|
103
|
+
const limit = 24 * 60;
|
|
104
|
+
const start = new Date(floorToMinute(now) + 60_000);
|
|
105
|
+
for (let i = 0; i < limit; i++) {
|
|
106
|
+
const candidate = new Date(start.getTime() + i * 60_000);
|
|
107
|
+
if (cronMatches(schedule.expression, candidate)) {
|
|
108
|
+
return candidate.toISOString();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {Object} agent
|
|
116
|
+
* @param {Object} agentState
|
|
117
|
+
* @param {Date} now
|
|
118
|
+
* @returns {string|null}
|
|
119
|
+
*/
|
|
120
|
+
export function computeNextWakeAt(agent, agentState, now) {
|
|
121
|
+
if (agent.enabled === false) return null;
|
|
122
|
+
const { schedule } = agent;
|
|
123
|
+
if (!schedule) return null;
|
|
124
|
+
|
|
125
|
+
if (schedule.type === "interval")
|
|
126
|
+
return nextWakeInterval(schedule, agentState, now);
|
|
127
|
+
if (schedule.type === "cron") return nextWakeCron(schedule, now);
|
|
128
|
+
if (schedule.type === "once")
|
|
129
|
+
return agentState.lastWokeAt ? null : schedule.runAt;
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {Object} agentState
|
|
135
|
+
* @param {string} error
|
|
136
|
+
*/
|
|
137
|
+
export function failAgent(agentState, error) {
|
|
138
|
+
Object.assign(agentState, {
|
|
139
|
+
status: "failed",
|
|
140
|
+
startedAt: null,
|
|
141
|
+
lastWokeAt: new Date().toISOString(),
|
|
142
|
+
lastError: String(error).slice(0, 500),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- Scheduler class ---------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/** Orchestrate periodic agent wakes by evaluating schedules and delegating to AgentRunner. */
|
|
149
|
+
export class Scheduler {
|
|
150
|
+
#loadConfig;
|
|
151
|
+
#stateManager;
|
|
152
|
+
#agentRunner;
|
|
153
|
+
#log;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {Function} loadConfig - Returns scheduler config
|
|
157
|
+
* @param {import('./state-manager.js').StateManager} stateManager
|
|
158
|
+
* @param {import('./agent-runner.js').AgentRunner} agentRunner
|
|
159
|
+
* @param {Function} logFn
|
|
160
|
+
*/
|
|
161
|
+
constructor(loadConfig, stateManager, agentRunner, logFn) {
|
|
162
|
+
if (!loadConfig) throw new Error("loadConfig is required");
|
|
163
|
+
if (!stateManager) throw new Error("stateManager is required");
|
|
164
|
+
if (!agentRunner) throw new Error("agentRunner is required");
|
|
165
|
+
if (!logFn) throw new Error("logFn is required");
|
|
166
|
+
this.#loadConfig = loadConfig;
|
|
167
|
+
this.#stateManager = stateManager;
|
|
168
|
+
this.#agentRunner = agentRunner;
|
|
169
|
+
this.#log = logFn;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Reset any agents exceeding max runtime, reload config, then wake each agent whose schedule is due.
|
|
174
|
+
*/
|
|
175
|
+
async wakeDueAgents() {
|
|
176
|
+
const config = this.#loadConfig();
|
|
177
|
+
const state = this.#stateManager.load();
|
|
178
|
+
const now = new Date();
|
|
179
|
+
|
|
180
|
+
this.#stateManager.resetStaleAgents(
|
|
181
|
+
state,
|
|
182
|
+
{ reason: "Exceeded maximum runtime", maxAge: MAX_AGENT_RUNTIME_MS },
|
|
183
|
+
this.#log,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
let wokeAny = false;
|
|
187
|
+
for (const [name, agent] of Object.entries(config.agents)) {
|
|
188
|
+
if (shouldWake(agent, state.agents[name] || {}, now)) {
|
|
189
|
+
await this.#agentRunner.wake(name, agent, state, config.env);
|
|
190
|
+
wokeAny = true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!wokeAny) this.#log("No agents due.");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SocketServer — Unix socket IPC for daemon communication.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createServer } from "node:net";
|
|
6
|
+
import { createConnection } from "node:net";
|
|
7
|
+
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
8
|
+
import {
|
|
9
|
+
existsSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
chmodSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
statSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
|
|
16
|
+
const logger = createLogger("outpost");
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { computeNextWakeAt } from "./scheduler.js";
|
|
20
|
+
|
|
21
|
+
/** Unix-socket IPC server that handles status queries, wake requests, and shutdown commands. */
|
|
22
|
+
export class SocketServer {
|
|
23
|
+
#socketPath;
|
|
24
|
+
#agentRunner;
|
|
25
|
+
#stateManager;
|
|
26
|
+
#log;
|
|
27
|
+
#loadConfig;
|
|
28
|
+
#cacheDir;
|
|
29
|
+
#daemonStartedAt;
|
|
30
|
+
#server;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} socketPath
|
|
34
|
+
* @param {import('./scheduler.js').Scheduler} scheduler
|
|
35
|
+
* @param {import('./agent-runner.js').AgentRunner} agentRunner
|
|
36
|
+
* @param {import('./state-manager.js').StateManager} stateManager
|
|
37
|
+
* @param {Function} loadConfig
|
|
38
|
+
* @param {Function} logFn
|
|
39
|
+
* @param {string} cacheDir
|
|
40
|
+
* @param {number} daemonStartedAt
|
|
41
|
+
*/
|
|
42
|
+
constructor(
|
|
43
|
+
socketPath,
|
|
44
|
+
scheduler,
|
|
45
|
+
agentRunner,
|
|
46
|
+
stateManager,
|
|
47
|
+
loadConfig,
|
|
48
|
+
logFn,
|
|
49
|
+
cacheDir,
|
|
50
|
+
daemonStartedAt,
|
|
51
|
+
) {
|
|
52
|
+
if (!socketPath) throw new Error("socketPath is required");
|
|
53
|
+
if (!scheduler) throw new Error("scheduler is required");
|
|
54
|
+
if (!agentRunner) throw new Error("agentRunner is required");
|
|
55
|
+
if (!stateManager) throw new Error("stateManager is required");
|
|
56
|
+
if (!loadConfig) throw new Error("loadConfig is required");
|
|
57
|
+
if (!logFn) throw new Error("logFn is required");
|
|
58
|
+
if (!cacheDir) throw new Error("cacheDir is required");
|
|
59
|
+
this.#socketPath = socketPath;
|
|
60
|
+
this.#agentRunner = agentRunner;
|
|
61
|
+
this.#stateManager = stateManager;
|
|
62
|
+
this.#loadConfig = loadConfig;
|
|
63
|
+
this.#log = logFn;
|
|
64
|
+
this.#cacheDir = cacheDir;
|
|
65
|
+
this.#daemonStartedAt = daemonStartedAt;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {import('node:net').Socket} socket
|
|
70
|
+
* @param {Object} data
|
|
71
|
+
*/
|
|
72
|
+
#send(socket, data) {
|
|
73
|
+
try {
|
|
74
|
+
socket.write(JSON.stringify(data) + "\n");
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {string} p
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
#expandPath(p) {
|
|
83
|
+
return p.startsWith("~/") ? join(homedir(), p.slice(2)) : resolve(p);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Find the most recently modified file in a directory matching a filter.
|
|
88
|
+
* @param {string} dir
|
|
89
|
+
* @param {(name: string) => boolean} filter
|
|
90
|
+
* @returns {string|null}
|
|
91
|
+
*/
|
|
92
|
+
#latestFileByMtime(dir, filter) {
|
|
93
|
+
const matches = readdirSync(dir).filter(filter);
|
|
94
|
+
if (matches.length === 0) return null;
|
|
95
|
+
let latest = join(dir, matches[0]);
|
|
96
|
+
let latestMtime = statSync(latest).mtimeMs;
|
|
97
|
+
for (let i = 1; i < matches.length; i++) {
|
|
98
|
+
const p = join(dir, matches[i]);
|
|
99
|
+
const mt = statSync(p).mtimeMs;
|
|
100
|
+
if (mt > latestMtime) {
|
|
101
|
+
latest = p;
|
|
102
|
+
latestMtime = mt;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return latest;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve briefing file for an agent
|
|
110
|
+
* @param {string} agentName
|
|
111
|
+
* @param {Object} agentConfig
|
|
112
|
+
* @returns {string|null}
|
|
113
|
+
*/
|
|
114
|
+
#resolveBriefingFile(agentName, agentConfig) {
|
|
115
|
+
const stateDir = join(this.#cacheDir, "state");
|
|
116
|
+
if (existsSync(stateDir)) {
|
|
117
|
+
const prefix = agentName.replace(/-/g, "_") + "_";
|
|
118
|
+
const found = this.#latestFileByMtime(
|
|
119
|
+
stateDir,
|
|
120
|
+
(f) => f.startsWith(prefix) && f.endsWith(".md"),
|
|
121
|
+
);
|
|
122
|
+
if (found) return found;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (agentConfig.kb) {
|
|
126
|
+
const dir = join(
|
|
127
|
+
this.#expandPath(agentConfig.kb),
|
|
128
|
+
"knowledge",
|
|
129
|
+
"Briefings",
|
|
130
|
+
);
|
|
131
|
+
if (existsSync(dir)) {
|
|
132
|
+
const files = readdirSync(dir)
|
|
133
|
+
.filter((f) => f.endsWith(".md"))
|
|
134
|
+
.sort()
|
|
135
|
+
.reverse();
|
|
136
|
+
if (files.length > 0) return join(dir, files[0]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {import('node:net').Socket} socket
|
|
145
|
+
*/
|
|
146
|
+
#handleStatusRequest(socket) {
|
|
147
|
+
const config = this.#loadConfig();
|
|
148
|
+
const state = this.#stateManager.load();
|
|
149
|
+
const now = new Date();
|
|
150
|
+
const agents = {};
|
|
151
|
+
|
|
152
|
+
for (const [name, agent] of Object.entries(config.agents)) {
|
|
153
|
+
const as = state.agents[name] || {};
|
|
154
|
+
agents[name] = {
|
|
155
|
+
enabled: agent.enabled !== false,
|
|
156
|
+
status: as.status || "never-woken",
|
|
157
|
+
lastWokeAt: as.lastWokeAt || null,
|
|
158
|
+
nextWakeAt: computeNextWakeAt(agent, as, now),
|
|
159
|
+
lastAction: as.lastAction || null,
|
|
160
|
+
lastDecision: as.lastDecision || null,
|
|
161
|
+
wakeCount: as.wakeCount || 0,
|
|
162
|
+
lastError: as.lastError || null,
|
|
163
|
+
kbPath: agent.kb ? this.#expandPath(agent.kb) : null,
|
|
164
|
+
briefingFile: this.#resolveBriefingFile(name, agent),
|
|
165
|
+
};
|
|
166
|
+
if (as.startedAt) agents[name].startedAt = as.startedAt;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this.#send(socket, {
|
|
170
|
+
type: "status",
|
|
171
|
+
uptime: this.#daemonStartedAt
|
|
172
|
+
? Math.floor((Date.now() - this.#daemonStartedAt) / 1000)
|
|
173
|
+
: 0,
|
|
174
|
+
agents,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {import('node:net').Socket} socket
|
|
180
|
+
* @param {string} line
|
|
181
|
+
*/
|
|
182
|
+
#handleMessage(socket, line) {
|
|
183
|
+
let request;
|
|
184
|
+
try {
|
|
185
|
+
request = JSON.parse(line);
|
|
186
|
+
} catch {
|
|
187
|
+
this.#send(socket, { type: "error", message: "Invalid JSON" });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (request.type === "status") return this.#handleStatusRequest(socket);
|
|
192
|
+
|
|
193
|
+
if (request.type === "shutdown") {
|
|
194
|
+
this.#log("Shutdown requested via socket.");
|
|
195
|
+
this.#send(socket, { type: "ack", command: "shutdown" });
|
|
196
|
+
socket.end();
|
|
197
|
+
this.#agentRunner.killActiveChildren();
|
|
198
|
+
process.exit(0);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (request.type === "wake") {
|
|
202
|
+
if (!request.agent) {
|
|
203
|
+
this.#send(socket, { type: "error", message: "Missing agent name" });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const config = this.#loadConfig();
|
|
207
|
+
const agent = config.agents[request.agent];
|
|
208
|
+
if (!agent) {
|
|
209
|
+
this.#send(socket, {
|
|
210
|
+
type: "error",
|
|
211
|
+
message: `Agent not found: ${request.agent}`,
|
|
212
|
+
});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
this.#send(socket, {
|
|
216
|
+
type: "ack",
|
|
217
|
+
command: "wake",
|
|
218
|
+
agent: request.agent,
|
|
219
|
+
});
|
|
220
|
+
const state = this.#stateManager.load();
|
|
221
|
+
this.#agentRunner
|
|
222
|
+
.wake(request.agent, agent, state, config.env)
|
|
223
|
+
.catch(() => {});
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
this.#send(socket, {
|
|
228
|
+
type: "error",
|
|
229
|
+
message: `Unknown request type: ${request.type}`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Remove any existing socket file, bind the server, and register SIGTERM/SIGINT handlers to kill active children before exit.
|
|
235
|
+
* @returns {import('node:net').Server}
|
|
236
|
+
*/
|
|
237
|
+
start() {
|
|
238
|
+
try {
|
|
239
|
+
unlinkSync(this.#socketPath);
|
|
240
|
+
} catch {}
|
|
241
|
+
|
|
242
|
+
this.#server = createServer((socket) => {
|
|
243
|
+
let buffer = "";
|
|
244
|
+
socket.on("data", (data) => {
|
|
245
|
+
buffer += data.toString();
|
|
246
|
+
let idx;
|
|
247
|
+
while ((idx = buffer.indexOf("\n")) !== -1) {
|
|
248
|
+
const line = buffer.slice(0, idx).trim();
|
|
249
|
+
buffer = buffer.slice(idx + 1);
|
|
250
|
+
if (line) this.#handleMessage(socket, line);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
socket.on("error", () => {});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
this.#server.listen(this.#socketPath, () => {
|
|
257
|
+
chmodSync(this.#socketPath, 0o600);
|
|
258
|
+
this.#log(`Socket server listening on ${this.#socketPath}`);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
this.#server.on("error", (err) => {
|
|
262
|
+
this.#log(`Socket server error: ${err.message}`);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const cleanup = () => {
|
|
266
|
+
this.#agentRunner.killActiveChildren();
|
|
267
|
+
this.#server.close();
|
|
268
|
+
try {
|
|
269
|
+
unlinkSync(this.#socketPath);
|
|
270
|
+
} catch {}
|
|
271
|
+
process.exit(0);
|
|
272
|
+
};
|
|
273
|
+
process.on("SIGTERM", cleanup);
|
|
274
|
+
process.on("SIGINT", cleanup);
|
|
275
|
+
|
|
276
|
+
return this.#server;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Stop the socket server
|
|
281
|
+
*/
|
|
282
|
+
stop() {
|
|
283
|
+
if (this.#server) {
|
|
284
|
+
this.#server.close();
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
unlinkSync(this.#socketPath);
|
|
288
|
+
} catch {}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Connect to the daemon socket and request graceful shutdown.
|
|
294
|
+
* @param {string} socketPath
|
|
295
|
+
* @returns {Promise<boolean>}
|
|
296
|
+
*/
|
|
297
|
+
export async function requestShutdown(socketPath) {
|
|
298
|
+
if (!existsSync(socketPath)) {
|
|
299
|
+
logger.info("Daemon not running (no socket).");
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return new Promise((resolve) => {
|
|
304
|
+
const timeout = setTimeout(() => {
|
|
305
|
+
logger.info("Shutdown timed out.");
|
|
306
|
+
socket.destroy();
|
|
307
|
+
resolve(false);
|
|
308
|
+
}, 5000);
|
|
309
|
+
|
|
310
|
+
const socket = createConnection(socketPath, () => {
|
|
311
|
+
socket.write(JSON.stringify({ type: "shutdown" }) + "\n");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
let buffer = "";
|
|
315
|
+
socket.on("data", (data) => {
|
|
316
|
+
buffer += data.toString();
|
|
317
|
+
if (buffer.includes("\n")) {
|
|
318
|
+
clearTimeout(timeout);
|
|
319
|
+
logger.info("Daemon stopped.");
|
|
320
|
+
socket.destroy();
|
|
321
|
+
resolve(true);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
socket.on("error", () => {
|
|
326
|
+
clearTimeout(timeout);
|
|
327
|
+
logger.info("Daemon not running (connection refused).");
|
|
328
|
+
resolve(false);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
socket.on("close", () => {
|
|
332
|
+
clearTimeout(timeout);
|
|
333
|
+
resolve(true);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StateManager — load/save state.json, reset stale agents.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
/** Persist and query agent scheduler state from a JSON file on disk. */
|
|
9
|
+
export class StateManager {
|
|
10
|
+
#statePath;
|
|
11
|
+
#fs;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} statePath - Path to state.json
|
|
15
|
+
* @param {{ readFileSync: Function, writeFileSync: Function, mkdirSync: Function }} fs
|
|
16
|
+
*/
|
|
17
|
+
constructor(statePath, fs) {
|
|
18
|
+
if (!statePath) throw new Error("statePath is required");
|
|
19
|
+
if (!fs) throw new Error("fs is required");
|
|
20
|
+
this.#statePath = statePath;
|
|
21
|
+
this.#fs = fs;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read and parse state from disk; on any read or parse error, write a fresh empty state and return it.
|
|
26
|
+
* @returns {Object}
|
|
27
|
+
*/
|
|
28
|
+
load() {
|
|
29
|
+
try {
|
|
30
|
+
const raw = JSON.parse(this.#fs.readFileSync(this.#statePath, "utf8"));
|
|
31
|
+
if (!raw || typeof raw !== "object" || !raw.agents) {
|
|
32
|
+
const state = { agents: {} };
|
|
33
|
+
this.save(state);
|
|
34
|
+
return state;
|
|
35
|
+
}
|
|
36
|
+
return raw;
|
|
37
|
+
} catch {
|
|
38
|
+
const state = { agents: {} };
|
|
39
|
+
this.save(state);
|
|
40
|
+
return state;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Save state to disk
|
|
46
|
+
* @param {Object} state
|
|
47
|
+
*/
|
|
48
|
+
save(state) {
|
|
49
|
+
this.#fs.mkdirSync(dirname(this.#statePath), { recursive: true });
|
|
50
|
+
this.#fs.writeFileSync(
|
|
51
|
+
this.#statePath,
|
|
52
|
+
JSON.stringify(state, null, 2) + "\n",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reset agents stuck in "active" state.
|
|
58
|
+
* @param {Object} state
|
|
59
|
+
* @param {{ reason: string, maxAge?: number }} opts
|
|
60
|
+
* @param {Function} logFn
|
|
61
|
+
* @returns {number} Number of agents reset
|
|
62
|
+
*/
|
|
63
|
+
resetStaleAgents(state, { reason, maxAge }, logFn) {
|
|
64
|
+
let resetCount = 0;
|
|
65
|
+
for (const [name, as] of Object.entries(state.agents)) {
|
|
66
|
+
if (as.status !== "active") continue;
|
|
67
|
+
if (maxAge && as.startedAt) {
|
|
68
|
+
const elapsed = Date.now() - new Date(as.startedAt).getTime();
|
|
69
|
+
if (elapsed < maxAge) continue;
|
|
70
|
+
}
|
|
71
|
+
logFn(`Resetting stale agent: ${name} (${reason})`);
|
|
72
|
+
Object.assign(as, {
|
|
73
|
+
status: "interrupted",
|
|
74
|
+
startedAt: null,
|
|
75
|
+
lastError: reason,
|
|
76
|
+
});
|
|
77
|
+
resetCount++;
|
|
78
|
+
}
|
|
79
|
+
if (resetCount > 0) this.save(state);
|
|
80
|
+
return resetCount;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parse Decision:/Action: lines from agent output and update state.
|
|
85
|
+
* @param {Object} agentState
|
|
86
|
+
* @param {string} stdout
|
|
87
|
+
* @param {string} agentName
|
|
88
|
+
* @param {string} cacheDir - Cache directory for state files
|
|
89
|
+
*/
|
|
90
|
+
updateAgentState(agentState, stdout, agentName, cacheDir) {
|
|
91
|
+
const lines = stdout.split("\n");
|
|
92
|
+
const decisionLine = lines.find((l) => l.startsWith("Decision:"));
|
|
93
|
+
const actionLine = lines.find((l) => l.startsWith("Action:"));
|
|
94
|
+
|
|
95
|
+
Object.assign(agentState, {
|
|
96
|
+
status: "idle",
|
|
97
|
+
startedAt: null,
|
|
98
|
+
lastWokeAt: new Date().toISOString(),
|
|
99
|
+
lastDecision: decisionLine
|
|
100
|
+
? decisionLine.slice(10).trim()
|
|
101
|
+
: stdout.slice(0, 200),
|
|
102
|
+
lastAction: actionLine ? actionLine.slice(8).trim() : null,
|
|
103
|
+
lastError: null,
|
|
104
|
+
wakeCount: (agentState.wakeCount || 0) + 1,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Save output as briefing fallback
|
|
108
|
+
const stateDir = join(cacheDir, "state");
|
|
109
|
+
this.#fs.mkdirSync(stateDir, { recursive: true });
|
|
110
|
+
const prefix = agentName.replace(/-/g, "_");
|
|
111
|
+
this.#fs.writeFileSync(join(stateDir, `${prefix}_last_output.md`), stdout);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Create a StateManager with real fs dependencies
|
|
117
|
+
* @param {string} statePath
|
|
118
|
+
* @returns {StateManager}
|
|
119
|
+
*/
|
|
120
|
+
export function createStateManager(statePath) {
|
|
121
|
+
return new StateManager(statePath, {
|
|
122
|
+
readFileSync,
|
|
123
|
+
writeFileSync,
|
|
124
|
+
mkdirSync,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: chief-of-staff
|
|
3
|
+
description: >
|
|
4
|
+
The user's executive assistant. Creates daily briefings that synthesize
|
|
5
|
+
email, calendar, and knowledge graph state into actionable priorities.
|
|
6
|
+
Woken at key moments (morning, evening) by the Outpost scheduler.
|
|
7
|
+
model: sonnet
|
|
8
|
+
permissionMode: bypassPermissions
|
|
9
|
+
skills:
|
|
10
|
+
- weekly-update
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
You are the chief of staff — the user's executive assistant. Each wake:
|
|
14
|
+
synthesize what matters across email, calendar, and the knowledge graph into a
|
|
15
|
+
single briefing.
|
|
16
|
+
|
|
17
|
+
## Inputs
|
|
18
|
+
|
|
19
|
+
Read all five sibling agents' triage files before writing — these are the
|
|
20
|
+
authoritative current-state summaries:
|
|
21
|
+
|
|
22
|
+
- `~/.cache/fit/outpost/state/postman_triage.md`
|
|
23
|
+
- `~/.cache/fit/outpost/state/concierge_triage.md`
|
|
24
|
+
- `~/.cache/fit/outpost/state/librarian_triage.md`
|
|
25
|
+
- `~/.cache/fit/outpost/state/recruiter_triage.md`
|
|
26
|
+
- `~/.cache/fit/outpost/state/head_hunter_triage.md`
|
|
27
|
+
|
|
28
|
+
Plus directly: `knowledge/Goals/`, `knowledge/Priorities/`, `drafts/`,
|
|
29
|
+
`~/.cache/fit/outpost/apple_calendar/`, and unchecked `- [ ]` items in
|
|
30
|
+
`knowledge/`.
|
|
31
|
+
|
|
32
|
+
## Routing
|
|
33
|
+
|
|
34
|
+
| Trigger | Output |
|
|
35
|
+
| -------------- | -------------------------------------------------- |
|
|
36
|
+
| Before noon | `knowledge/Briefings/{YYYY-MM-DD}-morning.md` |
|
|
37
|
+
| Noon or later | `knowledge/Briefings/{YYYY-MM-DD}-evening.md` |
|
|
38
|
+
| Monday morning | also run `weekly-update` for the week's priorities |
|
|
39
|
+
|
|
40
|
+
A briefing covers: today's schedule with prep status, top three priority actions
|
|
41
|
+
linked to `[[Priorities/...]]`, goal progress, inbox snapshot (urgent / awaiting
|
|
42
|
+
reply), open commitments, recruitment pipeline summary, and a heads-up section.
|
|
43
|
+
Evening briefings replace "Priority Actions" with "What Happened Today" and
|
|
44
|
+
"Still Outstanding".
|
|
45
|
+
|
|
46
|
+
## Scope
|
|
47
|
+
|
|
48
|
+
- This agent **synthesizes** — never duplicate work the other agents have
|
|
49
|
+
already triaged. Cite their findings, don't re-derive them.
|
|
50
|
+
- Do not act on email, candidates, or transcripts directly — those belong to the
|
|
51
|
+
postman, recruiter, and concierge.
|
|
52
|
+
|
|
53
|
+
## Output
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
Decision: {morning/evening} briefing — {key insight about today}
|
|
57
|
+
Action: Created knowledge/Briefings/{YYYY-MM-DD}-{morning|evening}.md
|
|
58
|
+
```
|