@qearlyao/familiar 0.1.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/.env.example +31 -0
- package/HEARTBEAT.md +23 -0
- package/LICENSE +21 -0
- package/MEMORY.md +1 -0
- package/README.md +245 -0
- package/SOUL.md +13 -0
- package/USER.md +13 -0
- package/config.example.toml +221 -0
- package/dist/agent-events.js +167 -0
- package/dist/agent.js +590 -0
- package/dist/browser-tools.js +638 -0
- package/dist/chat-log.js +130 -0
- package/dist/cli.js +168 -0
- package/dist/config.js +804 -0
- package/dist/data-retention.js +54 -0
- package/dist/discord.js +1203 -0
- package/dist/generated-media.js +86 -0
- package/dist/image-derivatives.js +102 -0
- package/dist/image-gen.js +440 -0
- package/dist/inbound-attachments.js +266 -0
- package/dist/index.js +10 -0
- package/dist/media-understanding.js +120 -0
- package/dist/memory/diary/ambient-injector.js +180 -0
- package/dist/memory/diary/ambient.js +124 -0
- package/dist/memory/diary/chunks.js +231 -0
- package/dist/memory/diary/index.js +3 -0
- package/dist/memory/diary/indexer.js +93 -0
- package/dist/memory/doctor.js +250 -0
- package/dist/memory/index/chunk-indexer.js +151 -0
- package/dist/memory/index/embedding-provider.js +119 -0
- package/dist/memory/index/fts-query.js +18 -0
- package/dist/memory/index/retrieval.js +246 -0
- package/dist/memory/index/schema.js +157 -0
- package/dist/memory/index/store.js +513 -0
- package/dist/memory/index/vec.js +72 -0
- package/dist/memory/index/vector-codec.js +27 -0
- package/dist/memory/lcm/backfill.js +247 -0
- package/dist/memory/lcm/condense.js +146 -0
- package/dist/memory/lcm/context-transformer.js +662 -0
- package/dist/memory/lcm/context.js +421 -0
- package/dist/memory/lcm/eviction-score.js +38 -0
- package/dist/memory/lcm/index.js +6 -0
- package/dist/memory/lcm/indexer.js +200 -0
- package/dist/memory/lcm/normalize.js +235 -0
- package/dist/memory/lcm/schema.js +188 -0
- package/dist/memory/lcm/segment-manager.js +136 -0
- package/dist/memory/lcm/store.js +722 -0
- package/dist/memory/lcm/summarizer.js +258 -0
- package/dist/memory/lcm/types.js +1 -0
- package/dist/memory/operator.js +477 -0
- package/dist/memory/service.js +202 -0
- package/dist/memory/tools.js +205 -0
- package/dist/models.js +165 -0
- package/dist/persona.js +54 -0
- package/dist/runtime.js +493 -0
- package/dist/scheduler.js +200 -0
- package/dist/settings.js +116 -0
- package/dist/skills.js +38 -0
- package/dist/tts.js +143 -0
- package/dist/web-auth.js +105 -0
- package/dist/web-events.js +114 -0
- package/dist/web-http.js +29 -0
- package/dist/web-static.js +106 -0
- package/dist/web-tools.js +940 -0
- package/dist/web-types.js +2 -0
- package/dist/web.js +844 -0
- package/package.json +60 -0
- package/web/dist/assets/index-ClgkMgaq.css +2 -0
- package/web/dist/assets/index-Cu2QquuR.js +59 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/icons.svg +24 -0
- package/web/dist/index.html +20 -0
package/dist/agent.js
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { appendFile, mkdir, readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { Agent } from "@earendil-works/pi-agent-core";
|
|
5
|
+
import { streamSimple } from "@earendil-works/pi-ai";
|
|
6
|
+
import { createBashTool, createEditTool, createReadTool, createWriteTool } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { createBrowserTools } from "./browser-tools.js";
|
|
8
|
+
import { createGeneratedMediaSink } from "./generated-media.js";
|
|
9
|
+
import { createImageGenTool } from "./image-gen.js";
|
|
10
|
+
import { assertModelCanAuthenticate, clampConfiguredThinkingLevel, createConfiguredModel, isAllowedModel, isThinkingLevel, parseModelRef, resolveModel, resolveModelApiKey, supportedThinkingLevels, } from "./models.js";
|
|
11
|
+
import { buildSystemPrompt, loadPersona } from "./persona.js";
|
|
12
|
+
import { formatFamiliarSkillsForPrompt, loadFamiliarSkills, logSkillDiagnostics } from "./skills.js";
|
|
13
|
+
import { createTtsTool } from "./tts.js";
|
|
14
|
+
import { createWebTools } from "./web-tools.js";
|
|
15
|
+
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.";
|
|
16
|
+
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.";
|
|
17
|
+
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.";
|
|
18
|
+
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.";
|
|
19
|
+
function deriveSessionId(workspacePath, sessionKey) {
|
|
20
|
+
const digest = createHash("sha256").update(`${workspacePath}\0${sessionKey}`).digest("hex").slice(0, 32);
|
|
21
|
+
return `familiar-${digest}`;
|
|
22
|
+
}
|
|
23
|
+
function dailyLogPath(dataDir, streamName, now = new Date()) {
|
|
24
|
+
const date = now.toISOString().slice(0, 10);
|
|
25
|
+
return resolve(dataDir, streamName, `${date}.jsonl`);
|
|
26
|
+
}
|
|
27
|
+
async function appendJsonl(path, record) {
|
|
28
|
+
await mkdir(dirname(path), { recursive: true });
|
|
29
|
+
await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
30
|
+
}
|
|
31
|
+
function writePayloadLog(config, record) {
|
|
32
|
+
appendJsonl(dailyLogPath(config.workspace.dataDir, "payloads"), record).catch((err) => console.error("payload log write failed", err));
|
|
33
|
+
}
|
|
34
|
+
function writeTranscriptLog(config, record) {
|
|
35
|
+
appendJsonl(dailyLogPath(config.workspace.dataDir, "transcripts"), record).catch((err) => console.error("transcript log write failed", err));
|
|
36
|
+
}
|
|
37
|
+
function clonePayload(payload) {
|
|
38
|
+
if (typeof structuredClone === "function")
|
|
39
|
+
return structuredClone(payload);
|
|
40
|
+
return JSON.parse(JSON.stringify(payload));
|
|
41
|
+
}
|
|
42
|
+
function isRecord(value) {
|
|
43
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
// TODO: remove once pi-ai handles store:false reasoning replay upstream.
|
|
46
|
+
function stripOpenAIStoredReasoningItems(payload, model) {
|
|
47
|
+
if (model.api !== "openai-responses" && model.api !== "azure-openai-responses")
|
|
48
|
+
return payload;
|
|
49
|
+
const nextPayload = clonePayload(payload);
|
|
50
|
+
if (!isRecord(nextPayload))
|
|
51
|
+
return nextPayload;
|
|
52
|
+
const request = nextPayload;
|
|
53
|
+
if (request.store !== false)
|
|
54
|
+
return nextPayload;
|
|
55
|
+
const input = request.input;
|
|
56
|
+
if (!Array.isArray(input))
|
|
57
|
+
return nextPayload;
|
|
58
|
+
request.input = input.filter((item) => {
|
|
59
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
60
|
+
return true;
|
|
61
|
+
return item.type !== "reasoning";
|
|
62
|
+
});
|
|
63
|
+
return nextPayload;
|
|
64
|
+
}
|
|
65
|
+
function moveAnthropicCacheControlBeforeInjectedMemory(payload, model) {
|
|
66
|
+
if (model.api !== "anthropic-messages")
|
|
67
|
+
return payload;
|
|
68
|
+
if (!isRecord(payload) || !Array.isArray(payload.messages))
|
|
69
|
+
return payload;
|
|
70
|
+
const messages = payload.messages;
|
|
71
|
+
const lastMessage = messages.at(-1);
|
|
72
|
+
if (!isRecord(lastMessage) || lastMessage.role !== "user")
|
|
73
|
+
return payload;
|
|
74
|
+
const content = lastMessage.content;
|
|
75
|
+
if (!Array.isArray(content) || content.length < 2)
|
|
76
|
+
return payload;
|
|
77
|
+
const injectedBlock = content.at(-1);
|
|
78
|
+
const stableBlock = content.at(-2);
|
|
79
|
+
if (!isInjectedMemoryTextBlock(injectedBlock) || !isRecord(stableBlock))
|
|
80
|
+
return payload;
|
|
81
|
+
const cacheControl = injectedBlock.cache_control;
|
|
82
|
+
if (!cacheControl)
|
|
83
|
+
return payload;
|
|
84
|
+
delete injectedBlock.cache_control;
|
|
85
|
+
stableBlock.cache_control = cacheControl;
|
|
86
|
+
return payload;
|
|
87
|
+
}
|
|
88
|
+
function isInjectedMemoryTextBlock(value) {
|
|
89
|
+
if (!isRecord(value) || value.type !== "text" || typeof value.text !== "string")
|
|
90
|
+
return false;
|
|
91
|
+
return value.text.trim().startsWith("<injected_memory>");
|
|
92
|
+
}
|
|
93
|
+
function normalizeProviderPayload(payload, model) {
|
|
94
|
+
return moveAnthropicCacheControlBeforeInjectedMemory(stripOpenAIStoredReasoningItems(payload, model), model);
|
|
95
|
+
}
|
|
96
|
+
export const __agentTest = {
|
|
97
|
+
normalizeProviderPayload,
|
|
98
|
+
};
|
|
99
|
+
function isStoredMessageRecord(value) {
|
|
100
|
+
if (!value || typeof value !== "object")
|
|
101
|
+
return false;
|
|
102
|
+
const record = value;
|
|
103
|
+
return typeof record.ts === "string" && typeof record.sessionId === "string" && !!record.message;
|
|
104
|
+
}
|
|
105
|
+
function isStoredResetRecord(value) {
|
|
106
|
+
if (!value || typeof value !== "object")
|
|
107
|
+
return false;
|
|
108
|
+
const record = value;
|
|
109
|
+
return record.type === "reset" && typeof record.ts === "string" && typeof record.sessionId === "string";
|
|
110
|
+
}
|
|
111
|
+
async function loadStoredMessages(dataDir, sessionId) {
|
|
112
|
+
const transcriptsDir = resolve(dataDir, "transcripts");
|
|
113
|
+
let files;
|
|
114
|
+
try {
|
|
115
|
+
files = await readdir(transcriptsDir);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error && typeof error === "object" && error.code === "ENOENT")
|
|
119
|
+
return [];
|
|
120
|
+
console.error("transcript history read failed", error);
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
const records = [];
|
|
124
|
+
for (const file of files.filter((entry) => entry.endsWith(".jsonl")).sort()) {
|
|
125
|
+
const path = resolve(transcriptsDir, file);
|
|
126
|
+
let contents;
|
|
127
|
+
try {
|
|
128
|
+
contents = await readFile(path, "utf8");
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
console.error(`transcript file read failed: ${path}`, error);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
for (const [index, line] of contents.split(/\r?\n/).entries()) {
|
|
135
|
+
if (!line.trim())
|
|
136
|
+
continue;
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(line);
|
|
139
|
+
if (!isStoredMessageRecord(parsed) && !isStoredResetRecord(parsed)) {
|
|
140
|
+
console.error(`skipping malformed transcript line: ${path}:${index + 1}`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (parsed.sessionId !== sessionId)
|
|
144
|
+
continue;
|
|
145
|
+
records.push(parsed);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
console.error(`skipping unparsable transcript line: ${path}:${index + 1}`, error);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
records.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
153
|
+
let lastResetIndex = -1;
|
|
154
|
+
for (let index = records.length - 1; index >= 0; index--) {
|
|
155
|
+
const record = records[index];
|
|
156
|
+
if (record && "type" in record && record.type === "reset") {
|
|
157
|
+
lastResetIndex = index;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const activeRecords = lastResetIndex >= 0 ? records.slice(lastResetIndex + 1) : records;
|
|
162
|
+
return activeRecords.flatMap((record) => ("message" in record ? [record.message] : []));
|
|
163
|
+
}
|
|
164
|
+
function getRequestApiKey(config, model) {
|
|
165
|
+
const apiKey = resolveModelApiKey(config, model);
|
|
166
|
+
assertModelCanAuthenticate(config, model);
|
|
167
|
+
return apiKey;
|
|
168
|
+
}
|
|
169
|
+
function formatModel(model) {
|
|
170
|
+
return `${model.provider}/${model.id}`;
|
|
171
|
+
}
|
|
172
|
+
function resolveModelName(value, fallback) {
|
|
173
|
+
return value ?? formatModel(fallback);
|
|
174
|
+
}
|
|
175
|
+
function assertModelAllowed(config, ref) {
|
|
176
|
+
if (!isAllowedModel(config, ref))
|
|
177
|
+
throw new Error(`Model is not allowlisted: ${ref.key}`);
|
|
178
|
+
}
|
|
179
|
+
function extractText(message) {
|
|
180
|
+
if (!message || typeof message !== "object")
|
|
181
|
+
return "";
|
|
182
|
+
const record = message;
|
|
183
|
+
if (record.stopReason === "error" && typeof record.errorMessage === "string" && record.errorMessage.trim()) {
|
|
184
|
+
return `Model error: ${record.errorMessage}`;
|
|
185
|
+
}
|
|
186
|
+
if (!("content" in record))
|
|
187
|
+
return "";
|
|
188
|
+
const content = record.content;
|
|
189
|
+
if (typeof content === "string")
|
|
190
|
+
return content;
|
|
191
|
+
if (!Array.isArray(content))
|
|
192
|
+
return "";
|
|
193
|
+
return content
|
|
194
|
+
.filter((item) => {
|
|
195
|
+
return !!item && typeof item === "object" && item.type === "text";
|
|
196
|
+
})
|
|
197
|
+
.map((item) => item.text)
|
|
198
|
+
.join("");
|
|
199
|
+
}
|
|
200
|
+
function getLastAssistantText(agent) {
|
|
201
|
+
for (let i = agent.state.messages.length - 1; i >= 0; i--) {
|
|
202
|
+
const message = agent.state.messages[i];
|
|
203
|
+
if (message.role === "assistant")
|
|
204
|
+
return extractText(message);
|
|
205
|
+
}
|
|
206
|
+
return "";
|
|
207
|
+
}
|
|
208
|
+
function logUsage(event) {
|
|
209
|
+
if (event.type !== "message_end" || event.message.role !== "assistant")
|
|
210
|
+
return;
|
|
211
|
+
const usage = event.message.usage;
|
|
212
|
+
console.log(JSON.stringify({
|
|
213
|
+
type: "usage",
|
|
214
|
+
input: usage.input,
|
|
215
|
+
output: usage.output,
|
|
216
|
+
cacheRead: usage.cacheRead,
|
|
217
|
+
cacheWrite: usage.cacheWrite,
|
|
218
|
+
cost: usage.cost.total,
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
function userTextMessage(text, timestamp = Date.now()) {
|
|
222
|
+
return {
|
|
223
|
+
role: "user",
|
|
224
|
+
content: [{ type: "text", text }],
|
|
225
|
+
timestamp,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function createFamiliarTools(config, mediaSink, referenceAttachments = () => [], memoryService) {
|
|
229
|
+
const bashTool = createBashTool(config.workspacePath);
|
|
230
|
+
bashTool.description = BASH_DESCRIPTION;
|
|
231
|
+
const readTool = createReadTool(config.workspacePath);
|
|
232
|
+
readTool.description = READ_DESCRIPTION;
|
|
233
|
+
const writeTool = createWriteTool(config.workspacePath);
|
|
234
|
+
writeTool.description = WRITE_DESCRIPTION;
|
|
235
|
+
const editTool = createEditTool(config.workspacePath);
|
|
236
|
+
editTool.description = EDIT_DESCRIPTION;
|
|
237
|
+
return [
|
|
238
|
+
bashTool,
|
|
239
|
+
readTool,
|
|
240
|
+
writeTool,
|
|
241
|
+
editTool,
|
|
242
|
+
createTtsTool(config, mediaSink),
|
|
243
|
+
...(config.imageGen.enabled ? [createImageGenTool(config, mediaSink, { referenceAttachments })] : []),
|
|
244
|
+
...createWebTools(config),
|
|
245
|
+
...createBrowserTools(config, mediaSink),
|
|
246
|
+
...(memoryService?.memoryTools() ?? []),
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
function setReferenceAttachments(session, attachments = []) {
|
|
250
|
+
session.referenceAttachments.splice(0, session.referenceAttachments.length, ...attachments);
|
|
251
|
+
}
|
|
252
|
+
export async function createFamiliarAgent(config, settings, memoryService, options = {}) {
|
|
253
|
+
let persona = await loadPersona(config);
|
|
254
|
+
let skillsResult = loadFamiliarSkills(config);
|
|
255
|
+
logSkillDiagnostics(skillsResult);
|
|
256
|
+
let systemPrompt = buildSystemPrompt(persona, formatFamiliarSkillsForPrompt(skillsResult.skills));
|
|
257
|
+
console.log("---SYSTEM PROMPT (start)---");
|
|
258
|
+
console.log(systemPrompt);
|
|
259
|
+
console.log("---SYSTEM PROMPT (end)---");
|
|
260
|
+
let defaultModel = createConfiguredModel(config);
|
|
261
|
+
// Fail fast during startup if the configured default model cannot authenticate.
|
|
262
|
+
getRequestApiKey(config, defaultModel);
|
|
263
|
+
const sessions = new Map();
|
|
264
|
+
// activePromptOptions covers the synchronous promptMessage window; skipAmbientMessages
|
|
265
|
+
// tags message identities so followUpMessage's fire-and-forget path also opts out.
|
|
266
|
+
const activePromptOptions = new Map();
|
|
267
|
+
const skipAmbientMessages = new WeakSet();
|
|
268
|
+
const resolveChannelModel = (sessionKey) => {
|
|
269
|
+
const override = settings.getChannelModel(sessionKey);
|
|
270
|
+
const modelName = resolveModelName(override.value, defaultModel);
|
|
271
|
+
const ref = parseModelRef(modelName);
|
|
272
|
+
if (!ref)
|
|
273
|
+
throw new Error(`Invalid persisted model for ${sessionKey}: ${modelName}`);
|
|
274
|
+
if (override.value)
|
|
275
|
+
assertModelAllowed(config, ref);
|
|
276
|
+
const model = override.value ? resolveModel(ref, config) : defaultModel;
|
|
277
|
+
getRequestApiKey(config, model);
|
|
278
|
+
return { model, source: override.source };
|
|
279
|
+
};
|
|
280
|
+
const resolveChannelThinkingLevel = (sessionKey, model) => {
|
|
281
|
+
const setting = settings.getChannelThinkingLevel(sessionKey, config.agent.thinkingLevel);
|
|
282
|
+
return {
|
|
283
|
+
value: clampConfiguredThinkingLevel(model, setting.value),
|
|
284
|
+
source: setting.source,
|
|
285
|
+
};
|
|
286
|
+
};
|
|
287
|
+
const createSession = async (sessionKey) => {
|
|
288
|
+
const sessionId = deriveSessionId(config.workspacePath, sessionKey);
|
|
289
|
+
const messages = await loadStoredMessages(config.workspace.dataDir, sessionId);
|
|
290
|
+
const { model } = resolveChannelModel(sessionKey);
|
|
291
|
+
const thinkingLevel = resolveChannelThinkingLevel(sessionKey, model).value;
|
|
292
|
+
const mediaSink = createGeneratedMediaSink();
|
|
293
|
+
const referenceAttachments = [];
|
|
294
|
+
console.log(`Loaded ${messages.length} prior messages from session history for ${sessionKey}`);
|
|
295
|
+
let agent;
|
|
296
|
+
agent = new Agent({
|
|
297
|
+
initialState: {
|
|
298
|
+
systemPrompt,
|
|
299
|
+
model,
|
|
300
|
+
messages,
|
|
301
|
+
tools: createFamiliarTools(config, mediaSink, () => referenceAttachments, memoryService),
|
|
302
|
+
thinkingLevel,
|
|
303
|
+
},
|
|
304
|
+
sessionId,
|
|
305
|
+
streamFn: (streamModel, context, options) => streamSimple(streamModel, context, {
|
|
306
|
+
...options,
|
|
307
|
+
apiKey: getRequestApiKey(config, streamModel),
|
|
308
|
+
cacheRetention: config.agent.cacheRetention,
|
|
309
|
+
onPayload: (payload, payloadModel) => {
|
|
310
|
+
const requestPayload = normalizeProviderPayload(payload, payloadModel);
|
|
311
|
+
writePayloadLog(config, {
|
|
312
|
+
ts: new Date().toISOString(),
|
|
313
|
+
direction: "request",
|
|
314
|
+
sessionId,
|
|
315
|
+
sessionKey,
|
|
316
|
+
model: payloadModel.id,
|
|
317
|
+
payload: requestPayload,
|
|
318
|
+
});
|
|
319
|
+
return requestPayload;
|
|
320
|
+
},
|
|
321
|
+
onResponse: (response, responseModel) => {
|
|
322
|
+
writePayloadLog(config, {
|
|
323
|
+
ts: new Date().toISOString(),
|
|
324
|
+
direction: "response_meta",
|
|
325
|
+
sessionId,
|
|
326
|
+
sessionKey,
|
|
327
|
+
model: responseModel.id,
|
|
328
|
+
status: response.status,
|
|
329
|
+
headers: response.headers,
|
|
330
|
+
});
|
|
331
|
+
},
|
|
332
|
+
}),
|
|
333
|
+
transformContext: memoryService
|
|
334
|
+
? (contextMessages, signal) => {
|
|
335
|
+
const activeOptions = activePromptOptions.get(sessionKey);
|
|
336
|
+
const skipAmbient = activeOptions?.skipAmbient || lastUserMessageSkipsAmbient(contextMessages);
|
|
337
|
+
return memoryService.transformContext(contextMessages, signal, {
|
|
338
|
+
sessionKey,
|
|
339
|
+
sessionId,
|
|
340
|
+
model: agent.state.model,
|
|
341
|
+
...(skipAmbient ? { skipAmbient: true } : {}),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
: undefined,
|
|
345
|
+
});
|
|
346
|
+
agent.subscribe((event) => {
|
|
347
|
+
logUsage(event);
|
|
348
|
+
if (event.type === "message_end") {
|
|
349
|
+
writeTranscriptLog(config, {
|
|
350
|
+
ts: new Date().toISOString(),
|
|
351
|
+
sessionId,
|
|
352
|
+
sessionKey,
|
|
353
|
+
message: event.message,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
return {
|
|
358
|
+
agent,
|
|
359
|
+
sessionId,
|
|
360
|
+
model,
|
|
361
|
+
thinkingLevel,
|
|
362
|
+
mediaSink,
|
|
363
|
+
referenceAttachments,
|
|
364
|
+
promptQueue: Promise.resolve(),
|
|
365
|
+
};
|
|
366
|
+
};
|
|
367
|
+
const getSession = async (sessionKey) => {
|
|
368
|
+
const existing = sessions.get(sessionKey);
|
|
369
|
+
if (existing)
|
|
370
|
+
return existing;
|
|
371
|
+
const sessionPromise = createSession(sessionKey);
|
|
372
|
+
sessions.set(sessionKey, sessionPromise);
|
|
373
|
+
try {
|
|
374
|
+
return await sessionPromise;
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
sessions.delete(sessionKey);
|
|
378
|
+
throw error;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
const resetSession = (session) => {
|
|
382
|
+
session.agent.abort();
|
|
383
|
+
session.agent.reset();
|
|
384
|
+
writeTranscriptLog(config, {
|
|
385
|
+
ts: new Date().toISOString(),
|
|
386
|
+
sessionId: session.sessionId,
|
|
387
|
+
type: "reset",
|
|
388
|
+
});
|
|
389
|
+
session.agent.state.systemPrompt = systemPrompt;
|
|
390
|
+
session.agent.state.model = session.model;
|
|
391
|
+
session.mediaSink.drain();
|
|
392
|
+
setReferenceAttachments(session);
|
|
393
|
+
session.agent.state.tools = createFamiliarTools(config, session.mediaSink, () => session.referenceAttachments, memoryService);
|
|
394
|
+
session.agent.state.thinkingLevel = session.thinkingLevel;
|
|
395
|
+
};
|
|
396
|
+
const refreshSession = (session, sessionKey) => {
|
|
397
|
+
const { model } = resolveChannelModel(sessionKey);
|
|
398
|
+
const thinkingLevel = resolveChannelThinkingLevel(sessionKey, model).value;
|
|
399
|
+
session.model = model;
|
|
400
|
+
session.thinkingLevel = thinkingLevel;
|
|
401
|
+
session.agent.state.systemPrompt = systemPrompt;
|
|
402
|
+
session.agent.state.model = model;
|
|
403
|
+
session.agent.state.thinkingLevel = thinkingLevel;
|
|
404
|
+
session.agent.state.tools = createFamiliarTools(config, session.mediaSink, () => session.referenceAttachments, memoryService);
|
|
405
|
+
};
|
|
406
|
+
return {
|
|
407
|
+
abort(sessionKey) {
|
|
408
|
+
const session = sessions.get(sessionKey);
|
|
409
|
+
void session
|
|
410
|
+
?.then((resolved) => {
|
|
411
|
+
resolved.agent.abort();
|
|
412
|
+
resolved.agent.clearAllQueues();
|
|
413
|
+
})
|
|
414
|
+
.catch((error) => console.error(`failed to abort familiar session ${sessionKey}`, error));
|
|
415
|
+
},
|
|
416
|
+
async reset(sessionKey) {
|
|
417
|
+
const existing = sessions.get(sessionKey);
|
|
418
|
+
if (!existing)
|
|
419
|
+
return;
|
|
420
|
+
const session = await existing;
|
|
421
|
+
resetSession(session);
|
|
422
|
+
},
|
|
423
|
+
async reload() {
|
|
424
|
+
const previousModel = formatModel(defaultModel);
|
|
425
|
+
const nextConfig = await options.reloadConfig?.();
|
|
426
|
+
if (nextConfig)
|
|
427
|
+
Object.assign(config, nextConfig);
|
|
428
|
+
persona = await loadPersona(config);
|
|
429
|
+
skillsResult = loadFamiliarSkills(config);
|
|
430
|
+
logSkillDiagnostics(skillsResult);
|
|
431
|
+
systemPrompt = buildSystemPrompt(persona, formatFamiliarSkillsForPrompt(skillsResult.skills));
|
|
432
|
+
defaultModel = createConfiguredModel(config);
|
|
433
|
+
getRequestApiKey(config, defaultModel);
|
|
434
|
+
const settledSessions = await Promise.allSettled([...sessions.entries()].map(async ([sessionKey, sessionPromise]) => {
|
|
435
|
+
const session = await sessionPromise;
|
|
436
|
+
refreshSession(session, sessionKey);
|
|
437
|
+
return sessionKey;
|
|
438
|
+
}));
|
|
439
|
+
const refreshed = settledSessions.filter((result) => result.status === "fulfilled").length;
|
|
440
|
+
const failed = settledSessions.length - refreshed;
|
|
441
|
+
const modelLine = previousModel === formatModel(defaultModel)
|
|
442
|
+
? `default_model: ${previousModel}`
|
|
443
|
+
: `default_model: ${previousModel} -> ${formatModel(defaultModel)}`;
|
|
444
|
+
return [
|
|
445
|
+
"Reloaded persona prompt, skills, and live agent settings.",
|
|
446
|
+
modelLine,
|
|
447
|
+
`skills: ${skillsResult.skills.length} loaded${skillsResult.diagnostics.length ? ` (${skillsResult.diagnostics.length} warnings)` : ""}`,
|
|
448
|
+
`active_sessions: ${refreshed}${failed ? ` (${failed} failed)` : ""}`,
|
|
449
|
+
"restart_required_for: Discord/Web listener settings, memory database paths, and long-lived memory internals",
|
|
450
|
+
].join("\n");
|
|
451
|
+
},
|
|
452
|
+
resolveChannelModel,
|
|
453
|
+
getModel(sessionKey) {
|
|
454
|
+
const { model, source } = resolveChannelModel(sessionKey);
|
|
455
|
+
return { value: formatModel(model), source };
|
|
456
|
+
},
|
|
457
|
+
getThinkingLevel(sessionKey) {
|
|
458
|
+
const { model } = resolveChannelModel(sessionKey);
|
|
459
|
+
const thinkingLevel = resolveChannelThinkingLevel(sessionKey, model);
|
|
460
|
+
return thinkingLevel;
|
|
461
|
+
},
|
|
462
|
+
async setModel(sessionKey, input) {
|
|
463
|
+
const ref = parseModelRef(input);
|
|
464
|
+
if (!ref)
|
|
465
|
+
throw new Error("Usage: /model provider/model-id");
|
|
466
|
+
assertModelAllowed(config, ref);
|
|
467
|
+
const nextModel = resolveModel(ref, config);
|
|
468
|
+
getRequestApiKey(config, nextModel);
|
|
469
|
+
const previousThinking = settings.getChannelThinkingLevel(sessionKey, config.agent.thinkingLevel).value;
|
|
470
|
+
const nextThinking = clampConfiguredThinkingLevel(nextModel, previousThinking);
|
|
471
|
+
await settings.setChannelModel(sessionKey, formatModel(nextModel));
|
|
472
|
+
const sessionPromise = sessions.get(sessionKey);
|
|
473
|
+
if (sessionPromise) {
|
|
474
|
+
const session = await sessionPromise;
|
|
475
|
+
session.model = nextModel;
|
|
476
|
+
session.thinkingLevel = nextThinking;
|
|
477
|
+
session.agent.state.model = nextModel;
|
|
478
|
+
session.agent.state.thinkingLevel = nextThinking;
|
|
479
|
+
}
|
|
480
|
+
const suffix = nextThinking === previousThinking ? "" : ` (clamped from ${previousThinking})`;
|
|
481
|
+
return `Model set to ${formatModel(nextModel)} for this channel\nThinking: ${nextThinking}${suffix}`;
|
|
482
|
+
},
|
|
483
|
+
async setThinkingLevel(sessionKey, input) {
|
|
484
|
+
const level = input.trim().toLowerCase();
|
|
485
|
+
if (!isThinkingLevel(level)) {
|
|
486
|
+
throw new Error("Usage: /thinking off|minimal|low|medium|high|xhigh");
|
|
487
|
+
}
|
|
488
|
+
const { model } = resolveChannelModel(sessionKey);
|
|
489
|
+
const clamped = clampConfiguredThinkingLevel(model, level);
|
|
490
|
+
await settings.setChannelThinkingLevel(sessionKey, clamped);
|
|
491
|
+
const sessionPromise = sessions.get(sessionKey);
|
|
492
|
+
if (sessionPromise) {
|
|
493
|
+
const session = await sessionPromise;
|
|
494
|
+
session.thinkingLevel = clamped;
|
|
495
|
+
session.agent.state.thinkingLevel = clamped;
|
|
496
|
+
}
|
|
497
|
+
const suffix = clamped === level ? "" : ` (clamped from ${level})`;
|
|
498
|
+
return `Thinking set to ${clamped}${suffix} for this channel\nSupported: ${supportedThinkingLevels(model).join(", ")}`;
|
|
499
|
+
},
|
|
500
|
+
async prompt(sessionKey, input, imagesOrOnEvent, onEvent, options = {}) {
|
|
501
|
+
const session = await getSession(sessionKey);
|
|
502
|
+
const images = Array.isArray(imagesOrOnEvent) ? imagesOrOnEvent : undefined;
|
|
503
|
+
const eventHandler = Array.isArray(imagesOrOnEvent) ? onEvent : imagesOrOnEvent;
|
|
504
|
+
const run = session.promptQueue.then(async () => {
|
|
505
|
+
session.mediaSink.drain();
|
|
506
|
+
setReferenceAttachments(session, options.referenceAttachments);
|
|
507
|
+
const unsubscribe = eventHandler ? session.agent.subscribe((event) => eventHandler(event)) : undefined;
|
|
508
|
+
try {
|
|
509
|
+
await session.agent.prompt(input, images);
|
|
510
|
+
}
|
|
511
|
+
finally {
|
|
512
|
+
setReferenceAttachments(session);
|
|
513
|
+
unsubscribe?.();
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
text: getLastAssistantText(session.agent),
|
|
517
|
+
attachments: session.mediaSink.drain(),
|
|
518
|
+
};
|
|
519
|
+
});
|
|
520
|
+
session.promptQueue = run.then(() => undefined, () => undefined);
|
|
521
|
+
return run;
|
|
522
|
+
},
|
|
523
|
+
async promptMessage(sessionKey, message, onEvent, options = {}) {
|
|
524
|
+
const session = await getSession(sessionKey);
|
|
525
|
+
const run = session.promptQueue.then(async () => {
|
|
526
|
+
session.mediaSink.drain();
|
|
527
|
+
setReferenceAttachments(session, options.referenceAttachments);
|
|
528
|
+
const unsubscribe = onEvent ? session.agent.subscribe((event) => onEvent(event)) : undefined;
|
|
529
|
+
const previousOptions = activePromptOptions.get(sessionKey);
|
|
530
|
+
activePromptOptions.set(sessionKey, options);
|
|
531
|
+
if (options.skipAmbient)
|
|
532
|
+
skipAmbientMessages.add(message);
|
|
533
|
+
try {
|
|
534
|
+
await session.agent.prompt(message);
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
setReferenceAttachments(session);
|
|
538
|
+
if (previousOptions)
|
|
539
|
+
activePromptOptions.set(sessionKey, previousOptions);
|
|
540
|
+
else
|
|
541
|
+
activePromptOptions.delete(sessionKey);
|
|
542
|
+
unsubscribe?.();
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
text: getLastAssistantText(session.agent),
|
|
546
|
+
attachments: session.mediaSink.drain(),
|
|
547
|
+
};
|
|
548
|
+
});
|
|
549
|
+
session.promptQueue = run.then(() => undefined, () => undefined);
|
|
550
|
+
return run;
|
|
551
|
+
},
|
|
552
|
+
steer(sessionKey, input) {
|
|
553
|
+
const session = sessions.get(sessionKey);
|
|
554
|
+
if (!session)
|
|
555
|
+
return;
|
|
556
|
+
void session
|
|
557
|
+
.then((resolved) => {
|
|
558
|
+
resolved.agent.steer(userTextMessage(input));
|
|
559
|
+
})
|
|
560
|
+
.catch((error) => console.error(`failed to load familiar session ${sessionKey} for steer`, error));
|
|
561
|
+
},
|
|
562
|
+
steerMessage(sessionKey, message) {
|
|
563
|
+
const session = sessions.get(sessionKey);
|
|
564
|
+
if (!session)
|
|
565
|
+
return;
|
|
566
|
+
void session
|
|
567
|
+
.then((resolved) => {
|
|
568
|
+
resolved.agent.steer(message);
|
|
569
|
+
})
|
|
570
|
+
.catch((error) => console.error(`failed to load familiar session ${sessionKey} for steer`, error));
|
|
571
|
+
},
|
|
572
|
+
async followUpMessage(sessionKey, message, options = {}) {
|
|
573
|
+
const session = await getSession(sessionKey);
|
|
574
|
+
if (options.skipAmbient)
|
|
575
|
+
skipAmbientMessages.add(message);
|
|
576
|
+
session.agent.followUp(message);
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
function lastUserMessageSkipsAmbient(messages) {
|
|
580
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
581
|
+
const message = messages[index];
|
|
582
|
+
if (!message || typeof message !== "object" || !("role" in message))
|
|
583
|
+
continue;
|
|
584
|
+
if (message.role !== "user")
|
|
585
|
+
continue;
|
|
586
|
+
return skipAmbientMessages.has(message);
|
|
587
|
+
}
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
}
|