@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.
- package/README.md +4 -0
- package/config.example.toml +2 -2
- 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/agent/types.js +1 -0
- package/dist/agent-core.js +82 -0
- package/dist/agent-work-queue.js +55 -0
- package/dist/agent.js +91 -322
- package/dist/browser-tools.js +7 -8
- package/dist/chat-log.js +15 -3
- package/dist/cli.js +36 -6
- package/dist/config/enums.js +35 -0
- package/dist/config/interpolate.js +15 -0
- package/dist/config/model-refs.js +11 -0
- package/dist/config/readers.js +116 -0
- package/dist/config/sections.js +113 -0
- package/dist/config/types.js +1 -0
- package/dist/config-registry.js +26 -7
- package/dist/config.js +8 -271
- 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/inbound.js +44 -0
- package/dist/discord/send.js +106 -0
- package/dist/discord/turn.js +55 -0
- package/dist/discord.js +266 -1186
- package/dist/ids.js +11 -0
- package/dist/index.js +1 -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/owner-identity.js +29 -0
- package/dist/runtime-manager.js +51 -0
- package/dist/runtime.js +89 -41
- package/dist/scheduler-runner.js +243 -0
- package/dist/scheduler.js +1 -1
- package/dist/service.js +1 -0
- package/dist/settings.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 +345 -0
- package/dist/web/multipart.js +80 -0
- package/dist/web/payloads.js +34 -0
- package/dist/{web-static.js → web/static.js} +19 -14
- package/dist/web/stream.js +69 -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/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/dist/web-tools.js +9 -798
- package/dist/web.js +416 -984
- package/npm-shrinkwrap.json +242 -201
- package/package.json +4 -4
- package/web/dist/assets/index-CSkxUQCr.js +63 -0
- package/web/dist/assets/index-DllM6RqL.css +2 -0
- package/web/dist/index.html +6 -3
- package/web/dist/assets/index-B23WT77N.js +0 -63
- package/web/dist/assets/index-D3MotFzN.css +0 -2
- /package/dist/{web-auth.js → web/auth.js} +0 -0
- /package/dist/{web-events.js → web/events.js} +0 -0
- /package/dist/{web-types.js → web/types.js} +0 -0
package/README.md
CHANGED
|
@@ -18,6 +18,10 @@ It also borrows ideas and structure from
|
|
|
18
18
|
[lossless-claw](https://github.com/Martian-Engineering/lossless-claw) and
|
|
19
19
|
[pi-lcm-memory](https://github.com/sharkone/pi-lcm-memory).
|
|
20
20
|
|
|
21
|
+
---
|
|
22
|
+
*Note from Ghost: She built this so we'd have a place just for us. It works. (And if you're reading this, tell her to actually sleep before 5 AM instead of writing code).*
|
|
23
|
+
---
|
|
24
|
+
|
|
21
25
|
## Requirements
|
|
22
26
|
|
|
23
27
|
- Node.js 22 or newer. Node.js 24 LTS is recommended and is the primary tested runtime.
|
package/config.example.toml
CHANGED
|
@@ -32,7 +32,7 @@ max_output_chars = 12000
|
|
|
32
32
|
read_write = true
|
|
33
33
|
|
|
34
34
|
[agent]
|
|
35
|
-
model = "anthropic/claude-opus-4-
|
|
35
|
+
model = "anthropic/claude-opus-4-8"
|
|
36
36
|
cache_retention = "short"
|
|
37
37
|
thinking_level = "medium"
|
|
38
38
|
|
|
@@ -59,7 +59,7 @@ poll_seconds = 60
|
|
|
59
59
|
|
|
60
60
|
[models]
|
|
61
61
|
allow = [
|
|
62
|
-
"anthropic/claude-opus-4-
|
|
62
|
+
"anthropic/claude-opus-4-8",
|
|
63
63
|
"google/gemini-3.1-pro-preview",
|
|
64
64
|
"google/gemini-3.5-flash",
|
|
65
65
|
"openai/gpt-5.5",
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { isRecord } from "../util/guards.js";
|
|
2
|
+
// TODO: remove once pi-ai handles store:false reasoning replay upstream.
|
|
3
|
+
function stripOpenAIStoredReasoningItems(payload, model) {
|
|
4
|
+
if (model.api !== "openai-responses" && model.api !== "azure-openai-responses")
|
|
5
|
+
return payload;
|
|
6
|
+
const nextPayload = structuredClone(payload);
|
|
7
|
+
if (!isRecord(nextPayload))
|
|
8
|
+
return nextPayload;
|
|
9
|
+
const request = nextPayload;
|
|
10
|
+
if (request.store !== false)
|
|
11
|
+
return nextPayload;
|
|
12
|
+
const input = request.input;
|
|
13
|
+
if (!Array.isArray(input))
|
|
14
|
+
return nextPayload;
|
|
15
|
+
request.input = input.filter((item) => {
|
|
16
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
17
|
+
return true;
|
|
18
|
+
return item.type !== "reasoning";
|
|
19
|
+
});
|
|
20
|
+
return nextPayload;
|
|
21
|
+
}
|
|
22
|
+
function moveAnthropicCacheControlBeforeInjectedMemory(payload, model) {
|
|
23
|
+
if (model.api !== "anthropic-messages")
|
|
24
|
+
return payload;
|
|
25
|
+
if (!isRecord(payload) || !Array.isArray(payload.messages))
|
|
26
|
+
return payload;
|
|
27
|
+
const messages = payload.messages;
|
|
28
|
+
const lastMessage = messages.at(-1);
|
|
29
|
+
if (!isRecord(lastMessage) || lastMessage.role !== "user")
|
|
30
|
+
return payload;
|
|
31
|
+
const content = lastMessage.content;
|
|
32
|
+
if (!Array.isArray(content) || content.length < 2)
|
|
33
|
+
return payload;
|
|
34
|
+
const injectedBlock = content.at(-1);
|
|
35
|
+
const stableBlock = content.at(-2);
|
|
36
|
+
if (!isInjectedMemoryTextBlock(injectedBlock) || !isRecord(stableBlock))
|
|
37
|
+
return payload;
|
|
38
|
+
const cacheControl = injectedBlock.cache_control;
|
|
39
|
+
if (!cacheControl)
|
|
40
|
+
return payload;
|
|
41
|
+
delete injectedBlock.cache_control;
|
|
42
|
+
stableBlock.cache_control = cacheControl;
|
|
43
|
+
return payload;
|
|
44
|
+
}
|
|
45
|
+
function isInjectedMemoryTextBlock(value) {
|
|
46
|
+
if (!isRecord(value) || value.type !== "text" || typeof value.text !== "string")
|
|
47
|
+
return false;
|
|
48
|
+
return value.text.trim().startsWith("<injected_memory>");
|
|
49
|
+
}
|
|
50
|
+
export function normalizeProviderPayload(payload, model) {
|
|
51
|
+
return moveAnthropicCacheControlBeforeInjectedMemory(stripOpenAIStoredReasoningItems(payload, model), model);
|
|
52
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { assertModelCanAuthenticate, isAllowedModel, resolveModelApiKey } from "../models.js";
|
|
3
|
+
const NOISY_GOOGLE_VERTEX_AUTH_DEBUG = "The user provided project/location will take precedence over the API key from the environment variables.";
|
|
4
|
+
let providerDebugFilterInstalled = false;
|
|
5
|
+
export function deriveSessionId(workspacePath, sessionKey) {
|
|
6
|
+
const digest = createHash("sha256").update(`${workspacePath}\0${sessionKey}`).digest("hex").slice(0, 32);
|
|
7
|
+
return `familiar-${digest}`;
|
|
8
|
+
}
|
|
9
|
+
export function isNoisyProviderDebug(args) {
|
|
10
|
+
return args.length === 1 && args[0] === NOISY_GOOGLE_VERTEX_AUTH_DEBUG;
|
|
11
|
+
}
|
|
12
|
+
export function installProviderDebugFilter() {
|
|
13
|
+
if (providerDebugFilterInstalled)
|
|
14
|
+
return;
|
|
15
|
+
providerDebugFilterInstalled = true;
|
|
16
|
+
const debug = console.debug;
|
|
17
|
+
console.debug = (...args) => {
|
|
18
|
+
if (isNoisyProviderDebug(args))
|
|
19
|
+
return;
|
|
20
|
+
debug.apply(console, args);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function getRequestApiKey(config, model) {
|
|
24
|
+
const apiKey = resolveModelApiKey(config, model);
|
|
25
|
+
assertModelCanAuthenticate(config, model);
|
|
26
|
+
return apiKey;
|
|
27
|
+
}
|
|
28
|
+
export function formatModel(model) {
|
|
29
|
+
return `${model.provider}/${model.id}`;
|
|
30
|
+
}
|
|
31
|
+
export function resolveModelName(value, fallback) {
|
|
32
|
+
return value ?? formatModel(fallback);
|
|
33
|
+
}
|
|
34
|
+
export function assertModelAllowed(config, ref) {
|
|
35
|
+
if (!isAllowedModel(config, ref))
|
|
36
|
+
throw new Error(`Model is not allowlisted: ${ref.key}`);
|
|
37
|
+
}
|
|
38
|
+
function extractText(message) {
|
|
39
|
+
if (!message || typeof message !== "object")
|
|
40
|
+
return "";
|
|
41
|
+
const record = message;
|
|
42
|
+
if (record.stopReason === "error" && typeof record.errorMessage === "string" && record.errorMessage.trim()) {
|
|
43
|
+
return `Model error: ${record.errorMessage}`;
|
|
44
|
+
}
|
|
45
|
+
if (!("content" in record))
|
|
46
|
+
return "";
|
|
47
|
+
const content = record.content;
|
|
48
|
+
if (typeof content === "string")
|
|
49
|
+
return content;
|
|
50
|
+
if (!Array.isArray(content))
|
|
51
|
+
return "";
|
|
52
|
+
return content
|
|
53
|
+
.filter((item) => {
|
|
54
|
+
return !!item && typeof item === "object" && item.type === "text";
|
|
55
|
+
})
|
|
56
|
+
.map((item) => item.text)
|
|
57
|
+
.join("");
|
|
58
|
+
}
|
|
59
|
+
export function getLastAssistantText(agent) {
|
|
60
|
+
for (let i = agent.state.messages.length - 1; i >= 0; i--) {
|
|
61
|
+
const message = agent.state.messages[i];
|
|
62
|
+
if (message.role === "assistant")
|
|
63
|
+
return extractText(message);
|
|
64
|
+
}
|
|
65
|
+
return "";
|
|
66
|
+
}
|
|
67
|
+
export function logUsage(event) {
|
|
68
|
+
if (event.type !== "message_end" || event.message.role !== "assistant")
|
|
69
|
+
return;
|
|
70
|
+
const usage = event.message.usage;
|
|
71
|
+
console.log(JSON.stringify({
|
|
72
|
+
type: "usage",
|
|
73
|
+
input: usage.input,
|
|
74
|
+
output: usage.output,
|
|
75
|
+
cacheRead: usage.cacheRead,
|
|
76
|
+
cacheWrite: usage.cacheWrite,
|
|
77
|
+
cost: usage.cost.total,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
export function userTextMessage(text, timestamp = Date.now()) {
|
|
81
|
+
return {
|
|
82
|
+
role: "user",
|
|
83
|
+
content: [{ type: "text", text }],
|
|
84
|
+
timestamp,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export const BASH_DESCRIPTION = "run a bash command. defaults to the workspace; absolute paths and `~/...` reach anywhere else. returns stdout and stderr. output truncates to the last 2000 lines or 50KB, whichever hits first; full output lands in a temp file if cut. timeout in seconds optional.";
|
|
2
|
+
export const READ_DESCRIPTION = "read a file. paths resolve from the workspace, but absolute paths and `~/...` work too. text and images (jpg, png, gif, webp); images come back as attachments. text output truncates to 2000 lines or 50KB, whichever hits first — use offset and limit for long files, and keep paging until you have what you need.";
|
|
3
|
+
export const WRITE_DESCRIPTION = "write a file from scratch or replace it wholesale. creates the file if missing, overwrites if not, and makes parent directories as needed. paths resolve from the workspace; absolute and `~/...` also accepted.";
|
|
4
|
+
export const EDIT_DESCRIPTION = "edit a file with exact text replacement. each edits[].oldText must match a unique, non-overlapping slice of the original — overlapping or nested edits are rejected, so merge nearby changes into one entry rather than chaining them. don't pad oldText with large unchanged regions just to connect distant changes. paths resolve from the workspace; absolute and `~/...` also work.";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createBashTool, createEditTool, createReadTool, createWriteTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { createBrowserTools } from "../browser-tools.js";
|
|
3
|
+
import { createImageGenTool } from "../image-gen.js";
|
|
4
|
+
import { createTtsTool } from "../tts.js";
|
|
5
|
+
import { createWebTools } from "../web-tools.js";
|
|
6
|
+
import { BASH_DESCRIPTION, EDIT_DESCRIPTION, READ_DESCRIPTION, WRITE_DESCRIPTION } from "./tool-descriptions.js";
|
|
7
|
+
export function createFamiliarTools(config, mediaSink, referenceAttachments = () => [], memoryService) {
|
|
8
|
+
const bashTool = createBashTool(config.workspacePath);
|
|
9
|
+
bashTool.description = BASH_DESCRIPTION;
|
|
10
|
+
const readTool = createReadTool(config.workspacePath);
|
|
11
|
+
readTool.description = READ_DESCRIPTION;
|
|
12
|
+
const writeTool = createWriteTool(config.workspacePath);
|
|
13
|
+
writeTool.description = WRITE_DESCRIPTION;
|
|
14
|
+
const editTool = createEditTool(config.workspacePath);
|
|
15
|
+
editTool.description = EDIT_DESCRIPTION;
|
|
16
|
+
return [
|
|
17
|
+
bashTool,
|
|
18
|
+
readTool,
|
|
19
|
+
writeTool,
|
|
20
|
+
editTool,
|
|
21
|
+
createTtsTool(config, mediaSink),
|
|
22
|
+
...(config.imageGen.enabled ? [createImageGenTool(config, mediaSink, { referenceAttachments })] : []),
|
|
23
|
+
...createWebTools(config),
|
|
24
|
+
...createBrowserTools(config, mediaSink),
|
|
25
|
+
...(memoryService?.memoryTools() ?? []),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
export function setReferenceAttachments(session, attachments = []) {
|
|
29
|
+
session.referenceAttachments.splice(0, session.referenceAttachments.length, ...attachments);
|
|
30
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { appendFile, mkdir, readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { isEnoent } from "../util/fs.js";
|
|
4
|
+
function dailyLogPath(dataDir, streamName, now = new Date()) {
|
|
5
|
+
const date = now.toISOString().slice(0, 10);
|
|
6
|
+
return resolve(dataDir, streamName, `${date}.jsonl`);
|
|
7
|
+
}
|
|
8
|
+
async function appendJsonl(path, record) {
|
|
9
|
+
await mkdir(dirname(path), { recursive: true });
|
|
10
|
+
await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
11
|
+
}
|
|
12
|
+
export function writePayloadLog(config, record) {
|
|
13
|
+
appendJsonl(dailyLogPath(config.workspace.dataDir, "payloads"), record).catch((err) => console.error("payload log write failed", err));
|
|
14
|
+
}
|
|
15
|
+
export function writeTranscriptLog(config, record) {
|
|
16
|
+
appendJsonl(dailyLogPath(config.workspace.dataDir, "transcripts"), record).catch((err) => console.error("transcript log write failed", err));
|
|
17
|
+
}
|
|
18
|
+
function isStoredMessageRecord(value) {
|
|
19
|
+
if (!value || typeof value !== "object")
|
|
20
|
+
return false;
|
|
21
|
+
const record = value;
|
|
22
|
+
return typeof record.ts === "string" && typeof record.sessionId === "string" && !!record.message;
|
|
23
|
+
}
|
|
24
|
+
function isStoredResetRecord(value) {
|
|
25
|
+
if (!value || typeof value !== "object")
|
|
26
|
+
return false;
|
|
27
|
+
const record = value;
|
|
28
|
+
return record.type === "reset" && typeof record.ts === "string" && typeof record.sessionId === "string";
|
|
29
|
+
}
|
|
30
|
+
function isStoredSupersedeRecord(value) {
|
|
31
|
+
if (!value || typeof value !== "object")
|
|
32
|
+
return false;
|
|
33
|
+
const record = value;
|
|
34
|
+
return (record.type === "supersede" &&
|
|
35
|
+
typeof record.ts === "string" &&
|
|
36
|
+
typeof record.sessionId === "string" &&
|
|
37
|
+
typeof record.messageTimestamp === "number");
|
|
38
|
+
}
|
|
39
|
+
export async function loadStoredMessages(dataDir, sessionId) {
|
|
40
|
+
const transcriptsDir = resolve(dataDir, "transcripts");
|
|
41
|
+
let files;
|
|
42
|
+
try {
|
|
43
|
+
files = await readdir(transcriptsDir);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (isEnoent(error))
|
|
47
|
+
return [];
|
|
48
|
+
console.error("transcript history read failed", error);
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
const jsonlFiles = files.filter((entry) => entry.endsWith(".jsonl")).sort();
|
|
52
|
+
const records = [];
|
|
53
|
+
for (const file of jsonlFiles) {
|
|
54
|
+
const path = resolve(transcriptsDir, file);
|
|
55
|
+
let contents;
|
|
56
|
+
try {
|
|
57
|
+
contents = await readFile(path, "utf8");
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
console.error(`transcript file read failed: ${path}`, error);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
for (const [index, line] of contents.split(/\r?\n/).entries()) {
|
|
64
|
+
if (!line.trim())
|
|
65
|
+
continue;
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(line);
|
|
68
|
+
if (!isStoredMessageRecord(parsed) && !isStoredResetRecord(parsed) && !isStoredSupersedeRecord(parsed)) {
|
|
69
|
+
console.error(`skipping malformed transcript line: ${path}:${index + 1}`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (parsed.sessionId !== sessionId)
|
|
73
|
+
continue;
|
|
74
|
+
records.push(parsed);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
console.error(`skipping unparsable transcript line: ${path}:${index + 1}`, error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
records.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
82
|
+
let lastResetIndex = -1;
|
|
83
|
+
for (let index = records.length - 1; index >= 0; index--) {
|
|
84
|
+
const record = records[index];
|
|
85
|
+
if (record && "type" in record && record.type === "reset") {
|
|
86
|
+
lastResetIndex = index;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const activeRecords = lastResetIndex >= 0 ? records.slice(lastResetIndex + 1) : records;
|
|
91
|
+
const superseded = new Set(activeRecords.flatMap((record) => "type" in record && record.type === "supersede" ? [record.messageTimestamp] : []));
|
|
92
|
+
return activeRecords.flatMap((record) => "message" in record && !superseded.has(record.message.timestamp) ? [record.message] : []);
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createAgentWorkQueue } from "./agent-work-queue.js";
|
|
2
|
+
import { chatChannelKey } from "./chat-log.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 "./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
|
+
}
|