@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.
Files changed (72) hide show
  1. package/.env.example +31 -0
  2. package/HEARTBEAT.md +23 -0
  3. package/LICENSE +21 -0
  4. package/MEMORY.md +1 -0
  5. package/README.md +245 -0
  6. package/SOUL.md +13 -0
  7. package/USER.md +13 -0
  8. package/config.example.toml +221 -0
  9. package/dist/agent-events.js +167 -0
  10. package/dist/agent.js +590 -0
  11. package/dist/browser-tools.js +638 -0
  12. package/dist/chat-log.js +130 -0
  13. package/dist/cli.js +168 -0
  14. package/dist/config.js +804 -0
  15. package/dist/data-retention.js +54 -0
  16. package/dist/discord.js +1203 -0
  17. package/dist/generated-media.js +86 -0
  18. package/dist/image-derivatives.js +102 -0
  19. package/dist/image-gen.js +440 -0
  20. package/dist/inbound-attachments.js +266 -0
  21. package/dist/index.js +10 -0
  22. package/dist/media-understanding.js +120 -0
  23. package/dist/memory/diary/ambient-injector.js +180 -0
  24. package/dist/memory/diary/ambient.js +124 -0
  25. package/dist/memory/diary/chunks.js +231 -0
  26. package/dist/memory/diary/index.js +3 -0
  27. package/dist/memory/diary/indexer.js +93 -0
  28. package/dist/memory/doctor.js +250 -0
  29. package/dist/memory/index/chunk-indexer.js +151 -0
  30. package/dist/memory/index/embedding-provider.js +119 -0
  31. package/dist/memory/index/fts-query.js +18 -0
  32. package/dist/memory/index/retrieval.js +246 -0
  33. package/dist/memory/index/schema.js +157 -0
  34. package/dist/memory/index/store.js +513 -0
  35. package/dist/memory/index/vec.js +72 -0
  36. package/dist/memory/index/vector-codec.js +27 -0
  37. package/dist/memory/lcm/backfill.js +247 -0
  38. package/dist/memory/lcm/condense.js +146 -0
  39. package/dist/memory/lcm/context-transformer.js +662 -0
  40. package/dist/memory/lcm/context.js +421 -0
  41. package/dist/memory/lcm/eviction-score.js +38 -0
  42. package/dist/memory/lcm/index.js +6 -0
  43. package/dist/memory/lcm/indexer.js +200 -0
  44. package/dist/memory/lcm/normalize.js +235 -0
  45. package/dist/memory/lcm/schema.js +188 -0
  46. package/dist/memory/lcm/segment-manager.js +136 -0
  47. package/dist/memory/lcm/store.js +722 -0
  48. package/dist/memory/lcm/summarizer.js +258 -0
  49. package/dist/memory/lcm/types.js +1 -0
  50. package/dist/memory/operator.js +477 -0
  51. package/dist/memory/service.js +202 -0
  52. package/dist/memory/tools.js +205 -0
  53. package/dist/models.js +165 -0
  54. package/dist/persona.js +54 -0
  55. package/dist/runtime.js +493 -0
  56. package/dist/scheduler.js +200 -0
  57. package/dist/settings.js +116 -0
  58. package/dist/skills.js +38 -0
  59. package/dist/tts.js +143 -0
  60. package/dist/web-auth.js +105 -0
  61. package/dist/web-events.js +114 -0
  62. package/dist/web-http.js +29 -0
  63. package/dist/web-static.js +106 -0
  64. package/dist/web-tools.js +940 -0
  65. package/dist/web-types.js +2 -0
  66. package/dist/web.js +844 -0
  67. package/package.json +60 -0
  68. package/web/dist/assets/index-ClgkMgaq.css +2 -0
  69. package/web/dist/assets/index-Cu2QquuR.js +59 -0
  70. package/web/dist/favicon.svg +1 -0
  71. package/web/dist/icons.svg +24 -0
  72. package/web/dist/index.html +20 -0
