@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,231 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { formatSetting } from "../config/settings.js";
|
|
3
|
+
import { messageId } from "../conversation/ids.js";
|
|
4
|
+
import { createAgentEventRecorder, storedAgentEventFromAgentEvent, thinkingDurationMs, updateAgentEventSummary, } from "../runtime/agent-events.js";
|
|
5
|
+
import { parseAgentReply } from "../runtime/silent-marker.js";
|
|
6
|
+
import { errorMessage } from "./errors.js";
|
|
7
|
+
import { webAttachments } from "./messages.js";
|
|
8
|
+
function replyPersistenceFields(reply) {
|
|
9
|
+
return {
|
|
10
|
+
text: reply.text,
|
|
11
|
+
messageIds: [reply.messageId],
|
|
12
|
+
webMessageId: reply.messageId,
|
|
13
|
+
attachments: reply.attachments,
|
|
14
|
+
thinking: reply.thinking,
|
|
15
|
+
thinkingMs: reply.thinkingMs,
|
|
16
|
+
silent: reply.silent,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function createWebRuntimeActions(options) {
|
|
20
|
+
const { config, familiarAgent, agentCore, eventHub, personaName, restart } = options;
|
|
21
|
+
const { appendAndPublishError, publish, publishDelta } = eventHub;
|
|
22
|
+
const promptAssistantMessage = async (input) => {
|
|
23
|
+
const { runtime, jobId, assistantMessageId } = input;
|
|
24
|
+
const summary = { thinking: "" };
|
|
25
|
+
const recorder = createAgentEventRecorder((storedEvent) => runtime.noteAgentEvent(jobId, assistantMessageId, storedEvent, { notify: false }));
|
|
26
|
+
let started = false;
|
|
27
|
+
let reply;
|
|
28
|
+
try {
|
|
29
|
+
reply = await input.dispatch(async (event) => {
|
|
30
|
+
if (event.type === "message_start" && event.message.role === "assistant" && !started) {
|
|
31
|
+
started = true;
|
|
32
|
+
input.onAssistantStart?.();
|
|
33
|
+
}
|
|
34
|
+
updateAgentEventSummary(summary, event);
|
|
35
|
+
const storedEvent = storedAgentEventFromAgentEvent(event);
|
|
36
|
+
if (storedEvent) {
|
|
37
|
+
runtime.publishAgentEvent(jobId, assistantMessageId, storedEvent);
|
|
38
|
+
await recorder.record(storedEvent);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
await recorder.flush();
|
|
44
|
+
}
|
|
45
|
+
const parsed = parseAgentReply(reply.text);
|
|
46
|
+
const finalText = parsed.silent ? "" : reply.text;
|
|
47
|
+
if (!started) {
|
|
48
|
+
input.onAssistantStart?.();
|
|
49
|
+
if (!parsed.silent) {
|
|
50
|
+
publish({
|
|
51
|
+
type: "message_started",
|
|
52
|
+
channelKey: runtime.channelKey,
|
|
53
|
+
messageId: assistantMessageId,
|
|
54
|
+
role: "assistant",
|
|
55
|
+
who: personaName,
|
|
56
|
+
});
|
|
57
|
+
if (finalText) {
|
|
58
|
+
publishDelta(runtime.channelKey, assistantMessageId, "text", finalText);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const thinkingMs = thinkingDurationMs(summary);
|
|
63
|
+
publish({
|
|
64
|
+
type: "message_completed",
|
|
65
|
+
channelKey: runtime.channelKey,
|
|
66
|
+
messageId: assistantMessageId,
|
|
67
|
+
thinkingMs,
|
|
68
|
+
attachments: webAttachments(config, reply.attachments),
|
|
69
|
+
silent: parsed.silent || undefined,
|
|
70
|
+
});
|
|
71
|
+
eventHub.markLocallyStreamed(assistantMessageId);
|
|
72
|
+
return {
|
|
73
|
+
text: finalText,
|
|
74
|
+
messageId: assistantMessageId,
|
|
75
|
+
thinking: summary.thinking,
|
|
76
|
+
thinkingMs,
|
|
77
|
+
attachments: reply.attachments,
|
|
78
|
+
silent: parsed.silent,
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
const promptForRuntime = async (runtime, jobId, prompt, attachments = [], onTurnEnd) => {
|
|
82
|
+
return promptAssistantMessage({
|
|
83
|
+
runtime,
|
|
84
|
+
jobId,
|
|
85
|
+
assistantMessageId: messageId(),
|
|
86
|
+
dispatch: (onEvent) => agentCore.promptForRuntime(runtime, jobId, prompt, attachments, onEvent, onTurnEnd),
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
const retryLatestAssistant = async (runtime) => {
|
|
90
|
+
if (runtime.hasActiveJob())
|
|
91
|
+
throw new Error("Cannot retry while a turn is running");
|
|
92
|
+
const target = runtime.latestAssistantRetryTarget();
|
|
93
|
+
if (!target)
|
|
94
|
+
throw new Error("No assistant message to retry");
|
|
95
|
+
const jobId = randomUUID();
|
|
96
|
+
const assistantMessageId = messageId();
|
|
97
|
+
const replaceMessage = () => {
|
|
98
|
+
publish({
|
|
99
|
+
type: "message_replaced",
|
|
100
|
+
channelKey: runtime.channelKey,
|
|
101
|
+
oldMessageId: target.messageId,
|
|
102
|
+
newMessageId: assistantMessageId,
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
const reply = await promptAssistantMessage({
|
|
107
|
+
runtime,
|
|
108
|
+
jobId,
|
|
109
|
+
assistantMessageId,
|
|
110
|
+
onAssistantStart: replaceMessage,
|
|
111
|
+
dispatch: (onEvent) => familiarAgent.retryLastAssistant(runtime.channelKey, onEvent, {
|
|
112
|
+
onTurnEnd: () => {
|
|
113
|
+
publish({
|
|
114
|
+
type: "status",
|
|
115
|
+
channelKey: runtime.channelKey,
|
|
116
|
+
kind: "idle",
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
await runtime.noteAssistantRetry({
|
|
122
|
+
oldMessageId: target.messageId,
|
|
123
|
+
newMessageId: assistantMessageId,
|
|
124
|
+
jobId,
|
|
125
|
+
triggerRecordId: target.triggerRecordId,
|
|
126
|
+
});
|
|
127
|
+
await runtime.noteOutbound({
|
|
128
|
+
...replyPersistenceFields(reply),
|
|
129
|
+
replyToMessageId: target.messageId,
|
|
130
|
+
jobId,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
const message = errorMessage(error);
|
|
135
|
+
await appendAndPublishError(runtime, message);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
const deleteLatestAssistant = async (runtime) => {
|
|
139
|
+
if (runtime.hasActiveJob())
|
|
140
|
+
throw new Error("Cannot delete while a turn is running");
|
|
141
|
+
const target = runtime.latestAssistantDeleteTarget();
|
|
142
|
+
if (!target)
|
|
143
|
+
throw new Error("No assistant message to delete");
|
|
144
|
+
try {
|
|
145
|
+
await familiarAgent.deleteLastAssistant(runtime.channelKey);
|
|
146
|
+
await runtime.noteMessageDelete(target.messageId);
|
|
147
|
+
publish({
|
|
148
|
+
type: "message_deleted",
|
|
149
|
+
channelKey: runtime.channelKey,
|
|
150
|
+
messageId: target.messageId,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
const message = errorMessage(error);
|
|
155
|
+
await appendAndPublishError(runtime, message);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const drainJobs = async (runtime) => {
|
|
159
|
+
for (;;) {
|
|
160
|
+
const dispatch = runtime.beginNextJob();
|
|
161
|
+
if (!dispatch)
|
|
162
|
+
return;
|
|
163
|
+
try {
|
|
164
|
+
const reply = await promptForRuntime(runtime, dispatch.job.jobId, dispatch.prompt, dispatch.attachments, () => {
|
|
165
|
+
publish({
|
|
166
|
+
type: "status",
|
|
167
|
+
channelKey: runtime.channelKey,
|
|
168
|
+
kind: "idle",
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
await runtime.completeActiveJob({
|
|
172
|
+
...replyPersistenceFields(reply),
|
|
173
|
+
replyToMessageId: dispatch.triggerMessageId,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (!runtime.hasActiveJob(dispatch.job.jobId))
|
|
178
|
+
return;
|
|
179
|
+
const message = errorMessage(error);
|
|
180
|
+
await runtime.failActiveJob(message);
|
|
181
|
+
await appendAndPublishError(runtime, message);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const applyControlCommand = async (runtime, control) => {
|
|
186
|
+
if (control.command === "stop") {
|
|
187
|
+
await familiarAgent.abort(runtime.channelKey);
|
|
188
|
+
return "Stopped current work.";
|
|
189
|
+
}
|
|
190
|
+
if (control.command === "new") {
|
|
191
|
+
await familiarAgent.reset(runtime.channelKey);
|
|
192
|
+
await runtime.resetConversation("new conversation requested");
|
|
193
|
+
return "Started a fresh agent transcript for this channel.";
|
|
194
|
+
}
|
|
195
|
+
if (control.command === "reload") {
|
|
196
|
+
return familiarAgent.reload();
|
|
197
|
+
}
|
|
198
|
+
if (control.command === "restart") {
|
|
199
|
+
return restart
|
|
200
|
+
? await restart()
|
|
201
|
+
: "Restart requested, but no restart handler is configured. Please restart the Familiar process manually.";
|
|
202
|
+
}
|
|
203
|
+
if (control.command === "model") {
|
|
204
|
+
return control.args
|
|
205
|
+
? await familiarAgent.setModel(runtime.channelKey, control.args)
|
|
206
|
+
: `Current model: ${formatSetting(familiarAgent.getModel(runtime.channelKey))}`;
|
|
207
|
+
}
|
|
208
|
+
if (control.command === "thinking") {
|
|
209
|
+
return control.args
|
|
210
|
+
? await familiarAgent.setThinkingLevel(runtime.channelKey, control.args)
|
|
211
|
+
: `Current thinking: ${formatSetting(familiarAgent.getThinkingLevel(runtime.channelKey))}`;
|
|
212
|
+
}
|
|
213
|
+
if (control.command === "channel-trigger")
|
|
214
|
+
return "Use Discord /familiar channel-trigger in the channel for now.";
|
|
215
|
+
if (control.command === "status") {
|
|
216
|
+
return [
|
|
217
|
+
runtime.formatStatus(),
|
|
218
|
+
`model: ${formatSetting(familiarAgent.getModel(runtime.channelKey))}`,
|
|
219
|
+
`thinking: ${formatSetting(familiarAgent.getThinkingLevel(runtime.channelKey))}`,
|
|
220
|
+
].join("\n");
|
|
221
|
+
}
|
|
222
|
+
return "Compact is not wired for this runtime yet. I logged the command, but I won't run lossy compaction here.";
|
|
223
|
+
};
|
|
224
|
+
return {
|
|
225
|
+
promptForRuntime,
|
|
226
|
+
retryLatestAssistant,
|
|
227
|
+
deleteLatestAssistant,
|
|
228
|
+
drainJobs,
|
|
229
|
+
applyControlCommand,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { atomicWriteJson, createWriteQueue, readFileOrNull } from "../util/fs.js";
|
|
4
|
+
import { isRecord } from "../util/guards.js";
|
|
5
|
+
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
6
|
+
const SESSION_TOUCH_INTERVAL_MS = 60 * 1000;
|
|
7
|
+
function sha256(value) {
|
|
8
|
+
return createHash("sha256").update(value).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
function normalizeString(value) {
|
|
11
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
12
|
+
}
|
|
13
|
+
function normalizeDeviceName(value, fallback) {
|
|
14
|
+
const name = normalizeString(value) ?? fallback;
|
|
15
|
+
return name.slice(0, 80);
|
|
16
|
+
}
|
|
17
|
+
function deviceDto(session, current = false) {
|
|
18
|
+
return {
|
|
19
|
+
id: session.id,
|
|
20
|
+
deviceName: session.deviceName,
|
|
21
|
+
createdAt: session.createdAt,
|
|
22
|
+
lastSeenAt: session.lastSeenAt,
|
|
23
|
+
expiresAt: session.expiresAt,
|
|
24
|
+
...(session.lastIp ? { lastIp: session.lastIp } : {}),
|
|
25
|
+
...(session.userAgent ? { userAgent: session.userAgent } : {}),
|
|
26
|
+
...(current ? { current: true } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function isActive(session, now) {
|
|
30
|
+
return !session.revokedAt && Date.parse(session.expiresAt) > now;
|
|
31
|
+
}
|
|
32
|
+
function normalizeSession(value) {
|
|
33
|
+
if (!isRecord(value))
|
|
34
|
+
return undefined;
|
|
35
|
+
const id = normalizeString(value.id);
|
|
36
|
+
const tokenHash = normalizeString(value.tokenHash);
|
|
37
|
+
const deviceName = normalizeString(value.deviceName);
|
|
38
|
+
const createdAt = normalizeString(value.createdAt);
|
|
39
|
+
const lastSeenAt = normalizeString(value.lastSeenAt);
|
|
40
|
+
const expiresAt = normalizeString(value.expiresAt);
|
|
41
|
+
if (!id || !tokenHash || !deviceName || !createdAt || !lastSeenAt || !expiresAt)
|
|
42
|
+
return undefined;
|
|
43
|
+
return {
|
|
44
|
+
id,
|
|
45
|
+
tokenHash,
|
|
46
|
+
deviceName,
|
|
47
|
+
createdAt,
|
|
48
|
+
lastSeenAt,
|
|
49
|
+
expiresAt,
|
|
50
|
+
lastIp: normalizeString(value.lastIp),
|
|
51
|
+
userAgent: normalizeString(value.userAgent),
|
|
52
|
+
revokedAt: normalizeString(value.revokedAt),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function normalizeSessionsFile(value, now) {
|
|
56
|
+
if (!isRecord(value) || !Array.isArray(value.sessions))
|
|
57
|
+
return { version: 1, sessions: [] };
|
|
58
|
+
return {
|
|
59
|
+
version: 1,
|
|
60
|
+
sessions: value.sessions
|
|
61
|
+
.map(normalizeSession)
|
|
62
|
+
.filter((session) => !!session && isActive(session, now)),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async function readSessionsFile(path, now) {
|
|
66
|
+
const raw = await readFileOrNull(path, "utf8");
|
|
67
|
+
return raw === null ? { version: 1, sessions: [] } : normalizeSessionsFile(JSON.parse(raw), now);
|
|
68
|
+
}
|
|
69
|
+
export async function loadWebSessionStore(config, now = Date.now()) {
|
|
70
|
+
const path = resolve(config.workspace.dataDir, "settings", "web-sessions.json");
|
|
71
|
+
let file = await readSessionsFile(path, now);
|
|
72
|
+
const enqueueWrite = createWriteQueue("web sessions");
|
|
73
|
+
const enqueuePersist = () => enqueueWrite(() => atomicWriteJson(path, file));
|
|
74
|
+
const findByToken = (token, now) => {
|
|
75
|
+
if (!token)
|
|
76
|
+
return undefined;
|
|
77
|
+
const tokenHash = sha256(token);
|
|
78
|
+
return file.sessions.find((session) => session.tokenHash === tokenHash && isActive(session, now));
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
path,
|
|
82
|
+
async createSession(input) {
|
|
83
|
+
const now = input.context.now;
|
|
84
|
+
const ts = new Date(now).toISOString();
|
|
85
|
+
const token = randomBytes(32).toString("base64url");
|
|
86
|
+
const session = {
|
|
87
|
+
id: randomBytes(12).toString("base64url"),
|
|
88
|
+
tokenHash: sha256(token),
|
|
89
|
+
deviceName: normalizeDeviceName(input.deviceName, "browser"),
|
|
90
|
+
createdAt: ts,
|
|
91
|
+
lastSeenAt: ts,
|
|
92
|
+
expiresAt: new Date(now + SESSION_TTL_MS).toISOString(),
|
|
93
|
+
lastIp: input.context.clientIp,
|
|
94
|
+
userAgent: input.context.userAgent,
|
|
95
|
+
};
|
|
96
|
+
file = { version: 1, sessions: [...file.sessions.filter((item) => isActive(item, now)), session] };
|
|
97
|
+
await enqueuePersist();
|
|
98
|
+
return { token, device: deviceDto(session, true) };
|
|
99
|
+
},
|
|
100
|
+
async authenticateSession(token, context) {
|
|
101
|
+
const session = findByToken(token, context.now);
|
|
102
|
+
if (!session)
|
|
103
|
+
return undefined;
|
|
104
|
+
const touchedAt = Date.parse(session.lastSeenAt);
|
|
105
|
+
const nextExpiresAt = new Date(context.now + SESSION_TTL_MS).toISOString();
|
|
106
|
+
const changed = context.now - touchedAt >= SESSION_TOUCH_INTERVAL_MS ||
|
|
107
|
+
session.lastIp !== context.clientIp ||
|
|
108
|
+
session.userAgent !== context.userAgent;
|
|
109
|
+
if (changed) {
|
|
110
|
+
session.lastSeenAt = new Date(context.now).toISOString();
|
|
111
|
+
session.expiresAt = nextExpiresAt;
|
|
112
|
+
session.lastIp = context.clientIp;
|
|
113
|
+
session.userAgent = context.userAgent;
|
|
114
|
+
await enqueuePersist();
|
|
115
|
+
}
|
|
116
|
+
return deviceDto(session, true);
|
|
117
|
+
},
|
|
118
|
+
currentDevice(token) {
|
|
119
|
+
const session = findByToken(token, Date.now());
|
|
120
|
+
return session ? deviceDto(session, true) : undefined;
|
|
121
|
+
},
|
|
122
|
+
listDevices(currentToken) {
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
const currentHash = currentToken ? sha256(currentToken) : undefined;
|
|
125
|
+
file = { version: 1, sessions: file.sessions.filter((session) => isActive(session, now)) };
|
|
126
|
+
return file.sessions.map((session) => deviceDto(session, session.tokenHash === currentHash));
|
|
127
|
+
},
|
|
128
|
+
async revokeDevice(id) {
|
|
129
|
+
const session = file.sessions.find((item) => item.id === id && !item.revokedAt);
|
|
130
|
+
if (!session)
|
|
131
|
+
return false;
|
|
132
|
+
session.revokedAt = new Date().toISOString();
|
|
133
|
+
await enqueuePersist();
|
|
134
|
+
return true;
|
|
135
|
+
},
|
|
136
|
+
async revokeCurrent(token) {
|
|
137
|
+
const session = findByToken(token, Date.now());
|
|
138
|
+
if (!session)
|
|
139
|
+
return false;
|
|
140
|
+
session.revokedAt = new Date().toISOString();
|
|
141
|
+
await enqueuePersist();
|
|
142
|
+
return true;
|
|
143
|
+
},
|
|
144
|
+
async revokeOthers(token) {
|
|
145
|
+
const session = findByToken(token, Date.now());
|
|
146
|
+
if (!session)
|
|
147
|
+
return 0;
|
|
148
|
+
const now = new Date().toISOString();
|
|
149
|
+
let revoked = 0;
|
|
150
|
+
for (const item of file.sessions) {
|
|
151
|
+
if (item.tokenHash === session.tokenHash || item.revokedAt)
|
|
152
|
+
continue;
|
|
153
|
+
item.revokedAt = now;
|
|
154
|
+
revoked += 1;
|
|
155
|
+
}
|
|
156
|
+
if (revoked > 0)
|
|
157
|
+
await enqueuePersist();
|
|
158
|
+
return revoked;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { createReadStream, existsSync } from "node:fs";
|
|
2
2
|
import { lstat, realpath, stat } from "node:fs/promises";
|
|
3
|
-
import { extname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { dirname, extname, join, relative, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { attachmentsDir, browserScreenshotsDir, generatedAttachmentsDir } from "
|
|
6
|
-
import { sendText } from "./
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
import { attachmentsDir, browserScreenshotsDir, generatedAttachmentsDir } from "../media/generated-media.js";
|
|
6
|
+
import { sendText } from "./http.js";
|
|
7
|
+
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
8
|
+
const DIST_DIR = resolve(PROJECT_ROOT, "web/dist");
|
|
9
|
+
// Only successful realpaths are cached: the attachment root dirs are created
|
|
10
|
+
// lazily on first write, so a missing root must stay retryable.
|
|
11
|
+
const rootRealPathCache = new Map();
|
|
10
12
|
function mimeType(path) {
|
|
11
13
|
const extension = extname(path).toLowerCase();
|
|
12
14
|
if (extension === ".html")
|
|
@@ -36,19 +38,18 @@ function mimeType(path) {
|
|
|
36
38
|
return "application/octet-stream";
|
|
37
39
|
}
|
|
38
40
|
export async function serveStatic(response, requestPath) {
|
|
39
|
-
|
|
40
|
-
if (!existsSync(distDir))
|
|
41
|
+
if (!existsSync(DIST_DIR))
|
|
41
42
|
return false;
|
|
42
43
|
const pathname = decodeURIComponent(requestPath.split("?")[0] || "/");
|
|
43
|
-
const candidate = resolve(
|
|
44
|
-
if (!candidate.startsWith(
|
|
44
|
+
const candidate = resolve(DIST_DIR, pathname === "/" ? "index.html" : pathname.slice(1));
|
|
45
|
+
if (!candidate.startsWith(DIST_DIR)) {
|
|
45
46
|
sendText(response, 403, "Forbidden");
|
|
46
47
|
return true;
|
|
47
48
|
}
|
|
48
49
|
let filePath = candidate;
|
|
49
50
|
const fileStat = await stat(filePath).catch(() => undefined);
|
|
50
51
|
if (!fileStat?.isFile())
|
|
51
|
-
filePath = join(
|
|
52
|
+
filePath = join(DIST_DIR, "index.html");
|
|
52
53
|
const stream = createReadStream(filePath);
|
|
53
54
|
response.writeHead(200, { "content-type": mimeType(filePath) });
|
|
54
55
|
stream.pipe(response);
|
|
@@ -88,9 +89,13 @@ export async function serveAttachment(config, response, requestPath, rangeHeader
|
|
|
88
89
|
{ root: browserScreenshotsDir(config), relativePath: relativePath.replace(/^screenshot[\\/]/, "") },
|
|
89
90
|
];
|
|
90
91
|
for (const { root, relativePath: candidateRelativePath } of candidates) {
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
|
|
92
|
+
let rootRealPath = rootRealPathCache.get(root);
|
|
93
|
+
if (rootRealPath === undefined) {
|
|
94
|
+
rootRealPath = await realpath(root).catch(() => undefined);
|
|
95
|
+
if (!rootRealPath)
|
|
96
|
+
continue;
|
|
97
|
+
rootRealPathCache.set(root, rootRealPath);
|
|
98
|
+
}
|
|
94
99
|
const filePath = resolve(root, candidateRelativePath);
|
|
95
100
|
const rel = relative(root, filePath);
|
|
96
101
|
if (rel.startsWith("..") || rel.startsWith("/") || rel.startsWith("\\") || rel === "") {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { isRecord } from "../util/guards.js";
|
|
2
|
+
import { acceptWebSocket, decodeFrames } from "./events.js";
|
|
3
|
+
export function attachWebSocketStream(server, options) {
|
|
4
|
+
const { authorize, eventHub, getRuntime, abort, retry, deleteLatest } = options;
|
|
5
|
+
const runtimeActions = { abort, retry, delete: deleteLatest };
|
|
6
|
+
const handleStreamMessage = (raw, client) => {
|
|
7
|
+
const message = JSON.parse(raw);
|
|
8
|
+
if (!isRecord(message) || typeof message.type !== "string")
|
|
9
|
+
return;
|
|
10
|
+
if (message.type === "hello") {
|
|
11
|
+
if (!client.channelKey)
|
|
12
|
+
return;
|
|
13
|
+
eventHub.replay(client, client.channelKey, typeof message.lastEventId === "string" ? message.lastEventId : null);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const action = runtimeActions[message.type];
|
|
17
|
+
if (action) {
|
|
18
|
+
void getRuntime(client.channelKey)
|
|
19
|
+
.then(action)
|
|
20
|
+
.catch((error) => console.error(`WebSocket ${message.type} runtime lookup failed`, error));
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
server.on("upgrade", (request, socket) => {
|
|
24
|
+
const netSocket = socket;
|
|
25
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
26
|
+
if (url.pathname !== "/api/web/stream") {
|
|
27
|
+
netSocket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
28
|
+
netSocket.destroy();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
void authorize(request, url.pathname)
|
|
32
|
+
.then((authorized) => {
|
|
33
|
+
if (!authorized) {
|
|
34
|
+
netSocket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
35
|
+
netSocket.destroy();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
return getRuntime(url.searchParams.get("channelKey") || undefined);
|
|
39
|
+
})
|
|
40
|
+
.then((runtime) => {
|
|
41
|
+
if (!runtime)
|
|
42
|
+
return;
|
|
43
|
+
if (netSocket.destroyed)
|
|
44
|
+
return;
|
|
45
|
+
if (!acceptWebSocket(request, netSocket))
|
|
46
|
+
return;
|
|
47
|
+
netSocket.setNoDelay(true);
|
|
48
|
+
const client = { socket: netSocket, channelKey: runtime.channelKey, authed: false };
|
|
49
|
+
const dispose = eventHub.registerClient(client);
|
|
50
|
+
let frameBuffer = Buffer.alloc(0);
|
|
51
|
+
netSocket.on("data", (chunk) => {
|
|
52
|
+
try {
|
|
53
|
+
frameBuffer = Buffer.concat([frameBuffer, chunk]);
|
|
54
|
+
const decoded = decodeFrames(frameBuffer);
|
|
55
|
+
frameBuffer = decoded.remaining;
|
|
56
|
+
if (decoded.close)
|
|
57
|
+
netSocket.destroy();
|
|
58
|
+
for (const raw of decoded.messages) {
|
|
59
|
+
handleStreamMessage(raw, client);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
console.error("WebSocket frame handling failed", error);
|
|
64
|
+
netSocket.destroy();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
netSocket.on("close", dispose);
|
|
68
|
+
netSocket.on("error", dispose);
|
|
69
|
+
})
|
|
70
|
+
.catch((error) => {
|
|
71
|
+
console.error("WebSocket runtime lookup failed", error);
|
|
72
|
+
if (!netSocket.destroyed) {
|
|
73
|
+
netSocket.write("HTTP/1.1 503 Service Unavailable\r\n\r\n");
|
|
74
|
+
netSocket.destroy();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { MAX_CACHE_CHARS_PER_PAGE } from "./types.js";
|
|
2
|
+
export class PageCache {
|
|
3
|
+
ttlMs;
|
|
4
|
+
capacity;
|
|
5
|
+
entries = new Map();
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
this.ttlMs = options.ttlMs ?? 5 * 60 * 1000;
|
|
8
|
+
this.capacity = options.capacity ?? 20;
|
|
9
|
+
}
|
|
10
|
+
get(url) {
|
|
11
|
+
const entry = this.entries.get(url);
|
|
12
|
+
if (!entry)
|
|
13
|
+
return undefined;
|
|
14
|
+
if (Date.now() - entry.fetchedAt > this.ttlMs) {
|
|
15
|
+
this.entries.delete(url);
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
entry.lastAccessed = Date.now();
|
|
19
|
+
this.entries.delete(url);
|
|
20
|
+
this.entries.set(url, entry);
|
|
21
|
+
return entry;
|
|
22
|
+
}
|
|
23
|
+
set(url, content, provider) {
|
|
24
|
+
if (content.length > MAX_CACHE_CHARS_PER_PAGE)
|
|
25
|
+
return;
|
|
26
|
+
if (this.entries.has(url))
|
|
27
|
+
this.entries.delete(url);
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
this.entries.set(url, {
|
|
30
|
+
content,
|
|
31
|
+
provider,
|
|
32
|
+
fetchedAt: now,
|
|
33
|
+
lastAccessed: now,
|
|
34
|
+
});
|
|
35
|
+
while (this.entries.size > this.capacity) {
|
|
36
|
+
const oldest = this.entries.keys().next().value;
|
|
37
|
+
if (!oldest)
|
|
38
|
+
break;
|
|
39
|
+
this.entries.delete(oldest);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function readEnvKey(name) {
|
|
2
|
+
const value = process.env[name];
|
|
3
|
+
return value?.trim() ? value.trim() : undefined;
|
|
4
|
+
}
|
|
5
|
+
export function loadWebConfig() {
|
|
6
|
+
return {
|
|
7
|
+
apiKeys: {
|
|
8
|
+
BRAVE_API_KEY: readEnvKey("BRAVE_API_KEY"),
|
|
9
|
+
TAVILY_API_KEY: readEnvKey("TAVILY_API_KEY"),
|
|
10
|
+
EXA_API_KEY: readEnvKey("EXA_API_KEY"),
|
|
11
|
+
JINA_API_KEY: readEnvKey("JINA_API_KEY"),
|
|
12
|
+
TINYFISH_API_KEY: readEnvKey("TINYFISH_API_KEY"),
|
|
13
|
+
},
|
|
14
|
+
warnings: [],
|
|
15
|
+
};
|
|
16
|
+
}
|