@qearlyao/familiar 0.1.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/.env.example +31 -0
- package/HEARTBEAT.md +23 -0
- package/LICENSE +21 -0
- package/MEMORY.md +1 -0
- package/README.md +245 -0
- package/SOUL.md +13 -0
- package/USER.md +13 -0
- package/config.example.toml +221 -0
- package/dist/agent-events.js +167 -0
- package/dist/agent.js +590 -0
- package/dist/browser-tools.js +638 -0
- package/dist/chat-log.js +130 -0
- package/dist/cli.js +168 -0
- package/dist/config.js +804 -0
- package/dist/data-retention.js +54 -0
- package/dist/discord.js +1203 -0
- package/dist/generated-media.js +86 -0
- package/dist/image-derivatives.js +102 -0
- package/dist/image-gen.js +440 -0
- package/dist/inbound-attachments.js +266 -0
- package/dist/index.js +10 -0
- package/dist/media-understanding.js +120 -0
- package/dist/memory/diary/ambient-injector.js +180 -0
- package/dist/memory/diary/ambient.js +124 -0
- package/dist/memory/diary/chunks.js +231 -0
- package/dist/memory/diary/index.js +3 -0
- package/dist/memory/diary/indexer.js +93 -0
- package/dist/memory/doctor.js +250 -0
- package/dist/memory/index/chunk-indexer.js +151 -0
- package/dist/memory/index/embedding-provider.js +119 -0
- package/dist/memory/index/fts-query.js +18 -0
- package/dist/memory/index/retrieval.js +246 -0
- package/dist/memory/index/schema.js +157 -0
- package/dist/memory/index/store.js +513 -0
- package/dist/memory/index/vec.js +72 -0
- package/dist/memory/index/vector-codec.js +27 -0
- package/dist/memory/lcm/backfill.js +247 -0
- package/dist/memory/lcm/condense.js +146 -0
- package/dist/memory/lcm/context-transformer.js +662 -0
- package/dist/memory/lcm/context.js +421 -0
- package/dist/memory/lcm/eviction-score.js +38 -0
- package/dist/memory/lcm/index.js +6 -0
- package/dist/memory/lcm/indexer.js +200 -0
- package/dist/memory/lcm/normalize.js +235 -0
- package/dist/memory/lcm/schema.js +188 -0
- package/dist/memory/lcm/segment-manager.js +136 -0
- package/dist/memory/lcm/store.js +722 -0
- package/dist/memory/lcm/summarizer.js +258 -0
- package/dist/memory/lcm/types.js +1 -0
- package/dist/memory/operator.js +477 -0
- package/dist/memory/service.js +202 -0
- package/dist/memory/tools.js +205 -0
- package/dist/models.js +165 -0
- package/dist/persona.js +54 -0
- package/dist/runtime.js +493 -0
- package/dist/scheduler.js +200 -0
- package/dist/settings.js +116 -0
- package/dist/skills.js +38 -0
- package/dist/tts.js +143 -0
- package/dist/web-auth.js +105 -0
- package/dist/web-events.js +114 -0
- package/dist/web-http.js +29 -0
- package/dist/web-static.js +106 -0
- package/dist/web-tools.js +940 -0
- package/dist/web-types.js +2 -0
- package/dist/web.js +844 -0
- package/package.json +60 -0
- package/web/dist/assets/index-ClgkMgaq.css +2 -0
- package/web/dist/assets/index-Cu2QquuR.js +59 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/icons.svg +24 -0
- package/web/dist/index.html +20 -0
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { buildRecordBase, } from "./chat-log.js";
|
|
3
|
+
import { promptAttachmentNotes } from "./inbound-attachments.js";
|
|
4
|
+
function formatAuthor(authorName, authorId) {
|
|
5
|
+
return authorName ? `${authorName} (uid:${authorId})` : `uid:${authorId}`;
|
|
6
|
+
}
|
|
7
|
+
function formatLocalTimestamp(ts) {
|
|
8
|
+
const date = new Date(ts);
|
|
9
|
+
if (Number.isNaN(date.getTime()))
|
|
10
|
+
return ts;
|
|
11
|
+
const offsetMinutes = -date.getTimezoneOffset();
|
|
12
|
+
const sign = offsetMinutes >= 0 ? "+" : "-";
|
|
13
|
+
const absolute = Math.abs(offsetMinutes);
|
|
14
|
+
const hours = Math.floor(absolute / 60);
|
|
15
|
+
const minutes = absolute % 60;
|
|
16
|
+
const offset = minutes === 0 ? `GMT${sign}${hours}` : `GMT${sign}${hours}:${String(minutes).padStart(2, "0")}`;
|
|
17
|
+
const local = [
|
|
18
|
+
date.getFullYear(),
|
|
19
|
+
String(date.getMonth() + 1).padStart(2, "0"),
|
|
20
|
+
String(date.getDate()).padStart(2, "0"),
|
|
21
|
+
].join("-");
|
|
22
|
+
const time = [
|
|
23
|
+
String(date.getHours()).padStart(2, "0"),
|
|
24
|
+
String(date.getMinutes()).padStart(2, "0"),
|
|
25
|
+
String(date.getSeconds()).padStart(2, "0"),
|
|
26
|
+
].join(":");
|
|
27
|
+
return `${local} ${time} ${offset}`;
|
|
28
|
+
}
|
|
29
|
+
function formatPromptRecord(record) {
|
|
30
|
+
const text = record.text.trim() || "(no text)";
|
|
31
|
+
const author = record.authorName?.trim()
|
|
32
|
+
? `${record.authorName.trim()} uid:${record.authorId}`
|
|
33
|
+
: `uid:${record.authorId}`;
|
|
34
|
+
const attachmentText = record.attachments.length ? `\n${promptAttachmentNotes(record.attachments)}` : "";
|
|
35
|
+
return `[${author} @ ${formatLocalTimestamp(record.ts)}] ${text}${attachmentText}`;
|
|
36
|
+
}
|
|
37
|
+
function getTriggerRecord(records, job) {
|
|
38
|
+
const record = records.find((candidate) => candidate.recordId === job.triggerRecordId);
|
|
39
|
+
return record?.type === "inbound" ? record : undefined;
|
|
40
|
+
}
|
|
41
|
+
function escapeRegExp(value) {
|
|
42
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
43
|
+
}
|
|
44
|
+
export class ConversationRuntime {
|
|
45
|
+
channel;
|
|
46
|
+
channelKey;
|
|
47
|
+
log;
|
|
48
|
+
ownerId;
|
|
49
|
+
botUserId;
|
|
50
|
+
records = [];
|
|
51
|
+
nextRecordId = 1;
|
|
52
|
+
armedAfterRecordId;
|
|
53
|
+
lastUserInteractionAt = 0;
|
|
54
|
+
pendingJobs = [];
|
|
55
|
+
activeJob;
|
|
56
|
+
listeners = new Set();
|
|
57
|
+
agentEventListeners = new Set();
|
|
58
|
+
constructor(options) {
|
|
59
|
+
this.channel = options.log.channel;
|
|
60
|
+
this.channelKey = options.channelKey;
|
|
61
|
+
this.log = options.log;
|
|
62
|
+
this.ownerId = options.ownerId;
|
|
63
|
+
this.botUserId = options.botUserId;
|
|
64
|
+
}
|
|
65
|
+
static async connect(options) {
|
|
66
|
+
const runtime = new ConversationRuntime(options);
|
|
67
|
+
await runtime.initialize();
|
|
68
|
+
return runtime;
|
|
69
|
+
}
|
|
70
|
+
async initialize() {
|
|
71
|
+
await this.log.acquire(`familiar-${process.pid}-${this.channelKey}`);
|
|
72
|
+
this.records = await this.log.read();
|
|
73
|
+
this.nextRecordId = this.records.reduce((max, record) => Math.max(max, record.recordId), 0) + 1;
|
|
74
|
+
this.lastUserInteractionAt = this.findLastUserInteractionAt();
|
|
75
|
+
this.rebuildPendingJobs();
|
|
76
|
+
}
|
|
77
|
+
findLastUserInteractionAt() {
|
|
78
|
+
for (let index = this.records.length - 1; index >= 0; index--) {
|
|
79
|
+
const record = this.records[index];
|
|
80
|
+
if (!record || record.type !== "inbound" || !this.isOwnerMessage(record))
|
|
81
|
+
continue;
|
|
82
|
+
const parsed = Date.parse(record.ts);
|
|
83
|
+
if (Number.isFinite(parsed))
|
|
84
|
+
return parsed;
|
|
85
|
+
}
|
|
86
|
+
return Date.now();
|
|
87
|
+
}
|
|
88
|
+
rebuildPendingJobs() {
|
|
89
|
+
const terminalJobIds = new Set();
|
|
90
|
+
const queuedJobs = [];
|
|
91
|
+
for (const record of this.records) {
|
|
92
|
+
if (record.type === "job_completed" || record.type === "job_failed")
|
|
93
|
+
terminalJobIds.add(record.jobId);
|
|
94
|
+
if (record.type === "job_queued") {
|
|
95
|
+
queuedJobs.push({
|
|
96
|
+
jobId: record.jobId,
|
|
97
|
+
trigger: record.trigger,
|
|
98
|
+
triggerRecordId: record.triggerRecordId,
|
|
99
|
+
queuedRecordId: record.recordId,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
this.pendingJobs = queuedJobs.filter((job) => !terminalJobIds.has(job.jobId));
|
|
104
|
+
}
|
|
105
|
+
async disconnect() {
|
|
106
|
+
await this.log.release();
|
|
107
|
+
}
|
|
108
|
+
getLastUserInteractionAt() {
|
|
109
|
+
return this.lastUserInteractionAt;
|
|
110
|
+
}
|
|
111
|
+
async armAfterCurrentTail() {
|
|
112
|
+
this.armedAfterRecordId = this.records.at(-1)?.recordId ?? 0;
|
|
113
|
+
await this.appendRecord({
|
|
114
|
+
type: "runtime",
|
|
115
|
+
...buildRecordBase(this.channel, this.nextRecordId),
|
|
116
|
+
event: "armed",
|
|
117
|
+
detail: `armed after record ${this.armedAfterRecordId}`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async noteHeartbeat(detail) {
|
|
121
|
+
await this.appendRecord({
|
|
122
|
+
type: "runtime",
|
|
123
|
+
...buildRecordBase(this.channel, this.nextRecordId),
|
|
124
|
+
event: "heartbeat",
|
|
125
|
+
detail,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async noteHeartbeatFailure(detail) {
|
|
129
|
+
await this.appendRecord({
|
|
130
|
+
type: "runtime",
|
|
131
|
+
...buildRecordBase(this.channel, this.nextRecordId),
|
|
132
|
+
event: "heartbeat_failed",
|
|
133
|
+
detail,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
async appendRecord(record, options = {}) {
|
|
137
|
+
this.records.push(record);
|
|
138
|
+
this.nextRecordId = Math.max(this.nextRecordId, record.recordId + 1);
|
|
139
|
+
if (record.type === "inbound" && this.isOwnerMessage(record)) {
|
|
140
|
+
const parsed = Date.parse(record.ts);
|
|
141
|
+
this.lastUserInteractionAt = Number.isFinite(parsed) ? parsed : Date.now();
|
|
142
|
+
}
|
|
143
|
+
await this.log.append(record);
|
|
144
|
+
if (options.notify === false)
|
|
145
|
+
return;
|
|
146
|
+
for (const listener of this.listeners) {
|
|
147
|
+
void Promise.resolve(listener(record)).catch((error) => console.error(`runtime listener failed for ${this.channelKey}`, error));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
subscribe(listener) {
|
|
151
|
+
this.listeners.add(listener);
|
|
152
|
+
return () => {
|
|
153
|
+
this.listeners.delete(listener);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
subscribeAgentEvents(listener) {
|
|
157
|
+
this.agentEventListeners.add(listener);
|
|
158
|
+
return () => {
|
|
159
|
+
this.agentEventListeners.delete(listener);
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
publishAgentEvent(jobId, messageId, event) {
|
|
163
|
+
const payload = { jobId, messageId, event, ts: Date.now() };
|
|
164
|
+
for (const listener of this.agentEventListeners) {
|
|
165
|
+
void Promise.resolve(listener(payload)).catch((error) => console.error(`runtime agent event listener failed for ${this.channelKey}`, error));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
isOwnerMessage(input) {
|
|
169
|
+
if (input.isBot)
|
|
170
|
+
return false;
|
|
171
|
+
return input.authorId === this.ownerId;
|
|
172
|
+
}
|
|
173
|
+
getLastQueuedTriggerRecordId() {
|
|
174
|
+
let last = 0;
|
|
175
|
+
for (const record of this.records) {
|
|
176
|
+
if (record.type === "job_queued")
|
|
177
|
+
last = Math.max(last, record.triggerRecordId);
|
|
178
|
+
}
|
|
179
|
+
return last;
|
|
180
|
+
}
|
|
181
|
+
getLastCompletedTriggerRecordId() {
|
|
182
|
+
let last = 0;
|
|
183
|
+
for (const record of this.records) {
|
|
184
|
+
if (record.type === "job_completed")
|
|
185
|
+
last = Math.max(last, record.triggerRecordId);
|
|
186
|
+
}
|
|
187
|
+
return last;
|
|
188
|
+
}
|
|
189
|
+
canRecordTrigger(record, options = {}) {
|
|
190
|
+
if (this.armedAfterRecordId === undefined)
|
|
191
|
+
return undefined;
|
|
192
|
+
if (record.recordId <= this.armedAfterRecordId)
|
|
193
|
+
return undefined;
|
|
194
|
+
if (record.recordId <= this.getLastQueuedTriggerRecordId())
|
|
195
|
+
return undefined;
|
|
196
|
+
if (this.channel.scope === "dm" || this.channel.scope === "web") {
|
|
197
|
+
if (!this.isOwnerMessage(record))
|
|
198
|
+
return undefined;
|
|
199
|
+
return options.mode === "collect" ? undefined : "dm";
|
|
200
|
+
}
|
|
201
|
+
if (options.mode === "collect")
|
|
202
|
+
return undefined;
|
|
203
|
+
if (options.channelTrigger === "always")
|
|
204
|
+
return "message";
|
|
205
|
+
return record.mentionedBot ? "mention" : undefined;
|
|
206
|
+
}
|
|
207
|
+
hasActiveJob(jobId) {
|
|
208
|
+
if (!this.activeJob)
|
|
209
|
+
return false;
|
|
210
|
+
return jobId ? this.activeJob.jobId === jobId : true;
|
|
211
|
+
}
|
|
212
|
+
parseControlCommand(input) {
|
|
213
|
+
if (!this.isOwnerMessage(input))
|
|
214
|
+
return undefined;
|
|
215
|
+
let text = input.text;
|
|
216
|
+
if (this.botUserId)
|
|
217
|
+
text = text.replace(new RegExp(`<@!?${escapeRegExp(this.botUserId)}>`, "g"), " ");
|
|
218
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
219
|
+
const commandText = normalized.toLowerCase();
|
|
220
|
+
const slashCommand = commandText.startsWith("/");
|
|
221
|
+
const explicitBotCommand = input.mentionedBot === true;
|
|
222
|
+
if (!slashCommand && !explicitBotCommand)
|
|
223
|
+
return undefined;
|
|
224
|
+
const [rawCommand = "", ...argParts] = normalized.split(" ");
|
|
225
|
+
const command = rawCommand.replace(/^\//, "").toLowerCase();
|
|
226
|
+
if (!["stop", "status", "new", "reload", "compact", "model", "thinking", "channel-trigger"].includes(command)) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
command: command,
|
|
231
|
+
args: argParts.join(" ").trim(),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
async noteControlCommand(input, control) {
|
|
235
|
+
await this.appendRecord({
|
|
236
|
+
type: "control",
|
|
237
|
+
...buildRecordBase(this.channel, this.nextRecordId),
|
|
238
|
+
command: control.command,
|
|
239
|
+
args: control.args || undefined,
|
|
240
|
+
messageId: input.messageId,
|
|
241
|
+
authorId: input.authorId,
|
|
242
|
+
authorName: input.authorName,
|
|
243
|
+
text: input.text.trim(),
|
|
244
|
+
});
|
|
245
|
+
if (input.checkpoint)
|
|
246
|
+
await this.noteCheckpoint(input.checkpoint);
|
|
247
|
+
}
|
|
248
|
+
async ingestInbound(input, options = {}) {
|
|
249
|
+
const record = {
|
|
250
|
+
type: "inbound",
|
|
251
|
+
...buildRecordBase(this.channel, this.nextRecordId),
|
|
252
|
+
ts: input.remoteTimestamp || new Date().toISOString(),
|
|
253
|
+
messageId: input.messageId,
|
|
254
|
+
authorId: input.authorId,
|
|
255
|
+
authorName: input.authorName,
|
|
256
|
+
text: input.text.trim(),
|
|
257
|
+
isBot: input.isBot ?? false,
|
|
258
|
+
mentionedBot: input.mentionedBot ?? false,
|
|
259
|
+
attachments: input.attachments ?? [],
|
|
260
|
+
};
|
|
261
|
+
await this.appendRecord(record);
|
|
262
|
+
if (input.checkpoint)
|
|
263
|
+
await this.noteCheckpoint(input.checkpoint);
|
|
264
|
+
const trigger = this.canRecordTrigger(record, options);
|
|
265
|
+
if (!trigger)
|
|
266
|
+
return { record, jobQueued: false };
|
|
267
|
+
await this.queueTrigger(record, trigger);
|
|
268
|
+
return { record, jobQueued: true };
|
|
269
|
+
}
|
|
270
|
+
async queueLatestTrigger(options = {}) {
|
|
271
|
+
const record = this.getLatestQueueableInbound(options);
|
|
272
|
+
if (!record)
|
|
273
|
+
return undefined;
|
|
274
|
+
const trigger = this.channel.scope === "dm" || this.channel.scope === "web"
|
|
275
|
+
? "dm"
|
|
276
|
+
: options.channelTrigger === "always"
|
|
277
|
+
? "message"
|
|
278
|
+
: "mention";
|
|
279
|
+
return this.queueTrigger(record, trigger);
|
|
280
|
+
}
|
|
281
|
+
buildSteerPromptForRecord(record) {
|
|
282
|
+
return formatPromptRecord(record);
|
|
283
|
+
}
|
|
284
|
+
getLatestQueueableInbound(options) {
|
|
285
|
+
const lastQueuedTriggerRecordId = this.getLastQueuedTriggerRecordId();
|
|
286
|
+
let latest;
|
|
287
|
+
let sawMention = false;
|
|
288
|
+
for (let index = this.records.length - 1; index >= 0; index--) {
|
|
289
|
+
const record = this.records[index];
|
|
290
|
+
if (record?.type !== "inbound")
|
|
291
|
+
continue;
|
|
292
|
+
if (this.armedAfterRecordId === undefined)
|
|
293
|
+
return undefined;
|
|
294
|
+
if (record.recordId <= this.armedAfterRecordId || record.recordId <= lastQueuedTriggerRecordId)
|
|
295
|
+
break;
|
|
296
|
+
if (this.channel.scope === "dm" || this.channel.scope === "web") {
|
|
297
|
+
if (!this.isOwnerMessage(record))
|
|
298
|
+
continue;
|
|
299
|
+
return record;
|
|
300
|
+
}
|
|
301
|
+
latest ??= record;
|
|
302
|
+
if (record.mentionedBot)
|
|
303
|
+
sawMention = true;
|
|
304
|
+
}
|
|
305
|
+
if (options.channelTrigger === "always")
|
|
306
|
+
return latest;
|
|
307
|
+
return sawMention ? latest : undefined;
|
|
308
|
+
}
|
|
309
|
+
async queueTrigger(record, trigger) {
|
|
310
|
+
const queuedRecord = {
|
|
311
|
+
type: "job_queued",
|
|
312
|
+
...buildRecordBase(this.channel, this.nextRecordId),
|
|
313
|
+
jobId: randomUUID(),
|
|
314
|
+
trigger,
|
|
315
|
+
triggerRecordId: record.recordId,
|
|
316
|
+
};
|
|
317
|
+
await this.appendRecord(queuedRecord);
|
|
318
|
+
const job = {
|
|
319
|
+
jobId: queuedRecord.jobId,
|
|
320
|
+
trigger: queuedRecord.trigger,
|
|
321
|
+
triggerRecordId: queuedRecord.triggerRecordId,
|
|
322
|
+
queuedRecordId: queuedRecord.recordId,
|
|
323
|
+
};
|
|
324
|
+
this.pendingJobs.push(job);
|
|
325
|
+
return job;
|
|
326
|
+
}
|
|
327
|
+
beginNextJob() {
|
|
328
|
+
if (this.activeJob || this.pendingJobs.length === 0)
|
|
329
|
+
return undefined;
|
|
330
|
+
const job = this.pendingJobs.shift();
|
|
331
|
+
if (!job)
|
|
332
|
+
return undefined;
|
|
333
|
+
this.activeJob = job;
|
|
334
|
+
const triggerRecord = getTriggerRecord(this.records, job);
|
|
335
|
+
return {
|
|
336
|
+
job,
|
|
337
|
+
prompt: this.buildPrompt(job),
|
|
338
|
+
attachments: this.buildPromptAttachments(job),
|
|
339
|
+
triggerMessageId: triggerRecord?.messageId,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
buildPrompt(job) {
|
|
343
|
+
const completedBoundary = this.getLastCompletedTriggerRecordId();
|
|
344
|
+
const slice = this.records.filter((record) => {
|
|
345
|
+
return (record.type === "inbound" && record.recordId > completedBoundary && record.recordId <= job.triggerRecordId);
|
|
346
|
+
});
|
|
347
|
+
return slice.map(formatPromptRecord).join("\n").trim();
|
|
348
|
+
}
|
|
349
|
+
buildPromptAttachments(job) {
|
|
350
|
+
const completedBoundary = this.getLastCompletedTriggerRecordId();
|
|
351
|
+
const slice = this.records.filter((record) => {
|
|
352
|
+
return (record.type === "inbound" && record.recordId > completedBoundary && record.recordId <= job.triggerRecordId);
|
|
353
|
+
});
|
|
354
|
+
return slice.flatMap((record) => record.attachments);
|
|
355
|
+
}
|
|
356
|
+
async noteOutbound(options) {
|
|
357
|
+
const text = options.text.trim();
|
|
358
|
+
if (!text && options.messageIds.length === 0)
|
|
359
|
+
return undefined;
|
|
360
|
+
const record = {
|
|
361
|
+
type: "outbound",
|
|
362
|
+
...buildRecordBase(this.channel, this.nextRecordId),
|
|
363
|
+
messageIds: options.messageIds,
|
|
364
|
+
webMessageId: options.webMessageId,
|
|
365
|
+
text,
|
|
366
|
+
attachments: options.attachments?.length ? options.attachments : undefined,
|
|
367
|
+
thinking: options.thinking,
|
|
368
|
+
thinkingMs: options.thinkingMs,
|
|
369
|
+
silent: options.silent || undefined,
|
|
370
|
+
replyToMessageId: options.replyToMessageId,
|
|
371
|
+
jobId: options.jobId,
|
|
372
|
+
control: options.control,
|
|
373
|
+
};
|
|
374
|
+
await this.appendRecord(record);
|
|
375
|
+
return record.recordId;
|
|
376
|
+
}
|
|
377
|
+
async completeActiveJob(options) {
|
|
378
|
+
const job = this.activeJob;
|
|
379
|
+
if (!job)
|
|
380
|
+
return;
|
|
381
|
+
const outboundRecordId = await this.noteOutbound({
|
|
382
|
+
text: options.text,
|
|
383
|
+
messageIds: options.messageIds,
|
|
384
|
+
webMessageId: options.webMessageId,
|
|
385
|
+
attachments: options.attachments,
|
|
386
|
+
thinking: options.thinking,
|
|
387
|
+
thinkingMs: options.thinkingMs,
|
|
388
|
+
silent: options.silent,
|
|
389
|
+
replyToMessageId: options.replyToMessageId,
|
|
390
|
+
jobId: job.jobId,
|
|
391
|
+
});
|
|
392
|
+
await this.appendRecord({
|
|
393
|
+
type: "job_completed",
|
|
394
|
+
...buildRecordBase(this.channel, this.nextRecordId),
|
|
395
|
+
jobId: job.jobId,
|
|
396
|
+
triggerRecordId: job.triggerRecordId,
|
|
397
|
+
outboundRecordId,
|
|
398
|
+
});
|
|
399
|
+
this.activeJob = undefined;
|
|
400
|
+
}
|
|
401
|
+
async noteAgentEvent(jobId, messageId, event, options = {}) {
|
|
402
|
+
await this.appendRecord({
|
|
403
|
+
type: "agent_event",
|
|
404
|
+
...buildRecordBase(this.channel, this.nextRecordId),
|
|
405
|
+
jobId,
|
|
406
|
+
messageId,
|
|
407
|
+
event,
|
|
408
|
+
}, options);
|
|
409
|
+
}
|
|
410
|
+
async failActiveJob(error) {
|
|
411
|
+
const job = this.activeJob;
|
|
412
|
+
if (!job)
|
|
413
|
+
return;
|
|
414
|
+
await this.appendRecord({
|
|
415
|
+
type: "job_failed",
|
|
416
|
+
...buildRecordBase(this.channel, this.nextRecordId),
|
|
417
|
+
jobId: job.jobId,
|
|
418
|
+
triggerRecordId: job.triggerRecordId,
|
|
419
|
+
error,
|
|
420
|
+
});
|
|
421
|
+
this.activeJob = undefined;
|
|
422
|
+
}
|
|
423
|
+
async noteCheckpoint(checkpoint) {
|
|
424
|
+
const previous = this.getLastCheckpoint();
|
|
425
|
+
if (previous.cursor === checkpoint.cursor && previous.messageId === checkpoint.messageId)
|
|
426
|
+
return;
|
|
427
|
+
await this.appendRecord({
|
|
428
|
+
type: "checkpoint",
|
|
429
|
+
...buildRecordBase(this.channel, this.nextRecordId),
|
|
430
|
+
cursor: checkpoint.cursor,
|
|
431
|
+
messageId: checkpoint.messageId,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
getLastCheckpoint() {
|
|
435
|
+
for (let index = this.records.length - 1; index >= 0; index--) {
|
|
436
|
+
const record = this.records[index];
|
|
437
|
+
if (record?.type === "checkpoint")
|
|
438
|
+
return { cursor: record.cursor, messageId: record.messageId };
|
|
439
|
+
}
|
|
440
|
+
return {};
|
|
441
|
+
}
|
|
442
|
+
async resetConversation(detail = "new conversation requested") {
|
|
443
|
+
this.pendingJobs = [];
|
|
444
|
+
this.activeJob = undefined;
|
|
445
|
+
this.armedAfterRecordId = this.records.at(-1)?.recordId ?? 0;
|
|
446
|
+
await this.appendRecord({
|
|
447
|
+
type: "runtime",
|
|
448
|
+
...buildRecordBase(this.channel, this.nextRecordId),
|
|
449
|
+
event: "reset",
|
|
450
|
+
detail,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
async appendError(message) {
|
|
454
|
+
await this.appendRecord({
|
|
455
|
+
type: "error",
|
|
456
|
+
...buildRecordBase(this.channel, this.nextRecordId),
|
|
457
|
+
message,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
getStatus() {
|
|
461
|
+
return {
|
|
462
|
+
channelKey: this.channelKey,
|
|
463
|
+
logDir: this.log.dir,
|
|
464
|
+
queueLength: this.pendingJobs.length,
|
|
465
|
+
hasActiveJob: this.activeJob !== undefined,
|
|
466
|
+
recordCount: this.records.length,
|
|
467
|
+
lastRecordId: this.records.at(-1)?.recordId ?? 0,
|
|
468
|
+
armed: this.armedAfterRecordId !== undefined,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
hasLiveWork() {
|
|
472
|
+
return this.activeJob !== undefined || this.pendingJobs.length > 0;
|
|
473
|
+
}
|
|
474
|
+
formatStatus() {
|
|
475
|
+
const status = this.getStatus();
|
|
476
|
+
const active = status.hasActiveJob ? "yes" : "no";
|
|
477
|
+
return [
|
|
478
|
+
`channel: ${status.channelKey}`,
|
|
479
|
+
`records: ${status.recordCount}`,
|
|
480
|
+
`last_record: ${status.lastRecordId}`,
|
|
481
|
+
`queued: ${status.queueLength}`,
|
|
482
|
+
`active_job: ${active}`,
|
|
483
|
+
`armed: ${status.armed ? "yes" : "no"}`,
|
|
484
|
+
`log_dir: ${status.logDir}`,
|
|
485
|
+
].join("\n");
|
|
486
|
+
}
|
|
487
|
+
formatInboundForLog(record) {
|
|
488
|
+
return `${formatAuthor(record.authorName, record.authorId)} @ ${formatLocalTimestamp(record.ts)}: ${record.text}`;
|
|
489
|
+
}
|
|
490
|
+
getRecords() {
|
|
491
|
+
return this.records;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
const stateWriteQueues = new Map();
|
|
4
|
+
function toDate(value) {
|
|
5
|
+
if (value instanceof Date)
|
|
6
|
+
return value;
|
|
7
|
+
return new Date(value);
|
|
8
|
+
}
|
|
9
|
+
function formatOffset(date) {
|
|
10
|
+
const offsetMinutes = -date.getTimezoneOffset();
|
|
11
|
+
const sign = offsetMinutes >= 0 ? "+" : "-";
|
|
12
|
+
const absolute = Math.abs(offsetMinutes);
|
|
13
|
+
const hours = Math.floor(absolute / 60);
|
|
14
|
+
const minutes = absolute % 60;
|
|
15
|
+
return minutes === 0 ? `GMT${sign}${hours}` : `GMT${sign}${hours}:${String(minutes).padStart(2, "0")}`;
|
|
16
|
+
}
|
|
17
|
+
export function formatLocalTimestamp(value) {
|
|
18
|
+
const date = toDate(value);
|
|
19
|
+
if (Number.isNaN(date.getTime()))
|
|
20
|
+
return String(value);
|
|
21
|
+
const localDate = [
|
|
22
|
+
date.getFullYear(),
|
|
23
|
+
String(date.getMonth() + 1).padStart(2, "0"),
|
|
24
|
+
String(date.getDate()).padStart(2, "0"),
|
|
25
|
+
].join("-");
|
|
26
|
+
const localTime = [
|
|
27
|
+
String(date.getHours()).padStart(2, "0"),
|
|
28
|
+
String(date.getMinutes()).padStart(2, "0"),
|
|
29
|
+
String(date.getSeconds()).padStart(2, "0"),
|
|
30
|
+
].join(":");
|
|
31
|
+
return `${localDate} ${localTime} ${formatOffset(date)}`;
|
|
32
|
+
}
|
|
33
|
+
export function formatIdleDuration(ms) {
|
|
34
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
35
|
+
return "0m";
|
|
36
|
+
const totalMinutes = Math.floor(ms / 60000);
|
|
37
|
+
const days = Math.floor(totalMinutes / 1440);
|
|
38
|
+
const hours = Math.floor((totalMinutes % 1440) / 60);
|
|
39
|
+
const minutes = totalMinutes % 60;
|
|
40
|
+
if (days > 0) {
|
|
41
|
+
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
|
42
|
+
}
|
|
43
|
+
if (hours > 0) {
|
|
44
|
+
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
45
|
+
}
|
|
46
|
+
return `${minutes}m`;
|
|
47
|
+
}
|
|
48
|
+
export function buildHeartbeatInjectionText(options) {
|
|
49
|
+
const nowDate = toDate(options.now);
|
|
50
|
+
const idleSinceDate = toDate(options.idleSince);
|
|
51
|
+
const idleDurationMs = Math.max(0, nowDate.getTime() - idleSinceDate.getTime());
|
|
52
|
+
const idleMinutes = Math.floor(idleDurationMs / 60000);
|
|
53
|
+
const body = options.body ??
|
|
54
|
+
`hey~ been quiet for a bit. this is your time now.
|
|
55
|
+
|
|
56
|
+
what you do with it is up to you — HEARTBEAT.md has the menu if you don't remember it. once you know the shape of it you don't have to re-read every fire, just trust what you remember and pick what fits.
|
|
57
|
+
|
|
58
|
+
it's okay to sit one out, but only when that's actually the real answer — not when it's the easy one.`;
|
|
59
|
+
return `<heartbeat local_time="${formatLocalTimestamp(nowDate)}" idle_duration="${formatIdleDuration(idleDurationMs)}" idle_minutes="${idleMinutes}">\n${body}\n</heartbeat>`;
|
|
60
|
+
}
|
|
61
|
+
function pad2(value) {
|
|
62
|
+
return String(value).padStart(2, "0");
|
|
63
|
+
}
|
|
64
|
+
function localDateKey(date) {
|
|
65
|
+
return [date.getFullYear(), pad2(date.getMonth() + 1), pad2(date.getDate())].join("-");
|
|
66
|
+
}
|
|
67
|
+
function parseTimeOfDay(value) {
|
|
68
|
+
const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(value.trim());
|
|
69
|
+
if (!match)
|
|
70
|
+
throw new Error(`Invalid cron time: ${value}`);
|
|
71
|
+
return { hour: Number(match[1]), minute: Number(match[2]) };
|
|
72
|
+
}
|
|
73
|
+
function parseLocalDateTime(value) {
|
|
74
|
+
const normalized = value.trim().replace("T", " ");
|
|
75
|
+
const match = /^(\d{4})-(\d{2})-(\d{2}) ([01]\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?$/.exec(normalized);
|
|
76
|
+
if (!match)
|
|
77
|
+
return new Date(value);
|
|
78
|
+
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]), Number(match[6] ?? 0));
|
|
79
|
+
}
|
|
80
|
+
function daysInMonth(year, month) {
|
|
81
|
+
return new Date(year, month + 1, 0).getDate();
|
|
82
|
+
}
|
|
83
|
+
function scheduledDate(year, month, day, time) {
|
|
84
|
+
const clampedDay = Math.min(day, daysInMonth(year, month));
|
|
85
|
+
// Local Date construction follows host timezone DST rules; rare skipped or repeated wall-clock hours are acceptable for v0.
|
|
86
|
+
return new Date(year, month, clampedDay, time.hour, time.minute, 0, 0);
|
|
87
|
+
}
|
|
88
|
+
function cronSlotKey(job, date) {
|
|
89
|
+
return `${job.id}:${job.frequency}:${localDateKey(date)}T${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
|
90
|
+
}
|
|
91
|
+
function latestScheduledDate(job, now) {
|
|
92
|
+
if (job.frequency === "once") {
|
|
93
|
+
if (!job.runAt)
|
|
94
|
+
return undefined;
|
|
95
|
+
const runAt = parseLocalDateTime(job.runAt);
|
|
96
|
+
return now.getTime() >= runAt.getTime() ? runAt : undefined;
|
|
97
|
+
}
|
|
98
|
+
if (job.frequency === "hourly") {
|
|
99
|
+
const minute = job.minute ?? 0;
|
|
100
|
+
const candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), minute, 0, 0);
|
|
101
|
+
if (candidate.getTime() <= now.getTime())
|
|
102
|
+
return candidate;
|
|
103
|
+
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours() - 1, minute, 0, 0);
|
|
104
|
+
}
|
|
105
|
+
const time = parseTimeOfDay(job.time ?? "00:00");
|
|
106
|
+
if (job.frequency === "daily") {
|
|
107
|
+
const candidate = scheduledDate(now.getFullYear(), now.getMonth(), now.getDate(), time);
|
|
108
|
+
if (candidate.getTime() <= now.getTime())
|
|
109
|
+
return candidate;
|
|
110
|
+
return scheduledDate(now.getFullYear(), now.getMonth(), now.getDate() - 1, time);
|
|
111
|
+
}
|
|
112
|
+
if (job.frequency === "weekly") {
|
|
113
|
+
const weekday = job.weekday ?? 0;
|
|
114
|
+
const daysSince = (now.getDay() - weekday + 7) % 7;
|
|
115
|
+
const candidate = scheduledDate(now.getFullYear(), now.getMonth(), now.getDate() - daysSince, time);
|
|
116
|
+
if (candidate.getTime() <= now.getTime())
|
|
117
|
+
return candidate;
|
|
118
|
+
return scheduledDate(now.getFullYear(), now.getMonth(), now.getDate() - daysSince - 7, time);
|
|
119
|
+
}
|
|
120
|
+
const day = job.day ?? 1;
|
|
121
|
+
const candidate = scheduledDate(now.getFullYear(), now.getMonth(), day, time);
|
|
122
|
+
if (candidate.getTime() <= now.getTime())
|
|
123
|
+
return candidate;
|
|
124
|
+
return scheduledDate(now.getFullYear(), now.getMonth() - 1, day, time);
|
|
125
|
+
}
|
|
126
|
+
export function dueCronSlot(job, state, now) {
|
|
127
|
+
if (!job.enabled)
|
|
128
|
+
return undefined;
|
|
129
|
+
if (state?.completed)
|
|
130
|
+
return undefined;
|
|
131
|
+
const nowDate = toDate(now);
|
|
132
|
+
const scheduled = latestScheduledDate(job, nowDate);
|
|
133
|
+
if (!scheduled)
|
|
134
|
+
return undefined;
|
|
135
|
+
const slot = cronSlotKey(job, scheduled);
|
|
136
|
+
return state?.lastFiredSlot === slot ? undefined : slot;
|
|
137
|
+
}
|
|
138
|
+
export function buildCronInjectionText(options) {
|
|
139
|
+
const nowDate = toDate(options.now);
|
|
140
|
+
return `<cron id="${options.job.id}" frequency="${options.job.frequency}" delivery="${options.job.deliveryMode}" local_time="${formatLocalTimestamp(nowDate)}" slot="${options.slot}">\n${options.job.prompt}\n</cron>`;
|
|
141
|
+
}
|
|
142
|
+
export function schedulerStatePath(dataDir) {
|
|
143
|
+
return resolve(dataDir, "scheduler", "cron-state.json");
|
|
144
|
+
}
|
|
145
|
+
export function schedulerLogPath(dataDir, now = new Date()) {
|
|
146
|
+
return resolve(dataDir, "scheduler", `${now.toISOString().slice(0, 10)}.jsonl`);
|
|
147
|
+
}
|
|
148
|
+
export async function loadSchedulerState(dataDir) {
|
|
149
|
+
const path = schedulerStatePath(dataDir);
|
|
150
|
+
try {
|
|
151
|
+
const raw = await readFile(path, "utf8");
|
|
152
|
+
const parsed = JSON.parse(raw);
|
|
153
|
+
return {
|
|
154
|
+
heartbeat: parsed.heartbeat && typeof parsed.heartbeat === "object" && !Array.isArray(parsed.heartbeat)
|
|
155
|
+
? parsed.heartbeat
|
|
156
|
+
: undefined,
|
|
157
|
+
cron: parsed.cron && typeof parsed.cron === "object" && !Array.isArray(parsed.cron) ? parsed.cron : {},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
|
|
162
|
+
return { cron: {} };
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export async function saveSchedulerState(dataDir, state) {
|
|
167
|
+
const path = schedulerStatePath(dataDir);
|
|
168
|
+
const prior = stateWriteQueues.get(path) ?? Promise.resolve();
|
|
169
|
+
const write = prior.then(async () => {
|
|
170
|
+
await mkdir(dirname(path), { recursive: true });
|
|
171
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
172
|
+
await writeFile(tempPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
173
|
+
await rename(tempPath, path);
|
|
174
|
+
});
|
|
175
|
+
const queued = write.catch(() => undefined);
|
|
176
|
+
stateWriteQueues.set(path, queued);
|
|
177
|
+
void queued.finally(() => {
|
|
178
|
+
if (stateWriteQueues.get(path) === queued)
|
|
179
|
+
stateWriteQueues.delete(path);
|
|
180
|
+
});
|
|
181
|
+
await write;
|
|
182
|
+
}
|
|
183
|
+
export async function appendSchedulerLog(dataDir, event) {
|
|
184
|
+
const record = { ...event, ts: event.ts ?? new Date().toISOString() };
|
|
185
|
+
const path = schedulerLogPath(dataDir);
|
|
186
|
+
await mkdir(dirname(path), { recursive: true });
|
|
187
|
+
await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
188
|
+
}
|
|
189
|
+
export function isHeartbeatDue(options) {
|
|
190
|
+
if (options.now < options.lastUserInteractionAt)
|
|
191
|
+
return false;
|
|
192
|
+
const idleDurationMs = options.now - options.lastUserInteractionAt;
|
|
193
|
+
if (idleDurationMs < options.idleThresholdMs)
|
|
194
|
+
return false;
|
|
195
|
+
const lastHeartbeatAt = options.lastHeartbeatAt ? Date.parse(options.lastHeartbeatAt) : undefined;
|
|
196
|
+
if (lastHeartbeatAt == null || !Number.isFinite(lastHeartbeatAt) || lastHeartbeatAt <= options.lastUserInteractionAt) {
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
return options.now - lastHeartbeatAt >= Math.max(0, options.intervalMs);
|
|
200
|
+
}
|