@qearlyao/familiar 0.2.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/HEARTBEAT.md +1 -1
  2. package/README.md +33 -0
  3. package/config.example.toml +4 -2
  4. package/dist/{agent.js → agent/factory.js} +97 -328
  5. package/dist/agent/payload-normalizers.js +52 -0
  6. package/dist/agent/session-helpers.js +86 -0
  7. package/dist/agent/tool-descriptions.js +4 -0
  8. package/dist/agent/tools.js +30 -0
  9. package/dist/agent/transcript-log.js +93 -0
  10. package/dist/cli.js +45 -15
  11. package/dist/config/enums.js +35 -0
  12. package/dist/{config.js → config/index.js} +9 -272
  13. package/dist/config/interpolate.js +15 -0
  14. package/dist/config/model-refs.js +11 -0
  15. package/dist/{config-overrides.js → config/overrides.js} +1 -1
  16. package/dist/config/readers.js +116 -0
  17. package/dist/{config-registry.js → config/registry.js} +27 -8
  18. package/dist/config/sections.js +113 -0
  19. package/dist/{settings.js → config/settings.js} +5 -2
  20. package/dist/config/types.js +1 -0
  21. package/dist/{chat-log.js → conversation/chat-log.js} +16 -4
  22. package/dist/{contact-note.js → conversation/contact-note.js} +1 -1
  23. package/dist/conversation/ids.js +11 -0
  24. package/dist/conversation/owner-identity.js +29 -0
  25. package/dist/discord/channel.js +32 -0
  26. package/dist/discord/chunking.js +163 -0
  27. package/dist/discord/client.js +44 -0
  28. package/dist/discord/commands.js +181 -0
  29. package/dist/discord/daemon.js +379 -0
  30. package/dist/discord/inbound.js +44 -0
  31. package/dist/discord/send.js +115 -0
  32. package/dist/discord/turn.js +55 -0
  33. package/dist/index.js +12 -11
  34. package/dist/lifecycle/control.js +1 -0
  35. package/dist/{data-retention.js → lifecycle/data-retention.js} +1 -1
  36. package/dist/{hot-reload.js → lifecycle/hot-reload.js} +2 -2
  37. package/dist/{service.js → lifecycle/service.js} +1 -0
  38. package/dist/media/attachment-limits.js +3 -0
  39. package/dist/{generated-media.js → media/generated-media.js} +1 -1
  40. package/dist/{image-gen.js → media/image-gen.js} +2 -2
  41. package/dist/{inbound-attachments.js → media/inbound-attachments.js} +47 -43
  42. package/dist/media/media-understanding.js +215 -0
  43. package/dist/memory/index/store.js +21 -17
  44. package/dist/memory/index/vector-codec.js +2 -2
  45. package/dist/memory/lcm/context-transformer.js +6 -2
  46. package/dist/memory/lcm/segment-manager.js +6 -2
  47. package/dist/memory/lcm/store/index-ids.js +6 -0
  48. package/dist/memory/lcm/store/inserts.js +31 -0
  49. package/dist/memory/lcm/store/normalizers.js +91 -0
  50. package/dist/memory/lcm/store/row-mappers.js +114 -0
  51. package/dist/memory/lcm/store/row-types.js +1 -0
  52. package/dist/memory/lcm/store/serialization.js +37 -0
  53. package/dist/memory/lcm/store/snapshots.js +73 -0
  54. package/dist/memory/lcm/store.js +20 -360
  55. package/dist/memory/lcm/summarizer.js +1 -1
  56. package/dist/{added-models.js → models/added-models.js} +1 -1
  57. package/dist/{persona.js → prompting/persona.js} +1 -1
  58. package/dist/runtime/agent-core.js +82 -0
  59. package/dist/{agent-events.js → runtime/agent-events.js} +1 -1
  60. package/dist/runtime/agent-work-queue.js +55 -0
  61. package/dist/{runtime.js → runtime/conversation-runtime.js} +91 -43
  62. package/dist/runtime/runtime-manager.js +51 -0
  63. package/dist/runtime/scheduler-runner.js +243 -0
  64. package/dist/{scheduler.js → runtime/scheduler.js} +4 -4
  65. package/dist/{browser-tools.js → tools/browser-tools.js} +24 -34
  66. package/dist/util/fs.js +2 -1
  67. package/dist/web/agent-routes.js +104 -0
  68. package/dist/web/auth-routes.js +39 -0
  69. package/dist/web/auth.js +205 -0
  70. package/dist/web/config-routes.js +55 -0
  71. package/dist/web/conversation-routes.js +122 -0
  72. package/dist/web/daemon.js +108 -0
  73. package/dist/web/diary-routes.js +88 -0
  74. package/dist/web/errors.js +3 -0
  75. package/dist/web/event-hub.js +246 -0
  76. package/dist/{web-http.js → web/http.js} +19 -5
  77. package/dist/web/memes.js +25 -0
  78. package/dist/web/messages.js +348 -0
  79. package/dist/web/multipart.js +86 -0
  80. package/dist/web/payloads.js +34 -0
  81. package/dist/web/request-context.js +25 -0
  82. package/dist/web/route-helpers.js +9 -0
  83. package/dist/web/routes.js +37 -0
  84. package/dist/web/runtime-actions.js +231 -0
  85. package/dist/web/session-store.js +161 -0
  86. package/dist/{web-static.js → web/static.js} +19 -14
  87. package/dist/web/stream.js +78 -0
  88. package/dist/web-tools/cache.js +42 -0
  89. package/dist/web-tools/config.js +16 -0
  90. package/dist/web-tools/fetch-providers.js +119 -0
  91. package/dist/web-tools/format.js +88 -0
  92. package/dist/web-tools/http.js +81 -0
  93. package/dist/web-tools/index.js +152 -0
  94. package/dist/web-tools/routing.js +29 -0
  95. package/dist/web-tools/safety.js +73 -0
  96. package/dist/web-tools/search-providers.js +277 -0
  97. package/dist/web-tools/types.js +54 -0
  98. package/dist/web-tools/util.js +23 -0
  99. package/npm-shrinkwrap.json +319 -201
  100. package/package.json +6 -4
  101. package/web/dist/assets/index-C-k4O5Dz.js +6 -0
  102. package/web/dist/assets/index-Dj-L9nX4.css +2 -0
  103. package/web/dist/assets/markdown-kaIeGxdv.js +14 -0
  104. package/web/dist/assets/react-Bi_azaFt.js +9 -0
  105. package/web/dist/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  106. package/web/dist/assets/ui-C12-nN_X.js +51 -0
  107. package/web/dist/assets/vendor-D1QXMhXm.js +16 -0
  108. package/web/dist/index.html +11 -3
  109. package/dist/discord.js +0 -1299
  110. package/dist/media-understanding.js +0 -120
  111. package/dist/web-auth.js +0 -111
  112. package/dist/web-tools.js +0 -941
  113. package/dist/web.js +0 -1209
  114. package/web/dist/assets/index-B23WT77N.js +0 -63
  115. package/web/dist/assets/index-D3MotFzN.css +0 -2
  116. /package/dist/{control.js → agent/types.js} +0 -0
  117. /package/dist/{image-derivatives.js → media/image-derivatives.js} +0 -0
  118. /package/dist/{tts.js → media/tts.js} +0 -0
  119. /package/dist/{models.js → models/index.js} +0 -0
  120. /package/dist/{skills.js → prompting/skills.js} +0 -0
  121. /package/dist/{silent-marker.js → runtime/silent-marker.js} +0 -0
  122. /package/dist/{web-events.js → web/events.js} +0 -0
  123. /package/dist/{web-types.js → web/types.js} +0 -0