@@ -0,0 +1,205 @@
1
+ import { Type } from "typebox";
2
+ import { retrieveMemory } from "./index/retrieval.js";
3
+ const DEFAULT_RECALL_LIMIT = 8;
4
+ const MAX_TEXT_PREVIEW_CHARS = 700;
5
+ const MAX_CONTEXT_LABEL_CHARS = 120;
6
+ const MAX_SOURCE_LABEL_CHARS = 160;
7
+ const MEMORY_SCOPE_CORPORA = {
8
+ diary: ["diary_chunk"],
9
+ factual: ["atomic_fact", "lcm_record", "lcm_summary"],
10
+ all: undefined,
11
+ };
12
+ const memoryRecallSchema = Type.Object({
13
+ query: Type.String({ description: "Natural-language memory search query." }),
14
+ scope: Type.Optional(Type.Union([Type.Literal("diary"), Type.Literal("factual"), Type.Literal("all")], {
15
+ default: "all",
16
+ description: "all searches every memory corpus; diary searches long-term diary memory; factual searches facts and conversation memory.",
17
+ })),
18
+ mode: Type.Optional(Type.Union([Type.Literal("lexical"), Type.Literal("semantic"), Type.Literal("hybrid")], {
19
+ default: "hybrid",
20
+ description: "hybrid uses lexical and semantic recall; lexical and semantic restrict to one mode.",
21
+ })),
22
+ before: Type.Optional(Type.String({
23
+ description: "Only recall chunks whose metadata.timestamp or createdAt is at or before this ISO 8601 time.",
24
+ })),
25
+ after: Type.Optional(Type.String({
26
+ description: "Only recall chunks whose metadata.timestamp or createdAt is at or after this ISO 8601 time.",
27
+ })),
28
+ limit: Type.Optional(Type.Number({
29
+ default: DEFAULT_RECALL_LIMIT,
30
+ minimum: 1,
31
+ maximum: 20,
32
+ })),
33
+ }, { additionalProperties: false });
34
+ const memoryOpenSchema = Type.Object({
35
+ id: Type.Number({
36
+ description: "Memory chunk id returned by memory_recall.",
37
+ minimum: 1,
38
+ }),
39
+ }, { additionalProperties: false });
40
+ export function createMemoryTools(deps) {
41
+ return [makeMemoryRecallTool(deps), makeMemoryOpenTool(deps)];
42
+ }
43
+ function makeMemoryRecallTool(deps) {
44
+ return {
45
+ name: "memory_recall",
46
+ label: "Memory Recall",
47
+ description: "search memory for diary, fact, or conversation chunks. returns previews and ids; use memory_open for full text and source details.",
48
+ parameters: memoryRecallSchema,
49
+ async execute(_toolCallId, input, signal) {
50
+ const query = input.query.trim();
51
+ if (!query)
52
+ throw new Error("memory_recall query is required.");
53
+ const scope = input.scope ?? "all";
54
+ const mode = input.mode ?? "hybrid";
55
+ const limit = clampLimit(input.limit);
56
+ assertIsoTime(input.before, "before");
57
+ assertIsoTime(input.after, "after");
58
+ const hits = await retrieveMemory({
59
+ query,
60
+ store: deps.store,
61
+ embeddingProvider: deps.embeddingProvider,
62
+ scope: { corpora: MEMORY_SCOPE_CORPORA[scope], before: input.before, after: input.after },
63
+ limit,
64
+ useLexical: mode !== "semantic",
65
+ useSemantic: mode !== "lexical",
66
+ signal,
67
+ });
68
+ return {
69
+ content: [{ type: "text", text: formatRecallResults(hits) }],
70
+ details: {
71
+ query,
72
+ scope,
73
+ mode,
74
+ limit,
75
+ resultCount: hits.length,
76
+ ids: hits.map((hit) => hit.id),
77
+ },
78
+ };
79
+ },
80
+ };
81
+ }
82
+ function makeMemoryOpenTool(deps) {
83
+ return {
84
+ name: "memory_open",
85
+ label: "Memory Open",
86
+ description: "open one stored memory chunk by id. returns the full text plus source details.",
87
+ parameters: memoryOpenSchema,
88
+ async execute(_toolCallId, input) {
89
+ const id = Math.trunc(input.id);
90
+ if (!Number.isInteger(input.id) || id < 1)
91
+ throw new Error("memory_open id must be a positive integer.");
92
+ const chunk = deps.store.getChunk(id);
93
+ if (!chunk) {
94
+ return {
95
+ content: [{ type: "text", text: `No memory chunk found for id ${id}.` }],
96
+ details: { id, found: false },
97
+ };
98
+ }
99
+ return {
100
+ content: [{ type: "text", text: formatOpenChunk(chunk) }],
101
+ details: {
102
+ id,
103
+ found: true,
104
+ corpus: chunk.corpus,
105
+ sourceId: chunk.sourceId,
106
+ sourceRef: chunk.sourceRef,
107
+ chunkIndex: chunk.chunkIndex,
108
+ sources: chunk.sources,
109
+ },
110
+ };
111
+ },
112
+ };
113
+ }
114
+ function clampLimit(value) {
115
+ if (value === undefined)
116
+ return DEFAULT_RECALL_LIMIT;
117
+ if (!Number.isInteger(value) || value < 1)
118
+ throw new Error("memory_recall limit must be a positive integer.");
119
+ return Math.min(value, 20);
120
+ }
121
+ function assertIsoTime(value, name) {
122
+ if (value === undefined)
123
+ return;
124
+ if (!Number.isFinite(Date.parse(value)))
125
+ throw new Error(`memory_recall ${name} must be an ISO 8601 timestamp.`);
126
+ }
127
+ function formatRecallResults(hits) {
128
+ if (hits.length === 0)
129
+ return "No matching memories found.";
130
+ return hits.map((hit, index) => formatRecallHit(hit, index + 1)).join("\n\n");
131
+ }
132
+ function formatRecallHit(hit, ordinal) {
133
+ const chunk = hit.chunk;
134
+ const lines = [
135
+ `${ordinal}. id=${hit.id} type=${memoryTypeLabel(chunk)} score=${hit.score.toFixed(4)}`,
136
+ ...compactContextLines(chunk),
137
+ `preview: ${previewText(chunk.snippet || chunk.text, MAX_TEXT_PREVIEW_CHARS)}`,
138
+ ];
139
+ return lines.join("\n");
140
+ }
141
+ function formatOpenChunk(chunk) {
142
+ const lines = [
143
+ `id=${chunk.id} type=${memoryTypeLabel(chunk)}`,
144
+ ...compactContextLines(chunk),
145
+ ...openSourceLines(chunk),
146
+ `stored=${formatUnixTimestamp(chunk.createdAt)} updated=${formatUnixTimestamp(chunk.updatedAt)}`,
147
+ "",
148
+ chunk.text,
149
+ ];
150
+ return lines.join("\n");
151
+ }
152
+ function memoryTypeLabel(chunk) {
153
+ if (chunk.corpus === "diary_chunk")
154
+ return "diary";
155
+ if (chunk.corpus === "lcm_record")
156
+ return "conversation";
157
+ if (chunk.corpus === "lcm_summary")
158
+ return "conversation_summary";
159
+ if (chunk.corpus === "atomic_fact")
160
+ return "fact";
161
+ return chunk.corpus;
162
+ }
163
+ function compactContextLines(chunk) {
164
+ const lines = [];
165
+ const happenedAt = metadataString(chunk.metadata, "happenedAt") ?? metadataString(chunk.metadata, "timestamp");
166
+ const date = metadataString(chunk.metadata, "date");
167
+ const heading = metadataString(chunk.metadata, "heading");
168
+ if (happenedAt)
169
+ lines.push(`when=${happenedAt}`);
170
+ else if (date)
171
+ lines.push(`when=${date}`);
172
+ if (heading)
173
+ lines.push(`title=${previewText(heading, MAX_CONTEXT_LABEL_CHARS)}`);
174
+ return lines;
175
+ }
176
+ function openSourceLines(chunk) {
177
+ const sources = chunk.sources.length > 0
178
+ ? chunk.sources
179
+ : [{ sourceId: chunk.sourceId, sourceRef: chunk.sourceRef, chunkIndex: chunk.chunkIndex }];
180
+ const labels = sources
181
+ .map((source) => source.sourceRef ?? source.sourceId)
182
+ .filter((value) => !!value)
183
+ .map((value) => previewText(value, MAX_SOURCE_LABEL_CHARS));
184
+ return labels.length ? [`sources=${Array.from(new Set(labels)).join("; ")}`] : [];
185
+ }
186
+ function metadataString(metadata, key) {
187
+ const value = metadata?.[key];
188
+ return typeof value === "string" && value.trim() ? value.trim() : null;
189
+ }
190
+ function previewText(text, maxLength) {
191
+ const normalized = text.replaceAll(/\s+/g, " ").trim();
192
+ if (normalized.length <= maxLength)
193
+ return normalized;
194
+ return `${normalized.slice(0, maxLength - 3).trimEnd()}...`;
195
+ }
196
+ function formatUnixTimestamp(value) {
197
+ const milliseconds = value < 10_000_000_000 ? value * 1000 : value;
198
+ return new Date(milliseconds).toISOString();
199
+ }
200
+ export const __memoryToolsTest = {
201
+ formatUnixTimestamp,
202
+ formatOpenChunk,
203
+ formatRecallResults,
204
+ previewText,
205
+ };
package/dist/models.js ADDED
@@ -0,0 +1,165 @@
1
+ import { clampThinkingLevel, findEnvKeys, getEnvApiKey, getModels, getProviders, } from "@earendil-works/pi-ai";
2
+ const PROVIDER_DEFAULTS = {
3
+ anthropic: {
4
+ api: "anthropic-messages",
5
+ baseUrl: "https://api.anthropic.com",
6
+ },
7
+ google: {
8
+ api: "google-generative-ai",
9
+ baseUrl: "https://generativelanguage.googleapis.com/v1beta",
10
+ },
11
+ "google-vertex": {
12
+ api: "google-vertex",
13
+ baseUrl: "https://{location}-aiplatform.googleapis.com",
14
+ },
15
+ openai: {
16
+ api: "openai-responses",
17
+ baseUrl: "https://api.openai.com/v1",
18
+ },
19
+ groq: {
20
+ api: "openai-completions",
21
+ baseUrl: "https://api.groq.com/openai/v1",
22
+ },
23
+ };
24
+ export function parseModelRef(value) {
25
+ const trimmed = value.trim();
26
+ const separator = trimmed.indexOf("/");
27
+ if (separator <= 0 || separator === trimmed.length - 1)
28
+ return undefined;
29
+ const provider = trimmed.slice(0, separator).trim();
30
+ const id = trimmed.slice(separator + 1).trim();
31
+ if (!provider || !id)
32
+ return undefined;
33
+ return { provider, id, key: `${provider}/${id}` };
34
+ }
35
+ function findBuiltInModel(ref) {
36
+ if (!getProviders().includes(ref.provider))
37
+ return undefined;
38
+ const models = getModels(ref.provider);
39
+ return models.find((model) => model.id === ref.id);
40
+ }
41
+ function createFallbackModel(ref) {
42
+ const defaults = PROVIDER_DEFAULTS[ref.provider];
43
+ if (!defaults) {
44
+ throw new Error(`Unsupported model provider: ${ref.provider}`);
45
+ }
46
+ return {
47
+ id: ref.id,
48
+ name: ref.id,
49
+ api: defaults.api,
50
+ provider: ref.provider,
51
+ baseUrl: defaults.baseUrl,
52
+ reasoning: true,
53
+ input: ["text", "image"],
54
+ cost: {
55
+ input: 0,
56
+ output: 0,
57
+ cacheRead: 0,
58
+ cacheWrite: 0,
59
+ },
60
+ contextWindow: 200000,
61
+ maxTokens: 8192,
62
+ };
63
+ }
64
+ function applyConfiguredBaseUrl(config, model) {
65
+ const baseUrl = config.models.baseUrls[`${model.provider}/${model.id}`] ?? config.models.baseUrls[model.provider];
66
+ return baseUrl ? { ...model, baseUrl } : model;
67
+ }
68
+ export function resolveModel(ref, config) {
69
+ const model = findBuiltInModel(ref) ?? createFallbackModel(ref);
70
+ return config ? applyConfiguredBaseUrl(config, model) : model;
71
+ }
72
+ export function createConfiguredModel(config) {
73
+ const ref = parseModelRef(config.agent.model);
74
+ if (!ref)
75
+ throw new Error(`Invalid agent.model: ${config.agent.model}`);
76
+ if (config.agent.api && config.agent.modelId && config.agent.baseUrl) {
77
+ return applyConfiguredBaseUrl(config, {
78
+ id: config.agent.modelId,
79
+ name: config.agent.modelId,
80
+ api: config.agent.api,
81
+ provider: config.agent.provider,
82
+ baseUrl: config.agent.baseUrl,
83
+ reasoning: true,
84
+ input: ["text", "image"],
85
+ cost: {
86
+ input: 0,
87
+ output: 0,
88
+ cacheRead: 0,
89
+ cacheWrite: 0,
90
+ },
91
+ contextWindow: 200000,
92
+ maxTokens: 8192,
93
+ });
94
+ }
95
+ return resolveModel(ref, config);
96
+ }
97
+ export function resolveModelApiKey(config, model) {
98
+ const configuredEnv = config.models.apiKeyEnvs[`${model.provider}/${model.id}`] ??
99
+ config.models.apiKeyEnvs[model.provider] ??
100
+ (model.provider === config.agent.provider ? config.agent.apiKeyEnv : undefined);
101
+ if (configuredEnv)
102
+ return process.env[configuredEnv];
103
+ return getEnvApiKey(model.provider);
104
+ }
105
+ export function requiresExplicitApiKey(config, model) {
106
+ if (model.provider === "google-vertex")
107
+ return false;
108
+ return resolveModelApiKey(config, model) === undefined;
109
+ }
110
+ function hasVertexAdcEnvironment() {
111
+ const project = process.env.GOOGLE_CLOUD_PROJECT?.trim() || process.env.GCLOUD_PROJECT?.trim();
112
+ const location = process.env.GOOGLE_CLOUD_LOCATION?.trim();
113
+ return !!project && !!location;
114
+ }
115
+ export function modelCanAuthenticate(config, model) {
116
+ if (model.provider === "google-vertex")
117
+ return resolveModelApiKey(config, model) !== undefined || hasVertexAdcEnvironment();
118
+ return !requiresExplicitApiKey(config, model);
119
+ }
120
+ export function assertModelCanAuthenticate(config, model) {
121
+ if (model.provider === "google-vertex") {
122
+ if (resolveModelApiKey(config, model) !== undefined || hasVertexAdcEnvironment())
123
+ return;
124
+ throw new Error(`Missing Vertex auth for ${model.provider}/${model.id}: ${describeModelAuth(config, model)}`);
125
+ }
126
+ if (requiresExplicitApiKey(config, model)) {
127
+ throw new Error(`Missing API key for ${model.provider}/${model.id}: ${describeModelAuth(config, model)}`);
128
+ }
129
+ }
130
+ export function describeModelAuth(config, model) {
131
+ const configuredEnv = config.models.apiKeyEnvs[`${model.provider}/${model.id}`] ??
132
+ config.models.apiKeyEnvs[model.provider] ??
133
+ (model.provider === config.agent.provider ? config.agent.apiKeyEnv : undefined);
134
+ if (configuredEnv)
135
+ return configuredEnv;
136
+ if (model.provider === "google-vertex") {
137
+ return "set GOOGLE_CLOUD_API_KEY, or use Vertex ADC with GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT and GOOGLE_CLOUD_LOCATION";
138
+ }
139
+ const envKeys = findEnvKeys(model.provider);
140
+ if (envKeys?.length)
141
+ return envKeys.join(", ");
142
+ return "no matching API key environment variable found";
143
+ }
144
+ export function isAllowedModel(config, ref) {
145
+ return config.models.allow.length === 0 || config.models.allow.includes(ref.key);
146
+ }
147
+ export function formatAllowedModels(config) {
148
+ return config.models.allow.length > 0 ? config.models.allow.join("\n") : "(no allowlist configured)";
149
+ }
150
+ export function clampConfiguredThinkingLevel(model, level) {
151
+ if (level === "off")
152
+ return "off";
153
+ return clampThinkingLevel(model, level);
154
+ }
155
+ export function isThinkingLevel(value) {
156
+ return (value === "off" ||
157
+ value === "minimal" ||
158
+ value === "low" ||
159
+ value === "medium" ||
160
+ value === "high" ||
161
+ value === "xhigh");
162
+ }
163
+ export function supportedThinkingLevels(model) {
164
+ return model.reasoning ? ["off", "minimal", "low", "medium", "high", "xhigh"] : ["off"];
165
+ }
@@ -0,0 +1,54 @@
1
+ import { readFile } from "node:fs/promises";
2
+ function isMissingFile(error) {
3
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
4
+ }
5
+ async function readOptionalPersonaFile(path) {
6
+ try {
7
+ return await readFile(path, "utf8");
8
+ }
9
+ catch (error) {
10
+ if (isMissingFile(error))
11
+ return null;
12
+ throw error;
13
+ }
14
+ }
15
+ export async function loadPersona(config) {
16
+ const [soul, user, memory, inner] = await Promise.all([
17
+ readFile(config.persona.soul, "utf8"),
18
+ readFile(config.persona.user, "utf8"),
19
+ readFile(config.persona.memory, "utf8"),
20
+ readOptionalPersonaFile(config.persona.inner),
21
+ ]);
22
+ return { soul, user, memory, inner };
23
+ }
24
+ function renderSystemPromptFile(file) {
25
+ return `<file name="${file.name}">
26
+ ${file.contents.trim()}
27
+ </file>`;
28
+ }
29
+ export function buildSystemPrompt(persona, skillsBlock = "") {
30
+ const files = [
31
+ { name: "SOUL.md", contents: persona.soul },
32
+ { name: "USER.md", contents: persona.user },
33
+ { name: "MEMORY.md", contents: persona.memory },
34
+ ...(persona.inner !== null ? [{ name: "INNER.md", contents: persona.inner }] : []),
35
+ ];
36
+ const renderedFiles = files.map(renderSystemPromptFile).join("\n\n");
37
+ const renderedSkillsBlock = skillsBlock.trim() ? `\n\n${skillsBlock.trim()}` : "";
38
+ return `<system-reminder>
39
+ ${renderedFiles}
40
+
41
+ <note_to_self>
42
+ you can edit MEMORY.md when something about her is worth keeping.
43
+ output [[FAMILIAR_SILENT]] if there's nothing worth saying — quiet's a real choice.
44
+ </note_to_self>
45
+ ${renderedSkillsBlock}
46
+ </system-reminder>`;
47
+ }
48
+ const NAME_FIELD_RE = /^\s*[-*]?\s*\*\*Name:\*\*\s*(.+?)\s*$/im;
49
+ export function parsePersonaName(soul, fallback = "Familiar") {
50
+ const match = soul.match(NAME_FIELD_RE);
51
+ if (!match)
52
+ return fallback;
53
+ return match[1].replace(/^["'`*_]+|["'`*_]+$/g, "").trim() || fallback;
54
+ }