@kisev/memomatic 1.0.0-dev.99.g48fbb8555698 → 12.0.1-dev.115.g8010bdfeea15

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 CHANGED
@@ -4,8 +4,8 @@
4
4
 
5
5
  `@kisev/memomatic` is a personal learning memory for agents following the OpenClaw
6
6
  memory architecture: a tiered Markdown corpus you can read as plain files, a
7
- rebuildable SQLite index with FTS5 and optional local embeddings, a `sessions`
8
- extraction pass, and a nightly `dream` consolidation sweep.
7
+ rebuildable SQLite index with FTS5, optional local embeddings and an optional
8
+ reranker, a `sessions` extraction pass, and a nightly `dream` consolidation sweep.
9
9
 
10
10
  ## Install
11
11
 
@@ -78,7 +78,7 @@ when memomatic is absent.
78
78
 
79
79
  Every entry can carry a `source` annotation. Visibility derives from it:
80
80
  `team-*`, `gitlab`, and `spec-manage` entries may be quoted in team-facing
81
- artifacts; every other source (`people-journal`, `stopit`,
81
+ artifacts; every other source (`people-journal`, `handoff`,
82
82
  `mattermost-triage`, `task-*`, `docs-*`, `user`) is personal-only. The label
83
83
  is exposed in search responses.
84
84
 
package/README.ru.md CHANGED
@@ -4,8 +4,9 @@
4
4
 
5
5
  `@kisev/memomatic` — персональная обучающая память для агентов по архитектуре
6
6
  памяти OpenClaw: ярусный Markdown-корпус, который можно читать как обычные файлы,
7
- пересобираемый SQLite-индекс с FTS5 и опциональными локальными эмбеддингами,
8
- проход извлечения `sessions` и ночной проход консолидации `dream`.
7
+ пересобираемый SQLite-индекс с FTS5, опциональными локальными эмбеддингами и
8
+ опциональным реранкером, проход извлечения `sessions` и ночной проход
9
+ консолидации `dream`.
9
10
 
10
11
  ## Установка
11
12
 
@@ -78,7 +79,7 @@ inbox, бэклог сессий, кандидатов на продвижени
78
79
 
79
80
  Каждая запись может нести аннотацию `source`. Из неё выводится видимость:
80
81
  записи `team-*`, `gitlab` и `spec-manage` можно цитировать в командных
81
- артефактах; остальные источники (`people-journal`, `stopit`,
82
+ артефактах; остальные источники (`people-journal`, `handoff`,
82
83
  `mattermost-triage`, `task-*`, `docs-*`, `user`) — personal-only. Метка
83
84
  возвращается в поиске.
84
85
 
package/dist/search.d.ts CHANGED
@@ -14,7 +14,11 @@ export type SearchHit = {
14
14
  exact: boolean;
15
15
  recency: number;
16
16
  importance: number;
17
- reason: "lexical" | "semantic" | "both";
17
+ reason: "lexical" | "semantic" | "both" | "rerank";
18
+ rerank?: {
19
+ candidates: number;
20
+ threshold: number;
21
+ };
18
22
  };
19
23
  };
20
24
  export declare function corpusFiles(paths: MemomaticPaths): Promise<string[]>;
package/dist/search.js CHANGED
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { cosineSimilarity, stableIdFor } from "./store.js";
4
4
  import { entryKind, parseCorpusEntries } from "./corpus.js";
5
5
  import { entryImportance, entryObservedAt } from "./entries.js";
6
- import { embedTexts, embeddingFingerprint } from "./settings.js";
6
+ import { embedTexts, embeddingFingerprint, rerankTexts } from "./settings.js";
7
7
  import { check } from "./operations.js";
8
8
  export async function corpusFiles(paths) {
9
9
  const files = [];
@@ -73,9 +73,10 @@ export async function reindex(paths, settings, store, options = {}) {
73
73
  completed: 0,
74
74
  cached: entries.length - missing.length,
75
75
  });
76
- for (let start = 0; start < missing.length; start += 32) {
76
+ const batchSize = Math.min(32, settings.embedding?.maxBatchTexts ?? 32);
77
+ for (let start = 0; start < missing.length; start += batchSize) {
77
78
  check(options);
78
- const batch = missing.slice(start, start + 32);
79
+ const batch = missing.slice(start, start + batchSize);
79
80
  const computed = await embedTexts(settings, batch.map((item) => item.entry.text), "document", options.signal);
80
81
  if (vectors && computed)
81
82
  batch.forEach((item, index) => {
@@ -85,7 +86,7 @@ export async function reindex(paths, settings, store, options = {}) {
85
86
  phase: "index.embeddings",
86
87
  message: "Embedding batch complete",
87
88
  total: missing.length,
88
- completed: Math.min(start + 32, missing.length),
89
+ completed: Math.min(start + batchSize, missing.length),
89
90
  });
90
91
  }
91
92
  check(options);
@@ -117,40 +118,21 @@ export function queryTokens(query) {
117
118
  .match(/[\p{L}\p{N}]+/gu) ?? []),
118
119
  ].filter((token) => token.length >= 2);
119
120
  }
120
- export async function search(store, query, settings, options = {}) {
121
- if (!query.trim())
122
- throw new Error("query is required");
123
- const tokens = queryTokens(query);
124
- const keyword = store.ftsSearch(query, 200);
125
- const vector = new Map();
126
- const storedVectors = store.vectors();
127
- if (settings.embedding &&
128
- store.allEntries().length &&
129
- store.getMeta("embeddingFingerprint") !== embeddingFingerprint(settings))
130
- throw new Error("embedding index is missing or belongs to another model; run memomatic index");
131
- const queryVector = (await embedTexts(settings, [query], "query"))?.[0];
132
- if (queryVector?.length) {
133
- for (const row of storedVectors) {
134
- if (row.data.length !== queryVector.length)
135
- throw new Error("embedding dimensions changed; run memomatic index");
136
- const similarity = cosineSimilarity(queryVector, row.data);
137
- if (similarity > 0.05)
138
- vector.set(row.stableId, similarity);
139
- }
140
- }
141
- const candidates = new Set([...keyword.keys(), ...vector.keys()]);
142
- const entries = store
143
- .allEntries()
144
- .filter((item) => options.project === undefined || item.project === options.project || item.kind === "user");
145
- const byId = new Map(entries.map((entry) => [entry.stableId, entry]));
121
+ function lexicalCoverage(entry, tokens, keyword) {
122
+ const words = queryTokens(entry.text);
123
+ const matchedTokens = tokens.filter((token) => words.some((word) => word.startsWith(token)));
124
+ return {
125
+ matchedTokens,
126
+ lexical: keyword.has(entry.stableId) && tokens.length ? matchedTokens.length / tokens.length : 0,
127
+ };
128
+ }
129
+ function gatedHits(tokens, keyword, vector, byId, settings) {
146
130
  const hits = [];
147
- for (const stableId of candidates) {
131
+ for (const stableId of new Set([...keyword.keys(), ...vector.keys()])) {
148
132
  const entry = byId.get(stableId);
149
133
  if (!entry)
150
134
  continue;
151
- const words = queryTokens(entry.text);
152
- const matchedTokens = tokens.filter((token) => words.some((word) => word.startsWith(token)));
153
- const lexical = keyword.has(stableId) && tokens.length ? matchedTokens.length / tokens.length : 0;
135
+ const { matchedTokens, lexical } = lexicalCoverage(entry, tokens, keyword);
154
136
  const semantic = vector.get(stableId) ?? null;
155
137
  const lexicalAccepted = lexical >= settings.search.minScore && lexical >= 0.5;
156
138
  const semanticAccepted = semantic !== null &&
@@ -164,6 +146,7 @@ export async function search(store, query, settings, options = {}) {
164
146
  const score = relevance * recency * (0.85 + 0.15 * importance);
165
147
  if (score < settings.search.minScore)
166
148
  continue;
149
+ const words = queryTokens(entry.text);
167
150
  hits.push({
168
151
  entry,
169
152
  score,
@@ -180,10 +163,104 @@ export async function search(store, query, settings, options = {}) {
180
163
  },
181
164
  });
182
165
  }
183
- hits.sort((left, right) => Number(right.explanation.exact) - Number(left.explanation.exact) ||
166
+ return hits.sort((left, right) => Number(right.explanation.exact) - Number(left.explanation.exact) ||
184
167
  right.score - left.score ||
185
168
  (right.explanation.semantic ?? 0) - (left.explanation.semantic ?? 0) ||
186
169
  left.entry.stableId.localeCompare(right.entry.stableId));
170
+ }
171
+ /**
172
+ * Reranker path: collect a bounded wide candidate set from lexical and vector
173
+ * search before the strict relevance gates, apply the project filter, and let
174
+ * the reranker decide final relevance and order without giving literal
175
+ * matches an unconditional priority.
176
+ */
177
+ async function rerankedHits(query, tokens, keyword, vector, byId, settings) {
178
+ const reranker = settings.reranker;
179
+ // The project filter narrows the pool before top-N selection, so hidden
180
+ // entries cannot consume the candidate budget.
181
+ const visible = (scores) => {
182
+ const filtered = new Map();
183
+ for (const [stableId, score] of scores)
184
+ if (byId.has(stableId))
185
+ filtered.set(stableId, score);
186
+ return filtered;
187
+ };
188
+ const widest = (scores) => [...scores.entries()]
189
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
190
+ .slice(0, reranker.candidates)
191
+ .flatMap(([stableId]) => {
192
+ const entry = byId.get(stableId);
193
+ return entry ? [entry] : [];
194
+ });
195
+ const candidates = [];
196
+ const seen = new Set();
197
+ for (const entry of [...widest(visible(keyword)), ...widest(visible(vector))]) {
198
+ if (seen.has(entry.stableId))
199
+ continue;
200
+ seen.add(entry.stableId);
201
+ candidates.push(entry);
202
+ }
203
+ if (!candidates.length)
204
+ return [];
205
+ // Reranker documents stay raw entry text without embedding prefixes.
206
+ const ranked = await rerankTexts(settings, query, candidates.map((entry) => entry.text));
207
+ const hits = [];
208
+ for (const { index, score } of ranked) {
209
+ if (score < reranker.minScore)
210
+ continue;
211
+ const entry = candidates[index];
212
+ const { matchedTokens, lexical } = lexicalCoverage(entry, tokens, keyword);
213
+ const semantic = vector.get(entry.stableId) ?? null;
214
+ const recency = recencyMultiplier(entry, settings.search.halfLifeDays);
215
+ const importance = Math.max(0, Math.min(1, (entry.importance - 1) / 9));
216
+ const words = queryTokens(entry.text);
217
+ hits.push({
218
+ entry,
219
+ score,
220
+ snippet: entry.text.slice(0, 240),
221
+ explanation: {
222
+ matchedTokens,
223
+ lexical,
224
+ semantic,
225
+ relevance: score,
226
+ recency,
227
+ importance,
228
+ exact: tokens.length > 0 && tokens.every((token) => words.includes(token)),
229
+ reason: "rerank",
230
+ rerank: { candidates: candidates.length, threshold: reranker.minScore },
231
+ },
232
+ });
233
+ }
234
+ return hits;
235
+ }
236
+ export async function search(store, query, settings, options = {}) {
237
+ if (!query.trim())
238
+ throw new Error("query is required");
239
+ const tokens = queryTokens(query);
240
+ const keyword = store.ftsSearch(query, settings.reranker ? Math.max(200, settings.reranker.candidates) : 200);
241
+ const vector = new Map();
242
+ const storedVectors = store.vectors();
243
+ if (settings.embedding &&
244
+ store.allEntries().length &&
245
+ store.getMeta("embeddingFingerprint") !== embeddingFingerprint(settings))
246
+ throw new Error("embedding index is missing or belongs to another model; run memomatic index");
247
+ const queryVector = (await embedTexts(settings, [query], "query"))?.[0];
248
+ if (queryVector?.length) {
249
+ for (const row of storedVectors) {
250
+ if (row.data.length !== queryVector.length)
251
+ throw new Error("embedding dimensions changed; run memomatic index");
252
+ const similarity = cosineSimilarity(queryVector, row.data);
253
+ if (similarity > 0.05)
254
+ vector.set(row.stableId, similarity);
255
+ }
256
+ }
257
+ const entries = store
258
+ .allEntries()
259
+ .filter((item) => options.project === undefined || item.project === options.project || item.kind === "user");
260
+ const byId = new Map(entries.map((entry) => [entry.stableId, entry]));
261
+ const hits = settings.reranker
262
+ ? await rerankedHits(query, tokens, keyword, vector, byId, settings)
263
+ : gatedHits(tokens, keyword, vector, byId, settings);
187
264
  const limited = hits.slice(0, settings.search.maxResults);
188
265
  if (options.markSurfaced !== false && limited.length)
189
266
  store.markSurfaced(limited.map((hit) => hit.entry.stableId), query);
package/dist/service.d.ts CHANGED
@@ -45,7 +45,11 @@ export declare function searchMemoryResults(context: MemomaticContext, query: st
45
45
  exact: boolean;
46
46
  recency: number;
47
47
  importance: number;
48
- reason: "lexical" | "semantic" | "both";
48
+ reason: "lexical" | "semantic" | "both" | "rerank";
49
+ rerank?: {
50
+ candidates: number;
51
+ threshold: number;
52
+ };
49
53
  } | undefined;
50
54
  source: string | null;
51
55
  visibility: import("./visibility.js").EntryVisibility;
@@ -1,9 +1,28 @@
1
+ export type EmbeddingSettings = {
2
+ url: string;
3
+ model: string;
4
+ /** Explicit query instruction; null keeps the built-in Qwen3 auto-detection. */
5
+ queryInstruction: string | null;
6
+ documentPrefix: string;
7
+ queryPrefix: string;
8
+ bearerEnv: string | null;
9
+ timeoutMs: number;
10
+ maxBatchTexts: number;
11
+ maxTextChars: number;
12
+ };
13
+ export type RerankerSettings = {
14
+ url: string;
15
+ model: string;
16
+ bearerEnv: string | null;
17
+ timeoutMs: number;
18
+ candidates: number;
19
+ minScore: number;
20
+ maxDocuments: number;
21
+ maxChars: number;
22
+ };
1
23
  export type MemomaticSettings = {
2
- embedding: {
3
- url: string;
4
- model: string;
5
- queryInstruction?: string;
6
- } | null;
24
+ embedding: EmbeddingSettings | null;
25
+ reranker: RerankerSettings | null;
7
26
  sessions: {
8
27
  model: string | null;
9
28
  variant: string | null;
@@ -39,10 +58,43 @@ export type MemomaticSettings = {
39
58
  days: number;
40
59
  };
41
60
  };
61
+ export declare const DEFAULT_MAX_TEXT_CHARS = 8000;
62
+ export declare const RERANKER_TEMPLATE_ALLOWANCE_CHARS = 1024;
63
+ export declare const defaultEmbedding: (url: string, model: string) => EmbeddingSettings;
64
+ export declare const defaultReranker: (url: string, model: string) => RerankerSettings;
42
65
  export declare const defaultSettings: () => MemomaticSettings;
43
66
  export declare function normalizeSettings(raw: unknown): MemomaticSettings;
44
67
  export declare function loadSettings(settingsFile: string): Promise<MemomaticSettings>;
45
- export declare function embedTexts(settings: MemomaticSettings, texts: string[], purpose?: "document" | "query", signal?: AbortSignal): Promise<Float32Array[] | null>;
68
+ /** Splits long text at paragraph, line or space boundaries within the budget. */
69
+ export declare function chunkText(text: string, maxChars: number): string[];
46
70
  export declare const MEMORY_QUERY_INSTRUCTION = "Given a search query, retrieve relevant personal memory entries that answer the query.";
47
- export declare function embeddingQuery(settings: MemomaticSettings, query: string): string;
71
+ export declare function embeddingQueryText(embedding: EmbeddingSettings, query: string): string;
72
+ /** Headroom a formatted query chunk needs beyond its raw text. */
73
+ export declare function embeddingQueryOverhead(embedding: EmbeddingSettings): number;
74
+ export declare function embeddingDocumentText(embedding: EmbeddingSettings, text: string): string;
75
+ /** Headroom a formatted document chunk needs beyond its raw text. */
76
+ export declare function embeddingDocumentOverhead(embedding: EmbeddingSettings): number;
77
+ /**
78
+ * Embeds texts through an OpenAI-compatible `embeddings` endpoint. Raw inputs
79
+ * are split with chunkText first and every chunk is formatted afterwards, so
80
+ * each document chunk carries the document prefix and each query chunk the
81
+ * query prefix plus the Qwen instruction, all inside maxTextChars. Chunks are
82
+ * embedded chunk-wise and mean-pooled back onto one vector per input text.
83
+ * Request character budgets are a conservative proxy, not exact tokenization.
84
+ */
85
+ export declare function embedTexts(settings: MemomaticSettings, texts: string[], purpose?: "document" | "query", signal?: AbortSignal): Promise<Float32Array[] | null>;
86
+ export type RerankHit = {
87
+ index: number;
88
+ score: number;
89
+ };
90
+ /**
91
+ * Ranks documents against a query through a `/rerank`-style endpoint
92
+ * (`{model, query, documents, top_n}` request, `results[].index` and
93
+ * `results[].relevance_score` response). Documents are chunked so the query,
94
+ * one document chunk and the server template stay inside maxChars; a query
95
+ * too large to leave room for a chunk fails before the request. Responses
96
+ * must contain exactly top_n results, and chunk scores aggregate to one best
97
+ * score per input document.
98
+ */
99
+ export declare function rerankTexts(settings: MemomaticSettings, query: string, documents: string[], signal?: AbortSignal): Promise<RerankHit[]>;
48
100
  export declare function embeddingFingerprint(settings: MemomaticSettings): string;
package/dist/settings.js CHANGED
@@ -1,7 +1,36 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { createHash } from "node:crypto";
3
+ // Conservative character proxy for an input budget of roughly 4096 tokens:
4
+ // dense scripts need about two characters per token, so 8000 characters stay
5
+ // inside the example budget without claiming exact tokenization. Long texts
6
+ // are split at chunkText instead of measured with a tokenizer.
7
+ export const DEFAULT_MAX_TEXT_CHARS = 8000;
8
+ // Headroom for the server-side rerank template wrapped around query and document.
9
+ export const RERANKER_TEMPLATE_ALLOWANCE_CHARS = 1024;
10
+ export const defaultEmbedding = (url, model) => ({
11
+ url,
12
+ model,
13
+ queryInstruction: null,
14
+ documentPrefix: "",
15
+ queryPrefix: "",
16
+ bearerEnv: null,
17
+ timeoutMs: 30_000,
18
+ maxBatchTexts: 32,
19
+ maxTextChars: DEFAULT_MAX_TEXT_CHARS,
20
+ });
21
+ export const defaultReranker = (url, model) => ({
22
+ url,
23
+ model,
24
+ bearerEnv: null,
25
+ timeoutMs: 30_000,
26
+ candidates: 64,
27
+ minScore: 0,
28
+ maxDocuments: 64,
29
+ maxChars: DEFAULT_MAX_TEXT_CHARS,
30
+ });
3
31
  export const defaultSettings = () => ({
4
32
  embedding: null,
33
+ reranker: null,
5
34
  sessions: {
6
35
  model: null,
7
36
  variant: null,
@@ -50,10 +79,56 @@ const SESSIONS_KEYS = [
50
79
  "idleMs",
51
80
  "opencodeUrl",
52
81
  ];
82
+ function httpUrl(value, section) {
83
+ if (typeof value !== "string")
84
+ throw new Error(`${section}.url must be a string`);
85
+ let parsed;
86
+ try {
87
+ parsed = new URL(value);
88
+ }
89
+ catch {
90
+ throw new Error(`${section}.url must be an absolute http(s) URL`);
91
+ }
92
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
93
+ throw new Error(`${section}.url must use http or https`);
94
+ return value;
95
+ }
96
+ function bearerEnvName(value, section) {
97
+ if (value === null || value === undefined)
98
+ return null;
99
+ if (typeof value !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value))
100
+ throw new Error(`${section}.bearerEnv must name an environment variable`);
101
+ return value;
102
+ }
103
+ function boundedInt(value, section, key, min, max) {
104
+ if (!Number.isSafeInteger(value) || value < min || value > max)
105
+ throw new Error(`invalid ${section}.${key}; expected an integer between ${min} and ${max}`);
106
+ return value;
107
+ }
108
+ function optionalText(value, section, key) {
109
+ if (typeof value !== "string")
110
+ throw new Error(`${section}.${key} must be a string`);
111
+ return value;
112
+ }
113
+ function serviceEndpoint(raw, section, defaults, validate) {
114
+ if (raw === null || raw === undefined)
115
+ return null;
116
+ if (typeof raw !== "object")
117
+ throw new Error(`invalid ${section} settings`);
118
+ const url = httpUrl(raw.url, section);
119
+ if (typeof raw.model !== "string" || !raw.model.trim())
120
+ throw new Error(`${section}.model must be a non-empty string`);
121
+ const merged = defaults(url, raw.model);
122
+ for (const [key, value] of Object.entries(raw)) {
123
+ if (key in merged && value !== null && value !== undefined)
124
+ merged[key] = value;
125
+ }
126
+ validate(merged);
127
+ return merged;
128
+ }
53
129
  export function normalizeSettings(raw) {
54
130
  const defaults = defaultSettings();
55
131
  const source = (raw ?? {});
56
- const embedding = source.embedding;
57
132
  const search = mergeSection(defaults.search, source.search);
58
133
  const dream = mergeSection(defaults.dream, source.dream);
59
134
  // Legacy single-model configs put extraction knobs under `dream`; adopt them
@@ -106,16 +181,32 @@ export function normalizeSettings(raw) {
106
181
  search.halfLifeDays <= 0 ||
107
182
  ![search.minScore, search.minSemanticScore].every((value) => Number.isFinite(value) && value >= 0 && value <= 1))
108
183
  throw new Error("invalid search settings");
184
+ const embedding = serviceEndpoint((source.embedding ?? null), "embedding", (url, model) => defaultEmbedding(url, model), (merged) => {
185
+ if (merged.queryInstruction !== null && typeof merged.queryInstruction !== "string")
186
+ throw new Error("embedding.queryInstruction must be a string or null");
187
+ merged.queryInstruction =
188
+ typeof merged.queryInstruction === "string" ? merged.queryInstruction : null;
189
+ merged.documentPrefix = optionalText(merged.documentPrefix, "embedding", "documentPrefix");
190
+ merged.queryPrefix = optionalText(merged.queryPrefix, "embedding", "queryPrefix");
191
+ merged.bearerEnv = bearerEnvName(merged.bearerEnv, "embedding");
192
+ merged.timeoutMs = boundedInt(merged.timeoutMs, "embedding", "timeoutMs", 1, Number.MAX_SAFE_INTEGER);
193
+ merged.maxBatchTexts = boundedInt(merged.maxBatchTexts, "embedding", "maxBatchTexts", 1, 512);
194
+ merged.maxTextChars = boundedInt(merged.maxTextChars, "embedding", "maxTextChars", 256, 1_000_000);
195
+ });
196
+ const reranker = serviceEndpoint((source.reranker ?? null), "reranker", (url, model) => defaultReranker(url, model), (merged) => {
197
+ merged.bearerEnv = bearerEnvName(merged.bearerEnv, "reranker");
198
+ merged.timeoutMs = boundedInt(merged.timeoutMs, "reranker", "timeoutMs", 1, Number.MAX_SAFE_INTEGER);
199
+ merged.candidates = boundedInt(merged.candidates, "reranker", "candidates", 1, 1000);
200
+ // Scores are ranking signals whose scale depends on the model, so the
201
+ // threshold is any finite non-negative number, not a probability bound.
202
+ if (!Number.isFinite(merged.minScore) || merged.minScore < 0)
203
+ throw new Error("invalid reranker.minScore; expected a finite number of at least 0");
204
+ merged.maxDocuments = boundedInt(merged.maxDocuments, "reranker", "maxDocuments", 1, 512);
205
+ merged.maxChars = boundedInt(merged.maxChars, "reranker", "maxChars", 256, 1_000_000);
206
+ });
109
207
  return {
110
- embedding: embedding && typeof embedding.url === "string" && typeof embedding.model === "string"
111
- ? {
112
- url: embedding.url,
113
- model: embedding.model,
114
- ...(typeof embedding.queryInstruction === "string"
115
- ? { queryInstruction: embedding.queryInstruction }
116
- : {}),
117
- }
118
- : null,
208
+ embedding,
209
+ reranker,
119
210
  sessions,
120
211
  dream,
121
212
  search,
@@ -132,25 +223,92 @@ export async function loadSettings(settingsFile) {
132
223
  throw error;
133
224
  }
134
225
  }
135
- export async function embedTexts(settings, texts, purpose = "document", signal) {
136
- if (!settings.embedding || !texts.length)
137
- return null;
138
- const response = await fetch(settings.embedding.url, {
139
- body: JSON.stringify({
140
- input: texts.map((text) => (purpose === "query" ? embeddingQuery(settings, text) : text)),
141
- model: settings.embedding.model,
142
- }),
143
- headers: { "content-type": "application/json" },
226
+ /** Splits long text at paragraph, line or space boundaries within the budget. */
227
+ export function chunkText(text, maxChars) {
228
+ if (text.length <= maxChars)
229
+ return [text];
230
+ const chunks = [];
231
+ let start = 0;
232
+ while (start < text.length) {
233
+ let end = Math.min(start + maxChars, text.length);
234
+ if (end < text.length) {
235
+ const window = text.slice(start, end);
236
+ const boundary = Math.max(window.lastIndexOf("\n\n"), window.lastIndexOf("\n"), window.lastIndexOf(" "));
237
+ if (boundary >= Math.floor(maxChars / 2))
238
+ end = start + boundary + 1;
239
+ }
240
+ const chunk = text.slice(start, end).trim();
241
+ if (chunk)
242
+ chunks.push(chunk);
243
+ start = end;
244
+ }
245
+ return chunks.length ? chunks : [text.slice(0, maxChars)];
246
+ }
247
+ function bearerToken(bearerEnv, service) {
248
+ if (!bearerEnv)
249
+ return undefined;
250
+ const token = process.env[bearerEnv];
251
+ if (!token)
252
+ throw new Error(`${service} bearerEnv "${bearerEnv}" is not set`);
253
+ // A control character makes the Authorization header value invalid; the
254
+ // fetch-level rejection echoes the token back, so reject it here where the
255
+ // diagnostic stays free of the secret.
256
+ if (/[\u0000-\u001f\u007f]/.test(token))
257
+ throw new Error(`${service} bearerEnv "${bearerEnv}" contains a control character; expected a single-line token`);
258
+ return token;
259
+ }
260
+ async function postJson(url, body, token, timeoutMs, signal, service) {
261
+ const response = await fetch(url, {
262
+ body: JSON.stringify(body),
263
+ headers: {
264
+ "content-type": "application/json",
265
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
266
+ },
144
267
  method: "POST",
145
268
  signal: signal
146
- ? AbortSignal.any([signal, AbortSignal.timeout(30_000)])
147
- : AbortSignal.timeout(30_000),
269
+ ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
270
+ : AbortSignal.timeout(timeoutMs),
271
+ }).catch((error) => {
272
+ const failure = error;
273
+ if (failure.name === "TimeoutError")
274
+ throw new Error(`${service} request timed out after ${timeoutMs}ms`);
275
+ if (failure.name === "AbortError")
276
+ throw new Error(`${service} request was aborted`);
277
+ throw new Error(`${service} request failed: ${failure.message}`);
148
278
  });
149
279
  if (!response.ok)
150
- throw new Error(`embedding request failed: ${response.status} ${await response.text()}`);
151
- const payload = (await response.json());
152
- const list = payload.data ?? [];
153
- if (list.length !== texts.length)
280
+ throw new Error(`${service} request failed: ${response.status} ${(await response.text()).slice(0, 2000)}`);
281
+ try {
282
+ return await response.json();
283
+ }
284
+ catch {
285
+ throw new Error(`${service} response is not valid JSON`);
286
+ }
287
+ }
288
+ export const MEMORY_QUERY_INSTRUCTION = "Given a search query, retrieve relevant personal memory entries that answer the query.";
289
+ function queryInstructionFor(embedding) {
290
+ return (embedding.queryInstruction ??
291
+ (/qwen3[-_]?embedding/i.test(embedding.model) ? MEMORY_QUERY_INSTRUCTION : ""));
292
+ }
293
+ export function embeddingQueryText(embedding, query) {
294
+ const instruction = queryInstructionFor(embedding);
295
+ return `${embedding.queryPrefix}${instruction ? `Instruct: ${instruction}\nQuery:${query}` : query}`;
296
+ }
297
+ /** Headroom a formatted query chunk needs beyond its raw text. */
298
+ export function embeddingQueryOverhead(embedding) {
299
+ const instruction = queryInstructionFor(embedding);
300
+ return (embedding.queryPrefix.length + (instruction ? `Instruct: ${instruction}\nQuery:`.length : 0));
301
+ }
302
+ export function embeddingDocumentText(embedding, text) {
303
+ return `${embedding.documentPrefix}${text}`;
304
+ }
305
+ /** Headroom a formatted document chunk needs beyond its raw text. */
306
+ export function embeddingDocumentOverhead(embedding) {
307
+ return embedding.documentPrefix.length;
308
+ }
309
+ function parseEmbeddingPayload(payload, count) {
310
+ const list = (payload.data ?? []);
311
+ if (!Array.isArray(list) || list.length !== count)
154
312
  throw new Error("embedding response is incomplete");
155
313
  const byIndex = new Map();
156
314
  for (const item of list) {
@@ -160,21 +318,145 @@ export async function embedTexts(settings, texts, purpose = "document", signal)
160
318
  !item.embedding.some((value) => value !== 0))
161
319
  throw new Error("embedding response is malformed");
162
320
  const index = item.index ?? byIndex.size;
163
- if (!Number.isInteger(index) || index < 0 || index >= texts.length || byIndex.has(index))
321
+ if (!Number.isInteger(index) || index < 0 || index >= count || byIndex.has(index))
164
322
  throw new Error("embedding response has invalid indices");
165
323
  byIndex.set(index, item.embedding);
166
324
  }
167
- const vectors = texts.map((_, index) => Float32Array.from(byIndex.get(index)));
325
+ const vectors = Array.from({ length: count }, (_, index) => Float32Array.from(byIndex.get(index)));
168
326
  if (!vectors.every((vector) => vector.length === vectors[0].length && vector.every(Number.isFinite)))
169
327
  throw new Error("embedding response has incompatible vectors");
170
328
  return vectors;
171
329
  }
172
- export const MEMORY_QUERY_INSTRUCTION = "Given a search query, retrieve relevant personal memory entries that answer the query.";
173
- export function embeddingQuery(settings, query) {
330
+ function meanPool(vectors) {
331
+ if (vectors.length === 1)
332
+ return vectors[0];
333
+ const pooled = new Float32Array(vectors[0].length);
334
+ for (const vector of vectors) {
335
+ if (vector.length !== pooled.length)
336
+ throw new Error("embedding response has incompatible vectors");
337
+ for (let index = 0; index < vector.length; index += 1)
338
+ pooled[index] += vector[index];
339
+ }
340
+ for (let index = 0; index < pooled.length; index += 1)
341
+ pooled[index] /= vectors.length;
342
+ return pooled;
343
+ }
344
+ /**
345
+ * Embeds texts through an OpenAI-compatible `embeddings` endpoint. Raw inputs
346
+ * are split with chunkText first and every chunk is formatted afterwards, so
347
+ * each document chunk carries the document prefix and each query chunk the
348
+ * query prefix plus the Qwen instruction, all inside maxTextChars. Chunks are
349
+ * embedded chunk-wise and mean-pooled back onto one vector per input text.
350
+ * Request character budgets are a conservative proxy, not exact tokenization.
351
+ */
352
+ export async function embedTexts(settings, texts, purpose = "document", signal) {
353
+ if (!settings.embedding || !texts.length)
354
+ return null;
174
355
  const embedding = settings.embedding;
175
- const instruction = embedding?.queryInstruction ??
176
- (/qwen3[-_]?embedding/i.test(embedding?.model ?? "") ? MEMORY_QUERY_INSTRUCTION : "");
177
- return instruction ? `Instruct: ${instruction}\nQuery:${query}` : query;
356
+ const token = bearerToken(embedding.bearerEnv, "embedding");
357
+ const format = purpose === "query"
358
+ ? (text) => embeddingQueryText(embedding, text)
359
+ : (text) => embeddingDocumentText(embedding, text);
360
+ const overhead = purpose === "query" ? embeddingQueryOverhead(embedding) : embeddingDocumentOverhead(embedding);
361
+ const chunkBudget = embedding.maxTextChars - overhead;
362
+ if (chunkBudget < 1)
363
+ throw new Error(`embedding ${purpose} formatting needs ${overhead} characters of headroom and leaves no room inside maxTextChars=${embedding.maxTextChars}`);
364
+ // Split the raw text with formatting headroom reserved, then format each
365
+ // chunk so prefixes and instructions cover every piece, not just the first.
366
+ const chunkLists = texts.map((text) => chunkText(text, chunkBudget).map(format));
367
+ const chunks = chunkLists.flat();
368
+ const vectors = new Array(chunks.length);
369
+ for (let start = 0; start < chunks.length; start += embedding.maxBatchTexts) {
370
+ const batch = chunks.slice(start, start + embedding.maxBatchTexts);
371
+ const payload = await postJson(embedding.url, { input: batch, model: embedding.model, encoding_format: "float" }, token, embedding.timeoutMs, signal, "embedding");
372
+ parseEmbeddingPayload(payload, batch.length).forEach((vector, index) => (vectors[start + index] = vector));
373
+ }
374
+ const pooled = [];
375
+ let offset = 0;
376
+ for (const list of chunkLists) {
377
+ pooled.push(meanPool(vectors.slice(offset, offset + list.length)));
378
+ offset += list.length;
379
+ }
380
+ return pooled;
381
+ }
382
+ function parseRerankResults(payload, count, offset) {
383
+ const results = payload.results;
384
+ if (!Array.isArray(results))
385
+ throw new Error("reranker response is missing results");
386
+ // top_n equals the number of sent documents, so a complete answer ranks
387
+ // every candidate; an empty or shortened response is a failure, not an
388
+ // empty success.
389
+ if (results.length !== count)
390
+ throw new Error("reranker response is incomplete");
391
+ const seen = new Set();
392
+ const hits = [];
393
+ for (const item of results) {
394
+ const record = item;
395
+ const index = record?.index;
396
+ const score = record?.relevance_score;
397
+ if (!Number.isInteger(index) || index < 0 || index >= count)
398
+ throw new Error("reranker response has invalid indices");
399
+ if (typeof score !== "number" || !Number.isFinite(score))
400
+ throw new Error("reranker response is malformed");
401
+ if (seen.has(index))
402
+ throw new Error("reranker response has invalid indices");
403
+ seen.add(index);
404
+ hits.push({ index: offset + index, score });
405
+ }
406
+ return hits;
407
+ }
408
+ /**
409
+ * Ranks documents against a query through a `/rerank`-style endpoint
410
+ * (`{model, query, documents, top_n}` request, `results[].index` and
411
+ * `results[].relevance_score` response). Documents are chunked so the query,
412
+ * one document chunk and the server template stay inside maxChars; a query
413
+ * too large to leave room for a chunk fails before the request. Responses
414
+ * must contain exactly top_n results, and chunk scores aggregate to one best
415
+ * score per input document.
416
+ */
417
+ export async function rerankTexts(settings, query, documents, signal) {
418
+ const reranker = settings.reranker;
419
+ if (!reranker || !documents.length)
420
+ return [];
421
+ const token = bearerToken(reranker.bearerEnv, "reranker");
422
+ // Query, one document chunk and the template allowance share maxChars. A
423
+ // small remainder is honored as-is; inflating it would push the request
424
+ // past the model input budget.
425
+ const remaining = reranker.maxChars - RERANKER_TEMPLATE_ALLOWANCE_CHARS - query.length;
426
+ if (remaining < 1)
427
+ throw new Error(`reranker query of ${query.length} characters leaves no document room inside maxChars=${reranker.maxChars}`);
428
+ const chunkLists = documents.map((document) => chunkText(document, remaining));
429
+ const offsets = [];
430
+ let total = 0;
431
+ for (const list of chunkLists) {
432
+ offsets.push(total);
433
+ total += list.length;
434
+ }
435
+ const scores = new Map();
436
+ const chunks = chunkLists.flat();
437
+ for (let start = 0; start < total; start += reranker.maxDocuments) {
438
+ const batch = chunks.slice(start, start + reranker.maxDocuments);
439
+ const payload = await postJson(reranker.url, {
440
+ model: reranker.model,
441
+ query,
442
+ documents: batch,
443
+ top_n: batch.length,
444
+ }, token, reranker.timeoutMs, signal, "reranker");
445
+ for (const { index, score } of parseRerankResults(payload, batch.length, start))
446
+ scores.set(index, Math.max(scores.get(index) ?? Number.NEGATIVE_INFINITY, score));
447
+ }
448
+ const hits = [];
449
+ chunkLists.forEach((list, index) => {
450
+ let best = null;
451
+ for (let offset = 0; offset < list.length; offset += 1) {
452
+ const score = scores.get(offsets[index] + offset);
453
+ if (score !== undefined && (best === null || score > best))
454
+ best = score;
455
+ }
456
+ if (best !== null)
457
+ hits.push({ index, score: best });
458
+ });
459
+ return hits.sort((left, right) => right.score - left.score || left.index - right.index);
178
460
  }
179
461
  export function embeddingFingerprint(settings) {
180
462
  return createHash("sha256")
@@ -182,7 +464,15 @@ export function embeddingFingerprint(settings) {
182
464
  ? {
183
465
  url: settings.embedding.url,
184
466
  model: settings.embedding.model,
185
- documentFormat: "raw-v1",
467
+ document: {
468
+ prefix: settings.embedding.documentPrefix,
469
+ maxChars: settings.embedding.maxTextChars,
470
+ pooling: "mean-v1",
471
+ // Chunking now happens before formatting, so every chunk
472
+ // carries the prefix/instruction; bumping this version
473
+ // invalidates indexes built by the previous algorithm.
474
+ formatting: "per-chunk-v2",
475
+ },
186
476
  }
187
477
  : null))
188
478
  .digest("hex");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kisev/memomatic",
3
- "version": "1.0.0-dev.99.g48fbb8555698",
3
+ "version": "12.0.1-dev.115.g8010bdfeea15",
4
4
  "description": "Personal learning memory for agents: tiered Markdown corpus, rebuildable index, dream sweep.",
5
5
  "homepage": "https://github.com/kisev/skills#readme",
6
6
  "bugs": {
@@ -34,7 +34,7 @@
34
34
  "pack:check": "node test/pack-allowlist.mjs"
35
35
  },
36
36
  "dependencies": {
37
- "@kisev/safe-fs": "1.0.0-dev.99.g48fbb8555698",
37
+ "@kisev/safe-fs": "12.0.1-dev.115.g8010bdfeea15",
38
38
  "commander": "15.0.0",
39
39
  "jsonc-parser": "3.3.1"
40
40
  },