@qearlyao/familiar 0.2.5 → 0.3.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 (81) hide show
  1. package/README.md +4 -0
  2. package/config.example.toml +2 -2
  3. package/dist/agent/payload-normalizers.js +52 -0
  4. package/dist/agent/session-helpers.js +86 -0
  5. package/dist/agent/tool-descriptions.js +4 -0
  6. package/dist/agent/tools.js +30 -0
  7. package/dist/agent/transcript-log.js +93 -0
  8. package/dist/agent/types.js +1 -0
  9. package/dist/agent-core.js +82 -0
  10. package/dist/agent-work-queue.js +55 -0
  11. package/dist/agent.js +91 -322
  12. package/dist/browser-tools.js +7 -8
  13. package/dist/chat-log.js +15 -3
  14. package/dist/cli.js +36 -6
  15. package/dist/config/enums.js +35 -0
  16. package/dist/config/interpolate.js +15 -0
  17. package/dist/config/model-refs.js +11 -0
  18. package/dist/config/readers.js +116 -0
  19. package/dist/config/sections.js +113 -0
  20. package/dist/config/types.js +1 -0
  21. package/dist/config-registry.js +26 -7
  22. package/dist/config.js +8 -271
  23. package/dist/discord/channel.js +32 -0
  24. package/dist/discord/chunking.js +163 -0
  25. package/dist/discord/client.js +44 -0
  26. package/dist/discord/commands.js +181 -0
  27. package/dist/discord/inbound.js +44 -0
  28. package/dist/discord/send.js +106 -0
  29. package/dist/discord/turn.js +55 -0
  30. package/dist/discord.js +266 -1186
  31. package/dist/ids.js +11 -0
  32. package/dist/index.js +1 -0
  33. package/dist/memory/index/store.js +21 -17
  34. package/dist/memory/index/vector-codec.js +2 -2
  35. package/dist/memory/lcm/context-transformer.js +6 -2
  36. package/dist/memory/lcm/segment-manager.js +6 -2
  37. package/dist/memory/lcm/store/index-ids.js +6 -0
  38. package/dist/memory/lcm/store/inserts.js +31 -0
  39. package/dist/memory/lcm/store/normalizers.js +91 -0
  40. package/dist/memory/lcm/store/row-mappers.js +114 -0
  41. package/dist/memory/lcm/store/row-types.js +1 -0
  42. package/dist/memory/lcm/store/serialization.js +37 -0
  43. package/dist/memory/lcm/store/snapshots.js +73 -0
  44. package/dist/memory/lcm/store.js +20 -360
  45. package/dist/owner-identity.js +29 -0
  46. package/dist/runtime-manager.js +51 -0
  47. package/dist/runtime.js +89 -41
  48. package/dist/scheduler-runner.js +243 -0
  49. package/dist/scheduler.js +1 -1
  50. package/dist/service.js +1 -0
  51. package/dist/settings.js +3 -0
  52. package/dist/web/event-hub.js +246 -0
  53. package/dist/{web-http.js → web/http.js} +19 -5
  54. package/dist/web/memes.js +25 -0
  55. package/dist/web/messages.js +345 -0
  56. package/dist/web/multipart.js +80 -0
  57. package/dist/web/payloads.js +34 -0
  58. package/dist/{web-static.js → web/static.js} +19 -14
  59. package/dist/web/stream.js +69 -0
  60. package/dist/web-tools/cache.js +42 -0
  61. package/dist/web-tools/config.js +16 -0
  62. package/dist/web-tools/fetch-providers.js +119 -0
  63. package/dist/web-tools/format.js +88 -0
  64. package/dist/web-tools/http.js +81 -0
  65. package/dist/web-tools/routing.js +29 -0
  66. package/dist/web-tools/safety.js +73 -0
  67. package/dist/web-tools/search-providers.js +277 -0
  68. package/dist/web-tools/types.js +54 -0
  69. package/dist/web-tools/util.js +23 -0
  70. package/dist/web-tools.js +9 -798
  71. package/dist/web.js +416 -984
  72. package/npm-shrinkwrap.json +242 -201
  73. package/package.json +4 -4
  74. package/web/dist/assets/index-CSkxUQCr.js +63 -0
  75. package/web/dist/assets/index-DllM6RqL.css +2 -0
  76. package/web/dist/index.html +6 -3
  77. package/web/dist/assets/index-B23WT77N.js +0 -63
  78. package/web/dist/assets/index-D3MotFzN.css +0 -2
  79. /package/dist/{web-auth.js → web/auth.js} +0 -0
  80. /package/dist/{web-events.js → web/events.js} +0 -0
  81. /package/dist/{web-types.js → web/types.js} +0 -0
package/dist/runtime.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { buildRecordBase, } from "./chat-log.js";
2
+ import { buildRecordBase, hiddenWebMessageIds, } from "./chat-log.js";
3
3
  import { promptAttachmentNotes } from "./inbound-attachments.js";
4
4
  import { formatLocalTimestamp } from "./util/time.js";
5
5
  function formatAuthor(authorName, authorId) {
@@ -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,243 @@
1
+ import { thinkingDurationMs } from "./agent-events.js";
2
+ import { CRON_SKIPPED, HEARTBEAT_SKIPPED, heartbeatStillDue, runAgentTurn, scheduledUserMessage, } from "./discord/turn.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
+ }
package/dist/scheduler.js CHANGED
@@ -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) {
package/dist/service.js CHANGED
@@ -279,6 +279,7 @@ export async function upgradeFamiliar(workspacePath, options = {}) {
279
279
  const npmCommand = currentPlatform === "win32" ? "npm.cmd" : "npm";
280
280
  const familiarCommand = currentPlatform === "win32" ? "familiar.cmd" : "familiar";
281
281
  await runInteractive(npmCommand, ["install", "-g", "@qearlyao/familiar@latest"], options, "npm upgrade");
282
+ await runInteractive(npmCommand, ["install", "-g", "@jackwener/opencli"], options, "OpenCLI upgrade");
282
283
  await runInteractive(familiarCommand, ["init", workspacePath], options, "workspace default refresh");
283
284
  }
284
285
  export function formatServiceResult(result) {
package/dist/settings.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { resolve } from "node:path";
2
2
  import { isThinkingLevel } from "./models.js";
3
3
  import { atomicWriteJson, createWriteQueue, readFileOrNull } from "./util/fs.js";
4
+ export function formatSetting(setting) {
5
+ return `${setting.value} (${setting.source})`;
6
+ }
4
7
  function isChannelTrigger(value) {
5
8
  return value === "mention" || value === "always";
6
9
  }