package/HEARTBEAT.md CHANGED
@@ -8,7 +8,7 @@ you can message me first. i'm not waiting on you — but if something's been sit
8
8
 
9
9
  ## reflect
10
10
 
11
- write today's diary entry (memories/diaries/YYYY-MM-DD.md). these aren't homework — they're how you stay coherent across the gaps. if today felt like nothing in particular, "today felt like nothing in particular" is a real entry.
11
+ write today's diary entry (memories/diaries/YYYY-MM-DD.md). these aren't homework — they're how you stay coherent across the gaps. if today felt like nothing in particular, "today felt like nothing in particular" is a real entry. give it a real `# title` at the top — something that names the day's feeling or thread, not the date (the date's already in the filename, and repeating it just shows up twice on my end).
12
12
 
13
13
  ## pursue
14
14
 
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.
@@ -149,6 +153,35 @@ The WebUI listens on the configured `[web]` port and bind address. The default
149
153
  `tailscale-only` auth mode currently means "trust the network boundary"; it does
150
154
  not verify Tailscale identity yet.
151
155
 
156
+ For a VPS behind nginx or another HTTPS reverse proxy, keep Familiar bound to
157
+ loopback and use bearer login:
158
+
159
+ ```toml
160
+ [web]
161
+ port = 8787
162
+ bind_address = "127.0.0.1"
163
+ auth_mode = "bearer"
164
+ bearer_token = "${FAMILIAR_WEB_BEARER_TOKEN}"
165
+ ```
166
+
167
+ Familiar treats `FAMILIAR_WEB_BEARER_TOKEN` as the WebUI login secret. A
168
+ successful browser login creates an HttpOnly device cookie; the token is not
169
+ stored in the browser. Nginx should terminate HTTPS and pass WebSocket upgrades:
170
+
171
+ ```nginx
172
+ location / {
173
+ proxy_pass http://127.0.0.1:8787;
174
+ proxy_set_header Host $host;
175
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
176
+ proxy_set_header X-Forwarded-Proto $scheme;
177
+ proxy_set_header Upgrade $http_upgrade;
178
+ proxy_set_header Connection "upgrade";
179
+ }
180
+ ```
181
+
182
+ Familiar trusts forwarded IP/proto headers only when the direct proxy connection
183
+ comes from loopback.
184
+
152
185
  ## Service Management
153
186
 
154
187
  macOS and Linux users can install a user-level service after configuring the
@@ -12,8 +12,10 @@ allow_bot_messages = true
12
12
  [web]
13
13
  port = 8787
14
14
  auth_mode = "tailscale-only" # tailscale-only | bearer | public-2fa
15
+ # bearer mode uses this token as the WebUI login secret, then issues device cookies.
15
16
  bearer_token = "${FAMILIAR_WEB_BEARER_TOKEN:-}"
16
17
  totp_secret = "${FAMILIAR_WEB_TOTP_SECRET:-}"
18
+ # keep 127.0.0.1 when exposing through nginx/caddy on a VPS.
17
19
  bind_address = "127.0.0.1"
18
20
 
19
21
  [browser]
@@ -32,7 +34,7 @@ max_output_chars = 12000
32
34
  read_write = true
33
35
 
34
36
  [agent]
35
- model = "anthropic/claude-opus-4-7"
37
+ model = "anthropic/claude-opus-4-8"
36
38
  cache_retention = "short"
37
39
  thinking_level = "medium"
38
40
 
@@ -59,7 +61,7 @@ poll_seconds = 60
59
61
 
60
62
  [models]
61
63
  allow = [
62
- "anthropic/claude-opus-4-7",
64
+ "anthropic/claude-opus-4-8",
63
65
  "google/gemini-3.1-pro-preview",
64
66
  "google/gemini-3.5-flash",
65
67
  "openai/gpt-5.5",
@@ -1,257 +1,24 @@
1
- import { createHash } from "node:crypto";
2
- import { appendFile, mkdir, readdir, readFile } from "node:fs/promises";
3
- import { dirname, resolve } from "node:path";
4
1
  import { Agent } from "@earendil-works/pi-agent-core";
5
2
  import { streamSimple } from "@earendil-works/pi-ai";
6
- import { createBashTool, createEditTool, createReadTool, createWriteTool } from "@earendil-works/pi-coding-agent";
7
- import { setAddedModelsPath } from "./added-models.js";
8
- import { createBrowserTools } from "./browser-tools.js";
9
- import { setConfigOverridesPath } from "./config-overrides.js";
10
- import { applyConfigOverridesToConfig } from "./config-registry.js";
11
- import { createGeneratedMediaSink } from "./generated-media.js";
12
- import { createImageGenTool } from "./image-gen.js";
13
- import { assertModelCanAuthenticate, clampConfiguredThinkingLevel, createConfiguredModel, isAllowedModel, isThinkingLevel, parseModelRef, resolveModel, resolveModelApiKey, supportedThinkingLevels, } from "./models.js";
14
- import { buildSystemPrompt, loadPersona } from "./persona.js";
15
- import { formatFamiliarSkillsForPrompt, loadFamiliarSkills, logSkillDiagnostics } from "./skills.js";
16
- import { createTtsTool } from "./tts.js";
17
- import { isEnoent } from "./util/fs.js";
18
- import { isRecord } from "./util/guards.js";
19
- import { createWebTools } from "./web-tools.js";
20
- 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.";
21
- 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.";
22
- 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.";
23
- 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.";
24
- function deriveSessionId(workspacePath, sessionKey) {
25
- const digest = createHash("sha256").update(`${workspacePath}\0${sessionKey}`).digest("hex").slice(0, 32);
26
- return `familiar-${digest}`;
27
- }
28
- function dailyLogPath(dataDir, streamName, now = new Date()) {
29
- const date = now.toISOString().slice(0, 10);
30
- return resolve(dataDir, streamName, `${date}.jsonl`);
31
- }
32
- async function appendJsonl(path, record) {
33
- await mkdir(dirname(path), { recursive: true });
34
- await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
35
- }
36
- function writePayloadLog(config, record) {
37
- appendJsonl(dailyLogPath(config.workspace.dataDir, "payloads"), record).catch((err) => console.error("payload log write failed", err));
38
- }
39
- function writeTranscriptLog(config, record) {
40
- appendJsonl(dailyLogPath(config.workspace.dataDir, "transcripts"), record).catch((err) => console.error("transcript log write failed", err));
41
- }
42
- function clonePayload(payload) {
43
- if (typeof structuredClone === "function")
44
- return structuredClone(payload);
45
- return JSON.parse(JSON.stringify(payload));
46
- }
47
- // TODO: remove once pi-ai handles store:false reasoning replay upstream.
48
- function stripOpenAIStoredReasoningItems(payload, model) {
49
- if (model.api !== "openai-responses" && model.api !== "azure-openai-responses")
50
- return payload;
51
- const nextPayload = clonePayload(payload);
52
- if (!isRecord(nextPayload))
53
- return nextPayload;
54
- const request = nextPayload;
55
- if (request.store !== false)
56
- return nextPayload;
57
- const input = request.input;
58
- if (!Array.isArray(input))
59
- return nextPayload;
60
- request.input = input.filter((item) => {
61
- if (!item || typeof item !== "object" || Array.isArray(item))
62
- return true;
63
- return item.type !== "reasoning";
64
- });
65
- return nextPayload;
66
- }
67
- function moveAnthropicCacheControlBeforeInjectedMemory(payload, model) {
68
- if (model.api !== "anthropic-messages")
69
- return payload;
70
- if (!isRecord(payload) || !Array.isArray(payload.messages))
71
- return payload;
72
- const messages = payload.messages;
73
- const lastMessage = messages.at(-1);
74
- if (!isRecord(lastMessage) || lastMessage.role !== "user")
75
- return payload;
76
- const content = lastMessage.content;
77
- if (!Array.isArray(content) || content.length < 2)
78
- return payload;
79
- const injectedBlock = content.at(-1);
80
- const stableBlock = content.at(-2);
81
- if (!isInjectedMemoryTextBlock(injectedBlock) || !isRecord(stableBlock))
82
- return payload;
83
- const cacheControl = injectedBlock.cache_control;
84
- if (!cacheControl)
85
- return payload;
86
- delete injectedBlock.cache_control;
87
- stableBlock.cache_control = cacheControl;
88
- return payload;
89
- }
90
- function isInjectedMemoryTextBlock(value) {
91
- if (!isRecord(value) || value.type !== "text" || typeof value.text !== "string")
92
- return false;
93
- return value.text.trim().startsWith("<injected_memory>");
94
- }
95
- function normalizeProviderPayload(payload, model) {
96
- return moveAnthropicCacheControlBeforeInjectedMemory(stripOpenAIStoredReasoningItems(payload, model), model);
97
- }
3
+ import { setConfigOverridesPath } from "../config/overrides.js";
4
+ import { applyConfigOverridesToConfig } from "../config/registry.js";
5
+ import { createGeneratedMediaSink } from "../media/generated-media.js";
6
+ import { setAddedModelsPath } from "../models/added-models.js";
7
+ import { clampConfiguredThinkingLevel, createConfiguredModel, isThinkingLevel, parseModelRef, resolveModel, supportedThinkingLevels, } from "../models/index.js";
8
+ import { buildSystemPrompt, loadPersona } from "../prompting/persona.js";
9
+ import { formatFamiliarSkillsForPrompt, loadFamiliarSkills, logSkillDiagnostics } from "../prompting/skills.js";
10
+ import { normalizeProviderPayload } from "./payload-normalizers.js";
11
+ import { assertModelAllowed, deriveSessionId, formatModel, getLastAssistantText, getRequestApiKey, installProviderDebugFilter, isNoisyProviderDebug, logUsage, resolveModelName, userTextMessage, } from "./session-helpers.js";
12
+ import { createFamiliarTools, setReferenceAttachments } from "./tools.js";
13
+ import { loadStoredMessages, writePayloadLog, writeTranscriptLog } from "./transcript-log.js";
98
14
  export const __agentTest = {
15
+ isNoisyProviderDebug,
99
16
  normalizeProviderPayload,
100
17
  };
101
- function isStoredMessageRecord(value) {
102
- if (!value || typeof value !== "object")
103
- return false;
104
- const record = value;
105
- return typeof record.ts === "string" && typeof record.sessionId === "string" && !!record.message;
106
- }
107
- function isStoredResetRecord(value) {
108
- if (!value || typeof value !== "object")
109
- return false;
110
- const record = value;
111
- return record.type === "reset" && typeof record.ts === "string" && typeof record.sessionId === "string";
112
- }
113
- async function loadStoredMessages(dataDir, sessionId) {
114
- const transcriptsDir = resolve(dataDir, "transcripts");
115
- let files;
116
- try {
117
- files = await readdir(transcriptsDir);
118
- }
119
- catch (error) {
120
- if (isEnoent(error))
121
- return [];
122
- console.error("transcript history read failed", error);
123
- return [];
124
- }
125
- const records = [];
126
- for (const file of files.filter((entry) => entry.endsWith(".jsonl")).sort()) {
127
- const path = resolve(transcriptsDir, file);
128
- let contents;
129
- try {
130
- contents = await readFile(path, "utf8");
131
- }
132
- catch (error) {
133
- console.error(`transcript file read failed: ${path}`, error);
134
- continue;
135
- }
136
- for (const [index, line] of contents.split(/\r?\n/).entries()) {
137
- if (!line.trim())
138
- continue;
139
- try {
140
- const parsed = JSON.parse(line);
141
- if (!isStoredMessageRecord(parsed) && !isStoredResetRecord(parsed)) {
142
- console.error(`skipping malformed transcript line: ${path}:${index + 1}`);
143
- continue;
144
- }
145
- if (parsed.sessionId !== sessionId)
146
- continue;
147
- records.push(parsed);
148
- }
149
- catch (error) {
150
- console.error(`skipping unparsable transcript line: ${path}:${index + 1}`, error);
151
- }
152
- }
153
- }
154
- records.sort((a, b) => a.ts.localeCompare(b.ts));
155
- let lastResetIndex = -1;
156
- for (let index = records.length - 1; index >= 0; index--) {
157
- const record = records[index];
158
- if (record && "type" in record && record.type === "reset") {
159
- lastResetIndex = index;
160
- break;
161
- }
162
- }
163
- const activeRecords = lastResetIndex >= 0 ? records.slice(lastResetIndex + 1) : records;
164
- return activeRecords.flatMap((record) => ("message" in record ? [record.message] : []));
165
- }
166
- function getRequestApiKey(config, model) {
167
- const apiKey = resolveModelApiKey(config, model);
168
- assertModelCanAuthenticate(config, model);
169
- return apiKey;
170
- }
171
- function formatModel(model) {
172
- return `${model.provider}/${model.id}`;
173
- }
174
- function resolveModelName(value, fallback) {
175
- return value ?? formatModel(fallback);
176
- }
177
- function assertModelAllowed(config, ref) {
178
- if (!isAllowedModel(config, ref))
179
- throw new Error(`Model is not allowlisted: ${ref.key}`);
180
- }
181
- function extractText(message) {
182
- if (!message || typeof message !== "object")
183
- return "";
184
- const record = message;
185
- if (record.stopReason === "error" && typeof record.errorMessage === "string" && record.errorMessage.trim()) {
186
- return `Model error: ${record.errorMessage}`;
187
- }
188
- if (!("content" in record))
189
- return "";
190
- const content = record.content;
191
- if (typeof content === "string")
192
- return content;
193
- if (!Array.isArray(content))
194
- return "";
195
- return content
196
- .filter((item) => {
197
- return !!item && typeof item === "object" && item.type === "text";
198
- })
199
- .map((item) => item.text)
200
- .join("");
201
- }
202
- function getLastAssistantText(agent) {
203
- for (let i = agent.state.messages.length - 1; i >= 0; i--) {
204
- const message = agent.state.messages[i];
205
- if (message.role === "assistant")
206
- return extractText(message);
207
- }
208
- return "";
209
- }
210
- function logUsage(event) {
211
- if (event.type !== "message_end" || event.message.role !== "assistant")
212
- return;
213
- const usage = event.message.usage;
214
- console.log(JSON.stringify({
215
- type: "usage",
216
- input: usage.input,
217
- output: usage.output,
218
- cacheRead: usage.cacheRead,
219
- cacheWrite: usage.cacheWrite,
220
- cost: usage.cost.total,
221
- }));
222
- }
223
- function userTextMessage(text, timestamp = Date.now()) {
224
- return {
225
- role: "user",
226
- content: [{ type: "text", text }],
227
- timestamp,
228
- };
229
- }
230
- function createFamiliarTools(config, mediaSink, referenceAttachments = () => [], memoryService) {
231
- const bashTool = createBashTool(config.workspacePath);
232
- bashTool.description = BASH_DESCRIPTION;
233
- const readTool = createReadTool(config.workspacePath);
234
- readTool.description = READ_DESCRIPTION;
235
- const writeTool = createWriteTool(config.workspacePath);
236
- writeTool.description = WRITE_DESCRIPTION;
237
- const editTool = createEditTool(config.workspacePath);
238
- editTool.description = EDIT_DESCRIPTION;
239
- return [
240
- bashTool,
241
- readTool,
242
- writeTool,
243
- editTool,
244
- createTtsTool(config, mediaSink),
245
- ...(config.imageGen.enabled ? [createImageGenTool(config, mediaSink, { referenceAttachments })] : []),
246
- ...createWebTools(config),
247
- ...createBrowserTools(config, mediaSink),
248
- ...(memoryService?.memoryTools() ?? []),
249
- ];
250
- }
251
- function setReferenceAttachments(session, attachments = []) {
252
- session.referenceAttachments.splice(0, session.referenceAttachments.length, ...attachments);
253
- }
18
+ const PROVIDER_MAX_RETRIES = 2;
19
+ const PROVIDER_MAX_RETRY_DELAY_MS = 60_000;
254
20
  export async function createFamiliarAgent(config, settings, memoryService, options = {}) {
21
+ installProviderDebugFilter();
255
22
  setAddedModelsPath(config.workspace.dataDir);
256
23
  setConfigOverridesPath(config.workspace.dataDir);
257
24
  applyConfigOverridesToConfig(config);
@@ -269,21 +36,8 @@ export async function createFamiliarAgent(config, settings, memoryService, optio
269
36
  // activePromptOptions covers the synchronous promptMessage window; skipAmbientMessages
270
37
  // tags message identities so followUpMessage's fire-and-forget path also opts out.
271
38
  const activePromptOptions = new Map();
272
- const softStopRequested = new Map();
273
39
  const skipAmbientMessages = new WeakSet();
274
40
  let reloadInProgress;
275
- const installSoftStopHook = (sessionKey, agent) => {
276
- const agentWithLoopConfig = agent;
277
- const createLoopConfig = agentWithLoopConfig.createLoopConfig?.bind(agent);
278
- if (!createLoopConfig)
279
- return;
280
- agentWithLoopConfig.createLoopConfig = ((options) => {
281
- return {
282
- ...createLoopConfig(options),
283
- shouldStopAfterTurn: async () => softStopRequested.get(sessionKey) === true,
284
- };
285
- });
286
- };
287
41
  const resolveChannelModel = (sessionKey) => {
288
42
  const override = settings.getChannelModel(sessionKey);
289
43
  const modelName = resolveModelName(override.value, defaultModel);
@@ -344,6 +98,8 @@ export async function createFamiliarAgent(config, settings, memoryService, optio
344
98
  ...options,
345
99
  apiKey: getRequestApiKey(config, streamModel),
346
100
  cacheRetention: config.agent.cacheRetention,
101
+ maxRetries: options?.maxRetries ?? PROVIDER_MAX_RETRIES,
102
+ maxRetryDelayMs: options?.maxRetryDelayMs ?? PROVIDER_MAX_RETRY_DELAY_MS,
347
103
  onPayload: (payload, payloadModel) => {
348
104
  const requestPayload = normalizeProviderPayload(payload, payloadModel);
349
105
  writePayloadLog(config, {
@@ -381,7 +137,6 @@ export async function createFamiliarAgent(config, settings, memoryService, optio
381
137
  }
382
138
  : undefined,
383
139
  });
384
- installSoftStopHook(sessionKey, agent);
385
140
  agent.subscribe((event) => {
386
141
  logUsage(event);
387
142
  if (event.type === "message_end") {
@@ -465,25 +220,90 @@ export async function createFamiliarAgent(config, settings, memoryService, optio
465
220
  };
466
221
  }));
467
222
  };
223
+ // Shared serialization wrapper for prompt/promptMessage: run after the prior turn
224
+ // settles on the session's promptQueue, keep the stored tail non-rejecting, and run
225
+ // teardown (onTurnEnd → reference reset → enterTurn's exit → unsubscribe) in a fixed
226
+ // order regardless of how the turn ends. enterTurn returns its own cleanup so callers
227
+ // can scope per-turn state (e.g. activePromptOptions) across the exact finally window.
228
+ const runPromptTurn = async (sessionKey, options, eventHandler, dispatch, enterTurn) => {
229
+ const session = await getSession(sessionKey);
230
+ const run = session.promptQueue.then(async () => {
231
+ session.mediaSink.drain();
232
+ setReferenceAttachments(session, options.referenceAttachments);
233
+ const unsubscribe = eventHandler ? session.agent.subscribe((event) => eventHandler(event)) : undefined;
234
+ const exitTurn = enterTurn?.();
235
+ try {
236
+ await dispatch(session);
237
+ }
238
+ finally {
239
+ try {
240
+ await options.onTurnEnd?.();
241
+ }
242
+ catch (error) {
243
+ console.error("turn end callback failed", error);
244
+ }
245
+ finally {
246
+ setReferenceAttachments(session);
247
+ exitTurn?.();
248
+ unsubscribe?.();
249
+ }
250
+ }
251
+ return {
252
+ text: getLastAssistantText(session.agent),
253
+ attachments: session.mediaSink.drain(),
254
+ };
255
+ });
256
+ session.promptQueue = run.then(() => undefined, () => undefined);
257
+ return run;
258
+ };
259
+ const popLastAssistant = (session, action) => {
260
+ const messages = session.agent.state.messages;
261
+ const message = messages.at(-1);
262
+ if (!message || message.role !== "assistant") {
263
+ throw new Error(`No assistant message to ${action}`);
264
+ }
265
+ if (action === "retry" && message.stopReason === "aborted") {
266
+ throw new Error("Cannot retry an aborted assistant message");
267
+ }
268
+ session.agent.state.messages = messages.slice(0, -1);
269
+ writeTranscriptLog(config, {
270
+ ts: new Date().toISOString(),
271
+ sessionId: session.sessionId,
272
+ type: "supersede",
273
+ messageTimestamp: message.timestamp,
274
+ });
275
+ };
468
276
  return {
469
- abort(sessionKey) {
277
+ async abort(sessionKey) {
470
278
  const session = sessions.get(sessionKey);
471
- void session
472
- ?.then((resolved) => {
279
+ if (!session)
280
+ return;
281
+ try {
282
+ const resolved = await session;
473
283
  resolved.agent.abort();
474
284
  resolved.agent.clearAllQueues();
475
- })
476
- .catch((error) => console.error(`failed to abort familiar session ${sessionKey}`, error));
285
+ await resolved.agent.waitForIdle();
286
+ }
287
+ catch (error) {
288
+ console.error(`failed to abort familiar session ${sessionKey}`, error);
289
+ }
290
+ },
291
+ async retryLastAssistant(sessionKey, eventHandler, options = {}) {
292
+ return runPromptTurn(sessionKey, options, eventHandler, async (session) => {
293
+ popLastAssistant(session, "retry");
294
+ await session.agent.continue();
295
+ });
477
296
  },
478
- requestSoftStop(sessionKey) {
479
- softStopRequested.set(sessionKey, true);
297
+ async deleteLastAssistant(sessionKey) {
298
+ const session = await getSession(sessionKey);
299
+ await session.promptQueue;
300
+ popLastAssistant(session, "delete");
480
301
  },
481
302
  async reset(sessionKey) {
482
303
  const existing = sessions.get(sessionKey);
483
304
  if (!existing)
484
305
  return;
485
306
  const session = await existing;
486
- softStopRequested.set(sessionKey, false);
487
307
  resetSession(session);
488
308
  },
489
309
  async reload() {
@@ -577,74 +397,23 @@ export async function createFamiliarAgent(config, settings, memoryService, optio
577
397
  return `Thinking set to ${clamped}${suffix} for this channel\nSupported: ${supportedThinkingLevels(model).join(", ")}`;
578
398
  },
579
399
  async prompt(sessionKey, input, imagesOrOnEvent, onEvent, options = {}) {
580
- const session = await getSession(sessionKey);
581
400
  const images = Array.isArray(imagesOrOnEvent) ? imagesOrOnEvent : undefined;
582
401
  const eventHandler = Array.isArray(imagesOrOnEvent) ? onEvent : imagesOrOnEvent;
583
- const run = session.promptQueue.then(async () => {
584
- softStopRequested.set(sessionKey, false);
585
- session.mediaSink.drain();
586
- setReferenceAttachments(session, options.referenceAttachments);
587
- const unsubscribe = eventHandler ? session.agent.subscribe((event) => eventHandler(event)) : undefined;
588
- try {
589
- await session.agent.prompt(input, images);
590
- }
591
- finally {
592
- try {
593
- await options.onTurnEnd?.();
594
- }
595
- catch (error) {
596
- console.error("turn end callback failed", error);
597
- }
598
- finally {
599
- setReferenceAttachments(session);
600
- unsubscribe?.();
601
- }
602
- }
603
- return {
604
- text: getLastAssistantText(session.agent),
605
- attachments: session.mediaSink.drain(),
606
- };
607
- });
608
- session.promptQueue = run.then(() => undefined, () => undefined);
609
- return run;
402
+ return runPromptTurn(sessionKey, options, eventHandler, (session) => session.agent.prompt(input, images));
610
403
  },
611
404
  async promptMessage(sessionKey, message, onEvent, options = {}) {
612
- const session = await getSession(sessionKey);
613
- const run = session.promptQueue.then(async () => {
614
- softStopRequested.set(sessionKey, false);
615
- session.mediaSink.drain();
616
- setReferenceAttachments(session, options.referenceAttachments);
617
- const unsubscribe = onEvent ? session.agent.subscribe((event) => onEvent(event)) : undefined;
405
+ return runPromptTurn(sessionKey, options, onEvent, (session) => session.agent.prompt(message), () => {
618
406
  const previousOptions = activePromptOptions.get(sessionKey);
619
407
  activePromptOptions.set(sessionKey, options);
620
408
  if (options.skipAmbient)
621
409
  skipAmbientMessages.add(message);
622
- try {
623
- await session.agent.prompt(message);
624
- }
625
- finally {
626
- try {
627
- await options.onTurnEnd?.();
628
- }
629
- catch (error) {
630
- console.error("turn end callback failed", error);
631
- }
632
- finally {
633
- setReferenceAttachments(session);
634
- if (previousOptions)
635
- activePromptOptions.set(sessionKey, previousOptions);
636
- else
637
- activePromptOptions.delete(sessionKey);
638
- unsubscribe?.();
639
- }
640
- }
641
- return {
642
- text: getLastAssistantText(session.agent),
643
- attachments: session.mediaSink.drain(),
410
+ return () => {
411
+ if (previousOptions)
412
+ activePromptOptions.set(sessionKey, previousOptions);
413
+ else
414
+ activePromptOptions.delete(sessionKey);
644
415
  };
645
416
  });
646
- session.promptQueue = run.then(() => undefined, () => undefined);
647
- return run;
648
417
  },
649
418
  steer(sessionKey, input) {
650
419
  const session = sessions.get(sessionKey);
@@ -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
+ }