@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.
Files changed (123) hide show
  1. package/HEARTBEAT.md +1 -1
  2. package/README.md +33 -0
  3. package/config.example.toml +4 -2
  4. package/dist/{agent.js → agent/factory.js} +97 -328
  5. package/dist/agent/payload-normalizers.js +52 -0
  6. package/dist/agent/session-helpers.js +86 -0
  7. package/dist/agent/tool-descriptions.js +4 -0
  8. package/dist/agent/tools.js +30 -0
  9. package/dist/agent/transcript-log.js +93 -0
  10. package/dist/cli.js +45 -15
  11. package/dist/config/enums.js +35 -0
  12. package/dist/{config.js → config/index.js} +9 -272
  13. package/dist/config/interpolate.js +15 -0
  14. package/dist/config/model-refs.js +11 -0
  15. package/dist/{config-overrides.js → config/overrides.js} +1 -1
  16. package/dist/config/readers.js +116 -0
  17. package/dist/{config-registry.js → config/registry.js} +27 -8
  18. package/dist/config/sections.js +113 -0
  19. package/dist/{settings.js → config/settings.js} +5 -2
  20. package/dist/config/types.js +1 -0
  21. package/dist/{chat-log.js → conversation/chat-log.js} +16 -4
  22. package/dist/{contact-note.js → conversation/contact-note.js} +1 -1
  23. package/dist/conversation/ids.js +11 -0
  24. package/dist/conversation/owner-identity.js +29 -0
  25. package/dist/discord/channel.js +32 -0
  26. package/dist/discord/chunking.js +163 -0
  27. package/dist/discord/client.js +44 -0
  28. package/dist/discord/commands.js +181 -0
  29. package/dist/discord/daemon.js +379 -0
  30. package/dist/discord/inbound.js +44 -0
  31. package/dist/discord/send.js +115 -0
  32. package/dist/discord/turn.js +55 -0
  33. package/dist/index.js +12 -11
  34. package/dist/lifecycle/control.js +1 -0
  35. package/dist/{data-retention.js → lifecycle/data-retention.js} +1 -1
  36. package/dist/{hot-reload.js → lifecycle/hot-reload.js} +2 -2
  37. package/dist/{service.js → lifecycle/service.js} +1 -0
  38. package/dist/media/attachment-limits.js +3 -0
  39. package/dist/{generated-media.js → media/generated-media.js} +1 -1
  40. package/dist/{image-gen.js → media/image-gen.js} +2 -2
  41. package/dist/{inbound-attachments.js → media/inbound-attachments.js} +47 -43
  42. package/dist/media/media-understanding.js +215 -0
  43. package/dist/memory/index/store.js +21 -17
  44. package/dist/memory/index/vector-codec.js +2 -2
  45. package/dist/memory/lcm/context-transformer.js +6 -2
  46. package/dist/memory/lcm/segment-manager.js +6 -2
  47. package/dist/memory/lcm/store/index-ids.js +6 -0
  48. package/dist/memory/lcm/store/inserts.js +31 -0
  49. package/dist/memory/lcm/store/normalizers.js +91 -0
  50. package/dist/memory/lcm/store/row-mappers.js +114 -0
  51. package/dist/memory/lcm/store/row-types.js +1 -0
  52. package/dist/memory/lcm/store/serialization.js +37 -0
  53. package/dist/memory/lcm/store/snapshots.js +73 -0
  54. package/dist/memory/lcm/store.js +20 -360
  55. package/dist/memory/lcm/summarizer.js +1 -1
  56. package/dist/{added-models.js → models/added-models.js} +1 -1
  57. package/dist/{persona.js → prompting/persona.js} +1 -1
  58. package/dist/runtime/agent-core.js +82 -0
  59. package/dist/{agent-events.js → runtime/agent-events.js} +1 -1
  60. package/dist/runtime/agent-work-queue.js +55 -0
  61. package/dist/{runtime.js → runtime/conversation-runtime.js} +91 -43
  62. package/dist/runtime/runtime-manager.js +51 -0
  63. package/dist/runtime/scheduler-runner.js +243 -0
  64. package/dist/{scheduler.js → runtime/scheduler.js} +4 -4
  65. package/dist/{browser-tools.js → tools/browser-tools.js} +24 -34
  66. package/dist/util/fs.js +2 -1
  67. package/dist/web/agent-routes.js +104 -0
  68. package/dist/web/auth-routes.js +39 -0
  69. package/dist/web/auth.js +205 -0
  70. package/dist/web/config-routes.js +55 -0
  71. package/dist/web/conversation-routes.js +122 -0
  72. package/dist/web/daemon.js +108 -0
  73. package/dist/web/diary-routes.js +88 -0
  74. package/dist/web/errors.js +3 -0
  75. package/dist/web/event-hub.js +246 -0
  76. package/dist/{web-http.js → web/http.js} +19 -5
  77. package/dist/web/memes.js +25 -0
  78. package/dist/web/messages.js +348 -0
  79. package/dist/web/multipart.js +86 -0
  80. package/dist/web/payloads.js +34 -0
  81. package/dist/web/request-context.js +25 -0
  82. package/dist/web/route-helpers.js +9 -0
  83. package/dist/web/routes.js +37 -0
  84. package/dist/web/runtime-actions.js +231 -0
  85. package/dist/web/session-store.js +161 -0
  86. package/dist/{web-static.js → web/static.js} +19 -14
  87. package/dist/web/stream.js +78 -0
  88. package/dist/web-tools/cache.js +42 -0
  89. package/dist/web-tools/config.js +16 -0
  90. package/dist/web-tools/fetch-providers.js +119 -0
  91. package/dist/web-tools/format.js +88 -0
  92. package/dist/web-tools/http.js +81 -0
  93. package/dist/web-tools/index.js +152 -0
  94. package/dist/web-tools/routing.js +29 -0
  95. package/dist/web-tools/safety.js +73 -0
  96. package/dist/web-tools/search-providers.js +277 -0
  97. package/dist/web-tools/types.js +54 -0
  98. package/dist/web-tools/util.js +23 -0
  99. package/npm-shrinkwrap.json +319 -201
  100. package/package.json +6 -4
  101. package/web/dist/assets/index-C-k4O5Dz.js +6 -0
  102. package/web/dist/assets/index-Dj-L9nX4.css +2 -0
  103. package/web/dist/assets/markdown-kaIeGxdv.js +14 -0
  104. package/web/dist/assets/react-Bi_azaFt.js +9 -0
  105. package/web/dist/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  106. package/web/dist/assets/ui-C12-nN_X.js +51 -0
  107. package/web/dist/assets/vendor-D1QXMhXm.js +16 -0
  108. package/web/dist/index.html +11 -3
  109. package/dist/discord.js +0 -1299
  110. package/dist/media-understanding.js +0 -120
  111. package/dist/web-auth.js +0 -111
  112. package/dist/web-tools.js +0 -941
  113. package/dist/web.js +0 -1209
  114. package/web/dist/assets/index-B23WT77N.js +0 -63
  115. package/web/dist/assets/index-D3MotFzN.css +0 -2
  116. /package/dist/{control.js → agent/types.js} +0 -0
  117. /package/dist/{image-derivatives.js → media/image-derivatives.js} +0 -0
  118. /package/dist/{tts.js → media/tts.js} +0 -0
  119. /package/dist/{models.js → models/index.js} +0 -0
  120. /package/dist/{skills.js → prompting/skills.js} +0 -0
  121. /package/dist/{silent-marker.js → runtime/silent-marker.js} +0 -0
  122. /package/dist/{web-events.js → web/events.js} +0 -0
  123. /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
