@qearlyao/familiar 0.2.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HEARTBEAT.md +1 -1
- package/README.md +33 -0
- package/config.example.toml +4 -2
- package/dist/{agent.js → agent/factory.js} +97 -328
- package/dist/agent/payload-normalizers.js +52 -0
- package/dist/agent/session-helpers.js +86 -0
- package/dist/agent/tool-descriptions.js +4 -0
- package/dist/agent/tools.js +30 -0
- package/dist/agent/transcript-log.js +93 -0
- package/dist/cli.js +45 -15
- package/dist/config/enums.js +35 -0
- package/dist/{config.js → config/index.js} +9 -272
- package/dist/config/interpolate.js +15 -0
- package/dist/config/model-refs.js +11 -0
- package/dist/{config-overrides.js → config/overrides.js} +1 -1
- package/dist/config/readers.js +116 -0
- package/dist/{config-registry.js → config/registry.js} +27 -8
- package/dist/config/sections.js +113 -0
- package/dist/{settings.js → config/settings.js} +5 -2
- package/dist/config/types.js +1 -0
- package/dist/{chat-log.js → conversation/chat-log.js} +16 -4
- package/dist/{contact-note.js → conversation/contact-note.js} +1 -1
- package/dist/conversation/ids.js +11 -0
- package/dist/conversation/owner-identity.js +29 -0
- package/dist/discord/channel.js +32 -0
- package/dist/discord/chunking.js +163 -0
- package/dist/discord/client.js +44 -0
- package/dist/discord/commands.js +181 -0
- package/dist/discord/daemon.js +379 -0
- package/dist/discord/inbound.js +44 -0
- package/dist/discord/send.js +115 -0
- package/dist/discord/turn.js +55 -0
- package/dist/index.js +12 -11
- package/dist/lifecycle/control.js +1 -0
- package/dist/{data-retention.js → lifecycle/data-retention.js} +1 -1
- package/dist/{hot-reload.js → lifecycle/hot-reload.js} +2 -2
- package/dist/{service.js → lifecycle/service.js} +1 -0
- package/dist/media/attachment-limits.js +3 -0
- package/dist/{generated-media.js → media/generated-media.js} +1 -1
- package/dist/{image-gen.js → media/image-gen.js} +2 -2
- package/dist/{inbound-attachments.js → media/inbound-attachments.js} +47 -43
- package/dist/media/media-understanding.js +215 -0
- package/dist/memory/index/store.js +21 -17
- package/dist/memory/index/vector-codec.js +2 -2
- package/dist/memory/lcm/context-transformer.js +6 -2
- package/dist/memory/lcm/segment-manager.js +6 -2
- package/dist/memory/lcm/store/index-ids.js +6 -0
- package/dist/memory/lcm/store/inserts.js +31 -0
- package/dist/memory/lcm/store/normalizers.js +91 -0
- package/dist/memory/lcm/store/row-mappers.js +114 -0
- package/dist/memory/lcm/store/row-types.js +1 -0
- package/dist/memory/lcm/store/serialization.js +37 -0
- package/dist/memory/lcm/store/snapshots.js +73 -0
- package/dist/memory/lcm/store.js +20 -360
- package/dist/memory/lcm/summarizer.js +1 -1
- package/dist/{added-models.js → models/added-models.js} +1 -1
- package/dist/{persona.js → prompting/persona.js} +1 -1
- package/dist/runtime/agent-core.js +82 -0
- package/dist/{agent-events.js → runtime/agent-events.js} +1 -1
- package/dist/runtime/agent-work-queue.js +55 -0
- package/dist/{runtime.js → runtime/conversation-runtime.js} +91 -43
- package/dist/runtime/runtime-manager.js +51 -0
- package/dist/runtime/scheduler-runner.js +243 -0
- package/dist/{scheduler.js → runtime/scheduler.js} +4 -4
- package/dist/{browser-tools.js → tools/browser-tools.js} +24 -34
- package/dist/util/fs.js +2 -1
- package/dist/web/agent-routes.js +104 -0
- package/dist/web/auth-routes.js +39 -0
- package/dist/web/auth.js +205 -0
- package/dist/web/config-routes.js +55 -0
- package/dist/web/conversation-routes.js +122 -0
- package/dist/web/daemon.js +108 -0
- package/dist/web/diary-routes.js +88 -0
- package/dist/web/errors.js +3 -0
- package/dist/web/event-hub.js +246 -0
- package/dist/{web-http.js → web/http.js} +19 -5
- package/dist/web/memes.js +25 -0
- package/dist/web/messages.js +348 -0
- package/dist/web/multipart.js +86 -0
- package/dist/web/payloads.js +34 -0
- package/dist/web/request-context.js +25 -0
- package/dist/web/route-helpers.js +9 -0
- package/dist/web/routes.js +37 -0
- package/dist/web/runtime-actions.js +231 -0
- package/dist/web/session-store.js +161 -0
- package/dist/{web-static.js → web/static.js} +19 -14
- package/dist/web/stream.js +78 -0
- package/dist/web-tools/cache.js +42 -0
- package/dist/web-tools/config.js +16 -0
- package/dist/web-tools/fetch-providers.js +119 -0
- package/dist/web-tools/format.js +88 -0
- package/dist/web-tools/http.js +81 -0
- package/dist/web-tools/index.js +152 -0
- package/dist/web-tools/routing.js +29 -0
- package/dist/web-tools/safety.js +73 -0
- package/dist/web-tools/search-providers.js +277 -0
- package/dist/web-tools/types.js +54 -0
- package/dist/web-tools/util.js +23 -0
- package/npm-shrinkwrap.json +319 -201
- package/package.json +6 -4
- package/web/dist/assets/index-C-k4O5Dz.js +6 -0
- package/web/dist/assets/index-Dj-L9nX4.css +2 -0
- package/web/dist/assets/markdown-kaIeGxdv.js +14 -0
- package/web/dist/assets/react-Bi_azaFt.js +9 -0
- package/web/dist/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
- package/web/dist/assets/ui-C12-nN_X.js +51 -0
- package/web/dist/assets/vendor-D1QXMhXm.js +16 -0
- package/web/dist/index.html +11 -3
- package/dist/discord.js +0 -1299
- package/dist/media-understanding.js +0 -120
- package/dist/web-auth.js +0 -111
- package/dist/web-tools.js +0 -941
- package/dist/web.js +0 -1209
- package/web/dist/assets/index-B23WT77N.js +0 -63
- package/web/dist/assets/index-D3MotFzN.css +0 -2
- /package/dist/{control.js → agent/types.js} +0 -0
- /package/dist/{image-derivatives.js → media/image-derivatives.js} +0 -0
- /package/dist/{tts.js → media/tts.js} +0 -0
- /package/dist/{models.js → models/index.js} +0 -0
- /package/dist/{skills.js → prompting/skills.js} +0 -0
- /package/dist/{silent-marker.js → runtime/silent-marker.js} +0 -0
- /package/dist/{web-events.js → web/events.js} +0 -0
- /package/dist/{web-types.js → web/types.js} +0 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { chatChannelKey } from "../conversation/chat-log.js";
|
|
2
|
+
import { createAgentWorkQueue } from "./agent-work-queue.js";
|
|
3
|
+
import { createRuntimeManager } from "./runtime-manager.js";
|
|
4
|
+
import { createSchedulerRunner } from "./scheduler-runner.js";
|
|
5
|
+
const webPersistenceOnlyDelivery = {
|
|
6
|
+
async deliver() {
|
|
7
|
+
return [];
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
export function createAgentCore(deps) {
|
|
11
|
+
let sessionSource;
|
|
12
|
+
const requireSessionSource = () => {
|
|
13
|
+
if (!sessionSource)
|
|
14
|
+
throw new Error("Owner identity is not established");
|
|
15
|
+
return sessionSource;
|
|
16
|
+
};
|
|
17
|
+
let schedulerStarted = false;
|
|
18
|
+
const runtimeManager = createRuntimeManager({
|
|
19
|
+
config: deps.config,
|
|
20
|
+
memoryService: deps.memoryService,
|
|
21
|
+
botUserId: () => requireSessionSource().botUserId,
|
|
22
|
+
});
|
|
23
|
+
const agentWork = createAgentWorkQueue({ familiarAgent: deps.familiarAgent });
|
|
24
|
+
const scheduler = createSchedulerRunner({
|
|
25
|
+
config: deps.config,
|
|
26
|
+
agentWork,
|
|
27
|
+
familiarAgent: deps.familiarAgent,
|
|
28
|
+
resolveDefaultSession: () => requireSessionSource().resolveDefaultSession(),
|
|
29
|
+
delivery: {
|
|
30
|
+
deliver: (options) => requireSessionSource().delivery.deliver(options),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
// The scheduler can only run once a session source exists; start it with the first
|
|
34
|
+
// source to arrive (cached identity at boot, or the live Discord connection).
|
|
35
|
+
const setSessionSource = async (source) => {
|
|
36
|
+
sessionSource = source;
|
|
37
|
+
if (!schedulerStarted) {
|
|
38
|
+
await scheduler.start();
|
|
39
|
+
schedulerStarted = true;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
attachDiscord(source) {
|
|
44
|
+
return setSessionSource(source);
|
|
45
|
+
},
|
|
46
|
+
useCachedIdentity(identity) {
|
|
47
|
+
const dmRef = { service: "discord", scope: "dm", channelId: identity.dmChannelId };
|
|
48
|
+
return setSessionSource({
|
|
49
|
+
botUserId: identity.botUserId,
|
|
50
|
+
resolveDefaultSession: async () => ({ runtime: await runtimeManager.getRuntimeForChannel(dmRef) }),
|
|
51
|
+
getWebSessions: async () => [
|
|
52
|
+
{ key: chatChannelKey(dmRef), label: "Main Chat", channel: dmRef, isDefault: true },
|
|
53
|
+
],
|
|
54
|
+
delivery: webPersistenceOnlyDelivery,
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
hasSessionSource() {
|
|
58
|
+
return sessionSource !== undefined;
|
|
59
|
+
},
|
|
60
|
+
getRuntimeForChannel: runtimeManager.getRuntimeForChannel,
|
|
61
|
+
peekRuntime: runtimeManager.peekRuntime,
|
|
62
|
+
getWebSessions() {
|
|
63
|
+
return requireSessionSource().getWebSessions();
|
|
64
|
+
},
|
|
65
|
+
async getRuntimeForWebChannel(channelKey) {
|
|
66
|
+
const sessions = await requireSessionSource().getWebSessions();
|
|
67
|
+
const session = channelKey ? sessions.find((candidate) => candidate.key === channelKey) : sessions[0];
|
|
68
|
+
if (!session)
|
|
69
|
+
throw new Error(channelKey ? `Unknown web session: ${channelKey}` : "No sessions available");
|
|
70
|
+
return runtimeManager.getRuntimeForChannel(session.channel);
|
|
71
|
+
},
|
|
72
|
+
promptForRuntime: agentWork.promptForRuntime,
|
|
73
|
+
get activeOwner() {
|
|
74
|
+
return agentWork.activeOwner;
|
|
75
|
+
},
|
|
76
|
+
rearmHeartbeat: scheduler.rearmHeartbeat,
|
|
77
|
+
stop() {
|
|
78
|
+
scheduler.stop();
|
|
79
|
+
return runtimeManager.disconnectAll();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { CRON_SKIPPED, canceledJobError, HEARTBEAT_SKIPPED } from "../discord/turn.js";
|
|
2
|
+
import { promptImagesFromAttachments } from "../media/inbound-attachments.js";
|
|
3
|
+
export function createAgentWorkQueue(deps) {
|
|
4
|
+
let activeAgentOwner;
|
|
5
|
+
let agentWorkQueue = Promise.resolve();
|
|
6
|
+
const enqueueAgentWork = (work) => {
|
|
7
|
+
const run = agentWorkQueue.then(work);
|
|
8
|
+
agentWorkQueue = run.then(() => undefined, () => undefined);
|
|
9
|
+
return run;
|
|
10
|
+
};
|
|
11
|
+
const promptForRuntime = async (runtime, jobId, prompt, attachments = [], onEvent, onTurnEnd) => {
|
|
12
|
+
return enqueueAgentWork(async () => {
|
|
13
|
+
if (!runtime.hasActiveJob(jobId))
|
|
14
|
+
throw canceledJobError();
|
|
15
|
+
activeAgentOwner = runtime.channelKey;
|
|
16
|
+
try {
|
|
17
|
+
const promptImages = await promptImagesFromAttachments(attachments);
|
|
18
|
+
const input = [prompt, promptImages.promptSuffix].filter(Boolean).join("\n");
|
|
19
|
+
const reply = await deps.familiarAgent.prompt(runtime.channelKey, input, promptImages.images, onEvent, {
|
|
20
|
+
referenceAttachments: attachments,
|
|
21
|
+
onTurnEnd,
|
|
22
|
+
});
|
|
23
|
+
if (!runtime.hasActiveJob(jobId))
|
|
24
|
+
throw canceledJobError();
|
|
25
|
+
return reply;
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
if (activeAgentOwner === runtime.channelKey)
|
|
29
|
+
activeAgentOwner = undefined;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
const promptScheduledMessage = async (runtime, buildMessage, onEvent, options) => {
|
|
34
|
+
return enqueueAgentWork(async () => {
|
|
35
|
+
const message = await buildMessage();
|
|
36
|
+
if (message === HEARTBEAT_SKIPPED || message === CRON_SKIPPED)
|
|
37
|
+
return message;
|
|
38
|
+
activeAgentOwner = runtime.channelKey;
|
|
39
|
+
try {
|
|
40
|
+
return await deps.familiarAgent.promptMessage(runtime.channelKey, message, onEvent, options);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
if (activeAgentOwner === runtime.channelKey)
|
|
44
|
+
activeAgentOwner = undefined;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
promptForRuntime,
|
|
50
|
+
promptScheduledMessage,
|
|
51
|
+
get activeOwner() {
|
|
52
|
+
return activeAgentOwner;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { buildRecordBase, } from "
|
|
3
|
-
import { promptAttachmentNotes } from "
|
|
4
|
-
import { formatLocalTimestamp } from "
|
|
2
|
+
import { buildRecordBase, hiddenWebMessageIds, } from "../conversation/chat-log.js";
|
|
3
|
+
import { promptAttachmentNotes } from "../media/inbound-attachments.js";
|
|
4
|
+
import { formatLocalTimestamp } from "../util/time.js";
|
|
5
5
|
function formatAuthor(authorName, authorId) {
|
|
6
6
|
return authorName ? `${authorName} (uid:${authorId})` : `uid:${authorId}`;
|
|
7
7
|
}
|
|
@@ -32,6 +32,9 @@ export class ConversationRuntime {
|
|
|
32
32
|
lastUserInteractionAt = 0;
|
|
33
33
|
pendingJobs = [];
|
|
34
34
|
activeJob;
|
|
35
|
+
lastQueuedTriggerRecordId = 0;
|
|
36
|
+
lastCompletedTriggerRecordId = 0;
|
|
37
|
+
queuedTriggerByJobId = new Map();
|
|
35
38
|
listeners = new Set();
|
|
36
39
|
agentEventListeners = new Set();
|
|
37
40
|
constructor(options) {
|
|
@@ -65,9 +68,13 @@ export class ConversationRuntime {
|
|
|
65
68
|
return Date.now();
|
|
66
69
|
}
|
|
67
70
|
rebuildPendingJobs() {
|
|
71
|
+
this.lastQueuedTriggerRecordId = 0;
|
|
72
|
+
this.lastCompletedTriggerRecordId = 0;
|
|
73
|
+
this.queuedTriggerByJobId.clear();
|
|
68
74
|
const terminalJobIds = new Set();
|
|
69
75
|
const queuedJobs = [];
|
|
70
76
|
for (const record of this.records) {
|
|
77
|
+
this.indexRecordForTriggers(record);
|
|
71
78
|
if (record.type === "job_completed" || record.type === "job_failed")
|
|
72
79
|
terminalJobIds.add(record.jobId);
|
|
73
80
|
if (record.type === "outbound" && record.jobId)
|
|
@@ -83,6 +90,21 @@ export class ConversationRuntime {
|
|
|
83
90
|
}
|
|
84
91
|
this.pendingJobs = queuedJobs.filter((job) => !terminalJobIds.has(job.jobId));
|
|
85
92
|
}
|
|
93
|
+
indexRecordForTriggers(record) {
|
|
94
|
+
if (record.type === "job_queued") {
|
|
95
|
+
this.queuedTriggerByJobId.set(record.jobId, record.triggerRecordId);
|
|
96
|
+
this.lastQueuedTriggerRecordId = Math.max(this.lastQueuedTriggerRecordId, record.triggerRecordId);
|
|
97
|
+
}
|
|
98
|
+
if (record.type === "job_completed") {
|
|
99
|
+
this.lastCompletedTriggerRecordId = Math.max(this.lastCompletedTriggerRecordId, record.triggerRecordId);
|
|
100
|
+
}
|
|
101
|
+
if (record.type === "outbound" && record.jobId) {
|
|
102
|
+
const triggerRecordId = this.queuedTriggerByJobId.get(record.jobId);
|
|
103
|
+
if (triggerRecordId !== undefined) {
|
|
104
|
+
this.lastCompletedTriggerRecordId = Math.max(this.lastCompletedTriggerRecordId, triggerRecordId);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
86
108
|
async disconnect() {
|
|
87
109
|
await this.log.release();
|
|
88
110
|
}
|
|
@@ -116,6 +138,7 @@ export class ConversationRuntime {
|
|
|
116
138
|
}
|
|
117
139
|
async appendRecord(record, options = {}) {
|
|
118
140
|
this.records.push(record);
|
|
141
|
+
this.indexRecordForTriggers(record);
|
|
119
142
|
this.nextRecordId = Math.max(this.nextRecordId, record.recordId + 1);
|
|
120
143
|
if (record.type === "inbound" && this.isOwnerMessage(record)) {
|
|
121
144
|
const parsed = Date.parse(record.ts);
|
|
@@ -151,36 +174,12 @@ export class ConversationRuntime {
|
|
|
151
174
|
return false;
|
|
152
175
|
return input.authorId === this.ownerId;
|
|
153
176
|
}
|
|
154
|
-
getLastQueuedTriggerRecordId() {
|
|
155
|
-
let last = 0;
|
|
156
|
-
for (const record of this.records) {
|
|
157
|
-
if (record.type === "job_queued")
|
|
158
|
-
last = Math.max(last, record.triggerRecordId);
|
|
159
|
-
}
|
|
160
|
-
return last;
|
|
161
|
-
}
|
|
162
|
-
getLastCompletedTriggerRecordId() {
|
|
163
|
-
let last = 0;
|
|
164
|
-
const queuedTriggerRecordIds = new Map();
|
|
165
|
-
for (const record of this.records) {
|
|
166
|
-
if (record.type === "job_queued")
|
|
167
|
-
queuedTriggerRecordIds.set(record.jobId, record.triggerRecordId);
|
|
168
|
-
if (record.type === "job_completed")
|
|
169
|
-
last = Math.max(last, record.triggerRecordId);
|
|
170
|
-
if (record.type === "outbound" && record.jobId) {
|
|
171
|
-
const triggerRecordId = queuedTriggerRecordIds.get(record.jobId);
|
|
172
|
-
if (triggerRecordId !== undefined)
|
|
173
|
-
last = Math.max(last, triggerRecordId);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return last;
|
|
177
|
-
}
|
|
178
177
|
canRecordTrigger(record, options = {}) {
|
|
179
178
|
if (this.armedAfterRecordId === undefined)
|
|
180
179
|
return undefined;
|
|
181
180
|
if (record.recordId <= this.armedAfterRecordId)
|
|
182
181
|
return undefined;
|
|
183
|
-
if (record.recordId <= this.
|
|
182
|
+
if (record.recordId <= this.lastQueuedTriggerRecordId)
|
|
184
183
|
return undefined;
|
|
185
184
|
if (this.channel.scope === "dm" || this.channel.scope === "web") {
|
|
186
185
|
if (!this.isOwnerMessage(record))
|
|
@@ -271,7 +270,7 @@ export class ConversationRuntime {
|
|
|
271
270
|
return formatPromptRecord(record);
|
|
272
271
|
}
|
|
273
272
|
getLatestQueueableInbound(options) {
|
|
274
|
-
const lastQueuedTriggerRecordId = this.
|
|
273
|
+
const lastQueuedTriggerRecordId = this.lastQueuedTriggerRecordId;
|
|
275
274
|
let latest;
|
|
276
275
|
let sawMention = false;
|
|
277
276
|
for (let index = this.records.length - 1; index >= 0; index--) {
|
|
@@ -321,26 +320,58 @@ export class ConversationRuntime {
|
|
|
321
320
|
return undefined;
|
|
322
321
|
this.activeJob = job;
|
|
323
322
|
const triggerRecord = getTriggerRecord(this.records, job);
|
|
323
|
+
const slice = this.triggerInboundSlice(job);
|
|
324
324
|
return {
|
|
325
325
|
job,
|
|
326
|
-
prompt:
|
|
327
|
-
attachments:
|
|
326
|
+
prompt: slice.map(formatPromptRecord).join("\n").trim(),
|
|
327
|
+
attachments: slice.flatMap((record) => record.attachments),
|
|
328
328
|
triggerMessageId: triggerRecord?.messageId,
|
|
329
329
|
};
|
|
330
330
|
}
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
331
|
+
latestLiveAssistantOutbound() {
|
|
332
|
+
const hidden = hiddenWebMessageIds(this.records);
|
|
333
|
+
for (let index = this.records.length - 1; index >= 0; index--) {
|
|
334
|
+
const record = this.records[index];
|
|
335
|
+
if (!record)
|
|
336
|
+
continue;
|
|
337
|
+
if (record.type === "runtime" && record.event === "reset")
|
|
338
|
+
return undefined;
|
|
339
|
+
if (record.type === "inbound")
|
|
340
|
+
return undefined;
|
|
341
|
+
if (record.type !== "outbound" || record.control || !record.jobId)
|
|
342
|
+
continue;
|
|
343
|
+
const messageId = record.webMessageId || record.messageIds[0];
|
|
344
|
+
if (!messageId || hidden.has(messageId))
|
|
345
|
+
continue;
|
|
346
|
+
return { record, messageId };
|
|
347
|
+
}
|
|
348
|
+
return undefined;
|
|
337
349
|
}
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
return
|
|
342
|
-
}
|
|
343
|
-
|
|
350
|
+
latestAssistantRetryTarget() {
|
|
351
|
+
const found = this.latestLiveAssistantOutbound();
|
|
352
|
+
if (!found)
|
|
353
|
+
return undefined;
|
|
354
|
+
const { record, messageId } = found;
|
|
355
|
+
if (this.assistantMessageWasAborted(messageId))
|
|
356
|
+
return undefined;
|
|
357
|
+
const triggerRecordId = record.jobId ? this.queuedTriggerByJobId.get(record.jobId) : undefined;
|
|
358
|
+
if (triggerRecordId === undefined)
|
|
359
|
+
return undefined;
|
|
360
|
+
return { messageId, triggerRecordId, attachments: record.attachments ?? [] };
|
|
361
|
+
}
|
|
362
|
+
latestAssistantDeleteTarget() {
|
|
363
|
+
const found = this.latestLiveAssistantOutbound();
|
|
364
|
+
return found ? { messageId: found.messageId } : undefined;
|
|
365
|
+
}
|
|
366
|
+
assistantMessageWasAborted(messageId) {
|
|
367
|
+
return this.records.some((record) => record.type === "agent_event" &&
|
|
368
|
+
record.messageId === messageId &&
|
|
369
|
+
record.event.type === "message_end" &&
|
|
370
|
+
record.event.stopReason === "aborted");
|
|
371
|
+
}
|
|
372
|
+
triggerInboundSlice(job) {
|
|
373
|
+
const completedBoundary = this.lastCompletedTriggerRecordId;
|
|
374
|
+
return this.records.filter((record) => record.type === "inbound" && record.recordId > completedBoundary && record.recordId <= job.triggerRecordId);
|
|
344
375
|
}
|
|
345
376
|
async noteOutbound(options) {
|
|
346
377
|
const text = options.text.trim();
|
|
@@ -396,6 +427,23 @@ export class ConversationRuntime {
|
|
|
396
427
|
event,
|
|
397
428
|
}, options);
|
|
398
429
|
}
|
|
430
|
+
async noteAssistantRetry(options) {
|
|
431
|
+
await this.appendRecord({
|
|
432
|
+
type: "assistant_retry",
|
|
433
|
+
...buildRecordBase(this.channel, this.nextRecordId),
|
|
434
|
+
oldMessageId: options.oldMessageId,
|
|
435
|
+
newMessageId: options.newMessageId,
|
|
436
|
+
jobId: options.jobId,
|
|
437
|
+
triggerRecordId: options.triggerRecordId,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
async noteMessageDelete(messageId) {
|
|
441
|
+
await this.appendRecord({
|
|
442
|
+
type: "message_delete",
|
|
443
|
+
...buildRecordBase(this.channel, this.nextRecordId),
|
|
444
|
+
messageId,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
399
447
|
async failActiveJob(error) {
|
|
400
448
|
const job = this.activeJob;
|
|
401
449
|
if (!job)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { chatChannelKey, createChatLog } from "../conversation/chat-log.js";
|
|
2
|
+
import { ConversationRuntime } from "./conversation-runtime.js";
|
|
3
|
+
export function createRuntimeManager(deps) {
|
|
4
|
+
const runtimes = new Map();
|
|
5
|
+
const openEntry = async (channel, channelKey) => {
|
|
6
|
+
const runtime = await ConversationRuntime.connect({
|
|
7
|
+
channelKey,
|
|
8
|
+
log: createChatLog(deps.config, channel),
|
|
9
|
+
ownerId: deps.config.discord.ownerId,
|
|
10
|
+
botUserId: deps.botUserId(),
|
|
11
|
+
});
|
|
12
|
+
const unsubscribe = deps.memoryService?.subscribeRuntime(runtime, runtime.channelKey);
|
|
13
|
+
const release = async () => {
|
|
14
|
+
unsubscribe?.();
|
|
15
|
+
await runtime.disconnect();
|
|
16
|
+
};
|
|
17
|
+
try {
|
|
18
|
+
await runtime.armAfterCurrentTail();
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
await release();
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
return { runtime, release };
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
async getRuntimeForChannel(channel) {
|
|
28
|
+
const channelKey = chatChannelKey(channel);
|
|
29
|
+
const existing = runtimes.get(channelKey);
|
|
30
|
+
if (existing)
|
|
31
|
+
return (await existing).runtime;
|
|
32
|
+
const entryPromise = openEntry(channel, channelKey);
|
|
33
|
+
runtimes.set(channelKey, entryPromise);
|
|
34
|
+
try {
|
|
35
|
+
return (await entryPromise).runtime;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
runtimes.delete(channelKey);
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
async peekRuntime(channelKey) {
|
|
43
|
+
return (await runtimes.get(channelKey)?.catch(() => undefined))?.runtime;
|
|
44
|
+
},
|
|
45
|
+
async disconnectAll() {
|
|
46
|
+
const entries = await Promise.all([...runtimes.values()].map((entry) => entry.catch(() => undefined)));
|
|
47
|
+
runtimes.clear();
|
|
48
|
+
await Promise.all(entries.flatMap((entry) => (entry ? [entry.release()] : [])));
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { CRON_SKIPPED, HEARTBEAT_SKIPPED, heartbeatStillDue, runAgentTurn, scheduledUserMessage, } from "../discord/turn.js";
|
|
2
|
+
import { thinkingDurationMs } from "./agent-events.js";
|
|
3
|
+
import { appendSchedulerLog, buildCronInjectionText, buildHeartbeatInjectionText, dueCronSlot, formatIdleDuration, loadSchedulerState, saveSchedulerState, } from "./scheduler.js";
|
|
4
|
+
export function createSchedulerRunner(deps) {
|
|
5
|
+
const { config, agentWork, familiarAgent, resolveDefaultSession, delivery } = deps;
|
|
6
|
+
let heartbeatTimer;
|
|
7
|
+
let cronTimer;
|
|
8
|
+
let heartbeatQueued = false;
|
|
9
|
+
let cronRunning = false;
|
|
10
|
+
let schedulerState = { cron: {} };
|
|
11
|
+
const saveScheduler = async () => {
|
|
12
|
+
await saveSchedulerState(config.workspace.dataDir, schedulerState);
|
|
13
|
+
};
|
|
14
|
+
const initializeHeartbeatState = async (runtime) => {
|
|
15
|
+
if (!config.heartbeat.enabled || schedulerState.heartbeat)
|
|
16
|
+
return;
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
const lastUserInteractionAt = runtime.getLastUserInteractionAt();
|
|
19
|
+
if (now - lastUserInteractionAt < config.heartbeat.idleThresholdMs)
|
|
20
|
+
return;
|
|
21
|
+
// Treat a cold start on an already-idle transcript as "we just fired at boot":
|
|
22
|
+
// the standard cadence/first-fire branches in isHeartbeatDue then handle user-reply
|
|
23
|
+
// vs. no-reply correctly without a separate suppression concept.
|
|
24
|
+
schedulerState.heartbeat = { lastFiredAt: new Date(now).toISOString() };
|
|
25
|
+
await saveScheduler();
|
|
26
|
+
};
|
|
27
|
+
const runHeartbeat = async () => {
|
|
28
|
+
if (!config.heartbeat.enabled)
|
|
29
|
+
return;
|
|
30
|
+
if (agentWork.activeOwner)
|
|
31
|
+
return;
|
|
32
|
+
if (heartbeatQueued)
|
|
33
|
+
return;
|
|
34
|
+
heartbeatQueued = true;
|
|
35
|
+
let runtime;
|
|
36
|
+
try {
|
|
37
|
+
const session = await resolveDefaultSession();
|
|
38
|
+
runtime = session.runtime;
|
|
39
|
+
const heartbeatRuntime = session.runtime;
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
if (heartbeatRuntime.hasLiveWork())
|
|
42
|
+
return;
|
|
43
|
+
const lastUserInteractionAt = heartbeatRuntime.getLastUserInteractionAt();
|
|
44
|
+
if (!heartbeatStillDue(config, now, lastUserInteractionAt, schedulerState.heartbeat?.lastFiredAt)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const turn = await runAgentTurn("heartbeat", heartbeatRuntime, (onEvent) => agentWork.promptScheduledMessage(heartbeatRuntime, async () => {
|
|
48
|
+
const queuedNow = Date.now();
|
|
49
|
+
const latestUserInteractionAt = heartbeatRuntime.getLastUserInteractionAt();
|
|
50
|
+
if (heartbeatRuntime.hasLiveWork())
|
|
51
|
+
return HEARTBEAT_SKIPPED;
|
|
52
|
+
if (!heartbeatStillDue(config, queuedNow, latestUserInteractionAt, schedulerState.heartbeat?.lastFiredAt)) {
|
|
53
|
+
return HEARTBEAT_SKIPPED;
|
|
54
|
+
}
|
|
55
|
+
schedulerState.heartbeat = { lastFiredAt: new Date(queuedNow).toISOString() };
|
|
56
|
+
await saveScheduler();
|
|
57
|
+
const text = buildHeartbeatInjectionText({ now: queuedNow, idleSince: latestUserInteractionAt });
|
|
58
|
+
await heartbeatRuntime.noteHeartbeat(`heartbeat stirred after ${formatIdleDuration(queuedNow - latestUserInteractionAt)}`);
|
|
59
|
+
return scheduledUserMessage(text, queuedNow);
|
|
60
|
+
}, onEvent, { skipAmbient: true }));
|
|
61
|
+
if (!turn)
|
|
62
|
+
return;
|
|
63
|
+
const { reply, parsedReply, summary, assistantMessageId } = turn;
|
|
64
|
+
const messageIds = await delivery.deliver({ reply, parsedReply });
|
|
65
|
+
await heartbeatRuntime.noteOutbound({
|
|
66
|
+
text: parsedReply.text,
|
|
67
|
+
messageIds,
|
|
68
|
+
webMessageId: assistantMessageId,
|
|
69
|
+
attachments: reply.attachments,
|
|
70
|
+
thinking: summary.thinking,
|
|
71
|
+
thinkingMs: thinkingDurationMs(summary),
|
|
72
|
+
silent: parsedReply.silent,
|
|
73
|
+
jobId: "heartbeat",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
78
|
+
await runtime?.noteHeartbeatFailure(message);
|
|
79
|
+
await runtime?.appendError(`Heartbeat failed: ${message}`);
|
|
80
|
+
console.error("Heartbeat failed", error);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
heartbeatQueued = false;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const markCronSlotStarted = async (job, slot) => {
|
|
87
|
+
schedulerState.cron[job.id] = {
|
|
88
|
+
lastFiredSlot: slot,
|
|
89
|
+
lastFiredAt: new Date().toISOString(),
|
|
90
|
+
...(schedulerState.cron[job.id]?.completed ? { completed: true } : {}),
|
|
91
|
+
};
|
|
92
|
+
await saveScheduler();
|
|
93
|
+
};
|
|
94
|
+
const completeCronSlot = async (job, slot) => {
|
|
95
|
+
schedulerState.cron[job.id] = {
|
|
96
|
+
...schedulerState.cron[job.id],
|
|
97
|
+
lastFiredSlot: slot,
|
|
98
|
+
lastFiredAt: schedulerState.cron[job.id]?.lastFiredAt ?? new Date().toISOString(),
|
|
99
|
+
...(job.frequency === "once" ? { completed: true } : {}),
|
|
100
|
+
};
|
|
101
|
+
await saveScheduler();
|
|
102
|
+
};
|
|
103
|
+
const runCronJob = async (job, slot, runtime) => {
|
|
104
|
+
await appendSchedulerLog(config.workspace.dataDir, {
|
|
105
|
+
type: "cron_due",
|
|
106
|
+
jobId: job.id,
|
|
107
|
+
slot,
|
|
108
|
+
deliveryMode: job.deliveryMode,
|
|
109
|
+
});
|
|
110
|
+
if (job.deliveryMode === "follow_up" && agentWork.activeOwner === runtime.channelKey) {
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
const text = buildCronInjectionText({ job, slot, now });
|
|
113
|
+
await appendSchedulerLog(config.workspace.dataDir, {
|
|
114
|
+
type: "cron_started",
|
|
115
|
+
jobId: job.id,
|
|
116
|
+
slot,
|
|
117
|
+
deliveryMode: job.deliveryMode,
|
|
118
|
+
});
|
|
119
|
+
await markCronSlotStarted(job, slot);
|
|
120
|
+
await familiarAgent.followUpMessage(runtime.channelKey, scheduledUserMessage(text, now), {
|
|
121
|
+
skipAmbient: true,
|
|
122
|
+
});
|
|
123
|
+
await completeCronSlot(job, slot);
|
|
124
|
+
await appendSchedulerLog(config.workspace.dataDir, {
|
|
125
|
+
type: "cron_completed",
|
|
126
|
+
jobId: job.id,
|
|
127
|
+
slot,
|
|
128
|
+
deliveryMode: job.deliveryMode,
|
|
129
|
+
detail: "queued as follow-up",
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const jobKey = `cron:${job.id}`;
|
|
134
|
+
const turn = await runAgentTurn(jobKey, runtime, (onEvent) => agentWork.promptScheduledMessage(runtime, async () => {
|
|
135
|
+
const jobState = schedulerState.cron[job.id];
|
|
136
|
+
if (jobState?.completed || jobState?.lastFiredSlot === slot)
|
|
137
|
+
return CRON_SKIPPED;
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
await appendSchedulerLog(config.workspace.dataDir, {
|
|
140
|
+
type: "cron_started",
|
|
141
|
+
jobId: job.id,
|
|
142
|
+
slot,
|
|
143
|
+
deliveryMode: job.deliveryMode,
|
|
144
|
+
});
|
|
145
|
+
await markCronSlotStarted(job, slot);
|
|
146
|
+
return scheduledUserMessage(buildCronInjectionText({ job, slot, now }), now);
|
|
147
|
+
}, onEvent, { skipAmbient: true }));
|
|
148
|
+
if (!turn) {
|
|
149
|
+
await appendSchedulerLog(config.workspace.dataDir, {
|
|
150
|
+
type: "cron_skipped",
|
|
151
|
+
jobId: job.id,
|
|
152
|
+
slot,
|
|
153
|
+
deliveryMode: job.deliveryMode,
|
|
154
|
+
detail: "already completed before prompt",
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const { reply, parsedReply, summary, assistantMessageId } = turn;
|
|
159
|
+
const messageIds = await delivery.deliver({ reply, parsedReply });
|
|
160
|
+
await runtime.noteOutbound({
|
|
161
|
+
text: parsedReply.text,
|
|
162
|
+
messageIds,
|
|
163
|
+
webMessageId: assistantMessageId,
|
|
164
|
+
attachments: reply.attachments,
|
|
165
|
+
thinking: summary.thinking,
|
|
166
|
+
thinkingMs: thinkingDurationMs(summary),
|
|
167
|
+
silent: parsedReply.silent,
|
|
168
|
+
jobId: jobKey,
|
|
169
|
+
});
|
|
170
|
+
await completeCronSlot(job, slot);
|
|
171
|
+
await appendSchedulerLog(config.workspace.dataDir, {
|
|
172
|
+
type: "cron_completed",
|
|
173
|
+
jobId: job.id,
|
|
174
|
+
slot,
|
|
175
|
+
deliveryMode: job.deliveryMode,
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
const tickCron = async () => {
|
|
179
|
+
if (!config.cron.enabled || cronRunning)
|
|
180
|
+
return;
|
|
181
|
+
cronRunning = true;
|
|
182
|
+
try {
|
|
183
|
+
const session = await resolveDefaultSession();
|
|
184
|
+
for (const job of config.cron.jobs) {
|
|
185
|
+
const slot = dueCronSlot(job, schedulerState.cron[job.id], Date.now());
|
|
186
|
+
if (!slot)
|
|
187
|
+
continue;
|
|
188
|
+
try {
|
|
189
|
+
await runCronJob(job, slot, session.runtime);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
193
|
+
await appendSchedulerLog(config.workspace.dataDir, {
|
|
194
|
+
type: "cron_failed",
|
|
195
|
+
jobId: job.id,
|
|
196
|
+
slot,
|
|
197
|
+
deliveryMode: job.deliveryMode,
|
|
198
|
+
detail: message,
|
|
199
|
+
});
|
|
200
|
+
await session.runtime.appendError(`Cron job ${job.id} failed: ${message}`);
|
|
201
|
+
console.error(`Cron job ${job.id} failed`, error);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
cronRunning = false;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const tickHeartbeat = () => {
|
|
210
|
+
void runHeartbeat().catch((error) => console.error("Heartbeat tick failed", error));
|
|
211
|
+
};
|
|
212
|
+
const rearmHeartbeat = () => {
|
|
213
|
+
if (heartbeatTimer) {
|
|
214
|
+
clearInterval(heartbeatTimer);
|
|
215
|
+
heartbeatTimer = undefined;
|
|
216
|
+
}
|
|
217
|
+
if (config.heartbeat.enabled) {
|
|
218
|
+
heartbeatTimer = setInterval(tickHeartbeat, Math.min(config.heartbeat.intervalMs, 60_000));
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
const start = async () => {
|
|
222
|
+
schedulerState = await loadSchedulerState(config.workspace.dataDir);
|
|
223
|
+
if (config.heartbeat.enabled) {
|
|
224
|
+
await initializeHeartbeatState((await resolveDefaultSession()).runtime);
|
|
225
|
+
rearmHeartbeat();
|
|
226
|
+
tickHeartbeat();
|
|
227
|
+
}
|
|
228
|
+
if (config.cron.enabled && config.cron.jobs.some((job) => job.enabled)) {
|
|
229
|
+
const runCronTick = () => {
|
|
230
|
+
void tickCron().catch((error) => console.error("Cron tick failed", error));
|
|
231
|
+
};
|
|
232
|
+
cronTimer = setInterval(runCronTick, config.cron.pollMs);
|
|
233
|
+
runCronTick();
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const stop = () => {
|
|
237
|
+
if (heartbeatTimer)
|
|
238
|
+
clearInterval(heartbeatTimer);
|
|
239
|
+
if (cronTimer)
|
|
240
|
+
clearInterval(cronTimer);
|
|
241
|
+
};
|
|
242
|
+
return { start, rearmHeartbeat, stop };
|
|
243
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { appendFile, mkdir, rename, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
|
-
import { readFileOrNull } from "
|
|
4
|
-
import { formatLocalTimestamp, toDate } from "
|
|
5
|
-
export { formatLocalTimestamp } from "
|
|
3
|
+
import { readFileOrNull } from "../util/fs.js";
|
|
4
|
+
import { formatLocalTimestamp, toDate } from "../util/time.js";
|
|
5
|
+
export { formatLocalTimestamp } from "../util/time.js";
|
|
6
6
|
const stateWriteQueues = new Map();
|
|
7
7
|
export function formatIdleDuration(ms) {
|
|
8
8
|
if (!Number.isFinite(ms) || ms <= 0)
|
|
@@ -54,7 +54,7 @@ function daysInMonth(year, month) {
|
|
|
54
54
|
}
|
|
55
55
|
function scheduledDate(year, month, day, time) {
|
|
56
56
|
const clampedDay = Math.min(day, daysInMonth(year, month));
|
|
57
|
-
// Local Date construction follows host timezone DST rules; rare skipped or repeated wall-clock hours are acceptable
|
|
57
|
+
// Local Date construction follows host timezone DST rules; rare skipped or repeated wall-clock hours are acceptable.
|
|
58
58
|
return new Date(year, month, clampedDay, time.hour, time.minute, 0, 0);
|
|
59
59
|
}
|
|
60
60
|
function cronSlotKey(job, date) {
|