+ }
@@ -1,4 +1,4 @@
1
- import { isRecord } from "./util/guards.js";
1
+ import { isRecord } from "../util/guards.js";
2
2
  function normalizeToolArguments(value) {
3
3
  return isRecord(value) ? value : {};
4
4
  }
@@ -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 "./chat-log.js";
3
- import { promptAttachmentNotes } from "./inbound-attachments.js";
4
- import { formatLocalTimestamp } from "./util/time.js";
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.getLastQueuedTriggerRecordId())
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.getLastQueuedTriggerRecordId();
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: this.buildPrompt(job),
327
- attachments: this.buildPromptAttachments(job),
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
- buildPrompt(job) {
332
- const completedBoundary = this.getLastCompletedTriggerRecordId();
333
- const slice = this.records.filter((record) => {
334
- return (record.type === "inbound" && record.recordId > completedBoundary && record.recordId <= job.triggerRecordId);
335
- });
336
- return slice.map(formatPromptRecord).join("\n").trim();
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
- buildPromptAttachments(job) {
339
- const completedBoundary = this.getLastCompletedTriggerRecordId();
340
- const slice = this.records.filter((record) => {
341
- return (record.type === "inbound" && record.recordId > completedBoundary && record.recordId <= job.triggerRecordId);
342
- });
343
- return slice.flatMap((record) => record.attachments);
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 "./util/fs.js";
4
- import { formatLocalTimestamp, toDate } from "./util/time.js";
5
- export { formatLocalTimestamp } from "./util/time.js";
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 for v0.
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) {