@gethmy/mcp 2.3.0 → 2.3.2

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 (38) hide show
  1. package/dist/cli.js +80 -23
  2. package/dist/index.js +80 -23
  3. package/dist/lib/active-learning.js +939 -787
  4. package/dist/lib/api-client.js +2527 -638
  5. package/dist/lib/auto-session.js +177 -196
  6. package/dist/lib/cli.js +34954 -128
  7. package/dist/lib/config.js +235 -201
  8. package/dist/lib/consolidation.js +374 -289
  9. package/dist/lib/context-assembly.js +1265 -838
  10. package/dist/lib/graph-expansion.js +139 -155
  11. package/dist/lib/http.js +1917 -130
  12. package/dist/lib/index.js +29525 -5
  13. package/dist/lib/lifecycle-maintenance.js +663 -79
  14. package/dist/lib/memory-cleanup.js +1316 -381
  15. package/dist/lib/onboard.js +2588 -32
  16. package/dist/lib/prompt-builder.js +438 -445
  17. package/dist/lib/remote.js +31733 -143
  18. package/dist/lib/server.js +29389 -3216
  19. package/dist/lib/skills.js +315 -132
  20. package/dist/lib/tui/agents.js +128 -107
  21. package/dist/lib/tui/docs.js +1590 -687
  22. package/dist/lib/tui/setup.js +5698 -804
  23. package/dist/lib/tui/theme.js +183 -86
  24. package/dist/lib/tui/writer.js +1149 -176
  25. package/package.json +2 -2
  26. package/src/api-client.ts +37 -1
  27. package/src/memory-cleanup.ts +92 -52
  28. package/src/server.ts +16 -1
  29. package/dist/lib/__tests__/active-learning.test.js +0 -386
  30. package/dist/lib/__tests__/agent-performance-profiles.test.js +0 -325
  31. package/dist/lib/__tests__/auto-session.test.js +0 -661
  32. package/dist/lib/__tests__/context-assembly.test.js +0 -362
  33. package/dist/lib/__tests__/graph-expansion.test.js +0 -150
  34. package/dist/lib/__tests__/integration-memory-crud.test.js +0 -797
  35. package/dist/lib/__tests__/integration-memory-system.test.js +0 -281
  36. package/dist/lib/__tests__/lifecycle-maintenance.test.js +0 -207
  37. package/dist/lib/__tests__/pattern-detection.test.js +0 -295
  38. package/dist/lib/__tests__/prompt-builder.test.js +0 -418
@@ -1,884 +1,1311 @@
1
- /**
2
- * Context Assembly Engine
3
- *
4
- * Token-budget-aware context constructor that assembles relevant memories
5
- * for a given task, producing a manifest of what was included/excluded.
6
- */
7
- import { checkPromotion, discoverRelatedContext } from "@harmony/memory";
8
- // Constants
9
- const DEFAULT_TOKEN_BUDGET = 4000;
10
- const MAX_TOKENS_PER_ENTITY = 500;
11
- const MIN_RELEVANCE_THRESHOLD = 0.15; // raised from 0.1 to filter low-signal entities
12
- // Tier weight multipliers for relevance scoring
13
- const TIER_WEIGHTS = {
14
- reference: 1.0,
15
- episode: 0.7,
16
- draft: 0.4,
17
- };
18
- // Dedicated procedure budget as a fraction of total budget
19
- const PROCEDURE_BUDGET_FRACTION = 0.15;
20
- // Tier budget allocation percentages (of remaining budget after procedure reservation)
21
- const TIER_BUDGET_ALLOCATION = {
22
- reference: 0.6,
23
- episode: 0.3,
24
- draft: 0.1,
25
- };
26
- // Minimum guaranteed slots per tier (reduced from 3 to avoid filling context with noise)
27
- const MIN_REFERENCE_SLOTS = 1;
28
- // Graph walk configuration
29
- const GRAPH_WALK_MAX_DEPTH = 1;
30
- const GRAPH_WALK_MAX_ENTITIES = 10;
31
- const GRAPH_WALK_MIN_CONFIDENCE = 0.5;
32
- const GRAPH_WALK_SEED_COUNT = 5;
33
- // Query expansion configuration
34
- const MAX_QUERY_VARIATIONS = 4;
35
- // LLM re-ranking configuration
36
- const RERANK_CLUSTER_THRESHOLD = 0.05;
37
- const RERANK_TOP_N = 10;
38
- const RERANK_MIN_CANDIDATES = 5;
39
- // Graph walk relation-type bonuses for relevance scoring
40
- const RELATION_BONUSES = {
41
- depends_on: 0.15,
42
- resolved_by: 0.2,
43
- relates_to: 0.1,
44
- implements: 0.15,
45
- blocks: 0.15,
46
- references: 0.1,
47
- extends: 0.1,
48
- caused_by: 0.15,
1
+ import { createRequire } from "node:module";
2
+ var __create = Object.create;
3
+ var __getProtoOf = Object.getPrototypeOf;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __toESM = (mod, isNodeMode, target) => {
8
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
9
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
10
+ for (let key of __getOwnPropNames(mod))
11
+ if (!__hasOwnProp.call(to, key))
12
+ __defProp(to, key, {
13
+ get: () => mod[key],
14
+ enumerable: true
15
+ });
16
+ return to;
49
17
  };
50
- // Synonym map for query expansion (common dev term variations)
51
- // NOTE: Avoid circular references (auth->login, login->auth) first synonym
52
- // is used for replacement, so each key should expand to non-overlapping terms.
53
- const QUERY_SYNONYMS = {
54
- auth: ["authentication", "authorization", "session"],
55
- authentication: ["auth", "session", "sign-in"],
56
- login: ["sign-in", "authentication", "session"],
57
- bug: ["error", "issue", "defect", "problem"],
58
- error: ["exception", "failure", "issue"],
59
- fix: ["resolve", "patch", "repair", "correct"],
60
- deploy: ["deployment", "release", "ship", "publish"],
61
- test: ["testing", "spec", "assertion", "verify"],
62
- config: ["configuration", "settings", "setup"],
63
- db: ["database", "storage", "persistence"],
64
- database: ["storage", "persistence", "data store"],
65
- api: ["endpoint", "route", "service"],
66
- ui: ["frontend", "component", "view"],
67
- perf: ["performance", "speed", "latency"],
68
- performance: ["speed", "latency", "optimization"],
18
+ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
19
+ var __export = (target, all) => {
20
+ for (var name in all)
21
+ __defProp(target, name, {
22
+ get: all[name],
23
+ enumerable: true,
24
+ configurable: true,
25
+ set: (newValue) => all[name] = () => newValue
26
+ });
69
27
  };
70
- /**
71
- * Estimate token count (rough: 1 token per 4 chars)
72
- */
73
- function estimateTokens(text) {
74
- return Math.ceil(text.length / 4);
75
- }
76
- /**
77
- * Content quality gate: filter out entities that waste token budget.
78
- * Returns true if the entity passes quality checks.
79
- */
80
- function passesQualityGate(entity) {
81
- const content = entity.content.trim();
82
- // Gate 1: Minimum content length — entities with <50 chars of content
83
- // are too shallow to provide value (e.g., "Resolved bug: Fix login button")
84
- if (content.length < 50)
85
- return false;
86
- // Gate 2: Title-content similarity skip entities where content is just
87
- // the title restated. Normalize both and check if content adds anything.
88
- const normalizedTitle = entity.title
89
- .toLowerCase()
90
- .replace(/[^a-z0-9\s]/g, "")
91
- .trim();
92
- const normalizedContent = content
93
- .toLowerCase()
94
- .replace(/[^a-z0-9\s]/g, "")
95
- .trim();
96
- if (normalizedContent.length < normalizedTitle.length * 1.5) {
97
- // Content is barely longer than the title — likely just a reformulation
98
- return false;
28
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
29
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
30
+
31
+ // ../memory/dist/schema.js
32
+ var init_schema = () => {};
33
+
34
+ // ../memory/dist/constraints.js
35
+ var init_constraints = __esm(() => {
36
+ init_schema();
37
+ });
38
+ // ../memory/dist/client.js
39
+ var init_client = __esm(() => {
40
+ init_constraints();
41
+ });
42
+
43
+ // ../memory/dist/graph-walk.js
44
+ async function discoverRelatedContext(client, startIds, maxDepth = 2, maxEntities = 20, minConfidence = 0.5) {
45
+ const visited = new Set;
46
+ const collectedEntities = [];
47
+ const collectedRelations = [];
48
+ let truncated = false;
49
+ const queue = startIds.map((id) => [id, 0]);
50
+ for (const id of startIds) {
51
+ visited.add(id);
52
+ }
53
+ while (queue.length > 0) {
54
+ const [entityId, depth] = queue.shift();
55
+ if (collectedEntities.length >= maxEntities) {
56
+ truncated = true;
57
+ break;
99
58
  }
100
- // Gate 3: Pattern noise detection — skip "Pattern: recurring X (N instances)"
101
- // and "Consolidated from N type memories:" entities that are just catalogs
102
- if (entity.type === "pattern" &&
103
- /recurring .+ \(\d+ instances\)/i.test(entity.title)) {
104
- // Check if content is just a member list (lines starting with "- ")
105
- const lines = content.split("\n").filter((l) => l.trim().length > 0);
106
- const bulletLines = lines.filter((l) => l.trim().startsWith("- "));
107
- if (bulletLines.length > lines.length * 0.6)
108
- return false;
59
+ if (depth > maxDepth)
60
+ continue;
61
+ try {
62
+ const entityResult = await client.getMemoryEntity(entityId);
63
+ const entity = entityResult.entity;
64
+ if (entity) {
65
+ collectedEntities.push({
66
+ id: entity.id,
67
+ type: entity.type,
68
+ title: entity.title,
69
+ confidence: entity.confidence ?? 1,
70
+ memory_tier: entity.memory_tier || "reference"
71
+ });
72
+ }
73
+ if (depth >= maxDepth)
74
+ continue;
75
+ const related = await client.getRelatedEntities(entityId);
76
+ for (const raw of related.outgoing || []) {
77
+ const rel = raw;
78
+ const relConfidence = rel.confidence ?? 1;
79
+ if (relConfidence < minConfidence)
80
+ continue;
81
+ const target = rel.target;
82
+ const targetId = target?.id ?? rel.target_id;
83
+ if (targetId && !visited.has(targetId)) {
84
+ visited.add(targetId);
85
+ queue.push([targetId, depth + 1]);
86
+ collectedRelations.push({
87
+ id: rel.id,
88
+ source_id: entityId,
89
+ target_id: targetId,
90
+ relation_type: rel.relation_type,
91
+ confidence: relConfidence
92
+ });
93
+ }
94
+ }
95
+ for (const raw of related.incoming || []) {
96
+ const rel = raw;
97
+ const relConfidence = rel.confidence ?? 1;
98
+ if (relConfidence < minConfidence)
99
+ continue;
100
+ const source = rel.source;
101
+ const sourceId = source?.id ?? rel.source_id;
102
+ if (sourceId && !visited.has(sourceId)) {
103
+ visited.add(sourceId);
104
+ queue.push([sourceId, depth + 1]);
105
+ collectedRelations.push({
106
+ id: rel.id,
107
+ source_id: sourceId,
108
+ target_id: entityId,
109
+ relation_type: rel.relation_type,
110
+ confidence: relConfidence
111
+ });
112
+ }
113
+ }
114
+ } catch {}
115
+ }
116
+ return {
117
+ entities: collectedEntities,
118
+ relations: collectedRelations,
119
+ depth: maxDepth,
120
+ truncated
121
+ };
122
+ }
123
+
124
+ // ../memory/dist/lifecycle.js
125
+ function computeDecayScore(tier, lastAccessedAt, accessCount) {
126
+ const halfLife = DECAY_HALF_LIVES[tier];
127
+ const now = Date.now();
128
+ let daysSinceAccess = 0;
129
+ if (lastAccessedAt) {
130
+ daysSinceAccess = (now - new Date(lastAccessedAt).getTime()) / (1000 * 60 * 60 * 24);
131
+ }
132
+ const timeDecay = 0.5 ** (daysSinceAccess / halfLife);
133
+ const accessBonus = Math.log10(accessCount + 1) * 0.1;
134
+ const score = Math.min(timeDecay + accessBonus, 1);
135
+ return { score, daysSinceAccess, halfLife, accessBonus };
136
+ }
137
+ function checkPromotion(currentTier, accessCount, confidence, createdAt) {
138
+ const ageDays = (Date.now() - new Date(createdAt).getTime()) / (1000 * 60 * 60 * 24);
139
+ const base = {
140
+ eligible: false,
141
+ targetTier: null,
142
+ reason: null,
143
+ currentTier,
144
+ accessCount,
145
+ confidence,
146
+ ageDays
147
+ };
148
+ if (currentTier === "draft") {
149
+ const rules = PROMOTION_RULES.draftToEpisode;
150
+ if (accessCount >= rules.minAccessCount && confidence >= rules.minConfidence && ageDays >= rules.minAgeDays) {
151
+ return {
152
+ ...base,
153
+ eligible: true,
154
+ targetTier: "episode",
155
+ reason: `Accessed ${accessCount} times (≥${rules.minAccessCount}), confidence ${confidence} (≥${rules.minConfidence}), age ${Math.round(ageDays)}d (≥${rules.minAgeDays}d)`
156
+ };
109
157
  }
110
- // Gate 4: Procedure quality — procedures must contain actual steps,
111
- // not just a card title wrapped in a template
112
- if (entity.type === "procedure") {
113
- // Count numbered steps (1. ..., 2. ..., etc.)
114
- const stepCount = (content.match(/^\d+\.\s/gm) || []).length;
115
- if (stepCount < 3)
116
- return false;
158
+ }
159
+ if (currentTier === "episode") {
160
+ const rules = PROMOTION_RULES.episodeToReference;
161
+ if (accessCount >= rules.minAccessCount && confidence >= rules.minConfidence && ageDays >= rules.minAgeDays) {
162
+ return {
163
+ ...base,
164
+ eligible: true,
165
+ targetTier: "reference",
166
+ reason: `Accessed ${accessCount} times (≥${rules.minAccessCount}), confidence ${confidence} (≥${rules.minConfidence}), age ${Math.round(ageDays)}d (≥${rules.minAgeDays}d)`
167
+ };
117
168
  }
118
- return true;
169
+ }
170
+ return base;
119
171
  }
120
- /**
121
- * Generate a unique assembly ID
122
- */
123
- function generateAssemblyId() {
124
- return `ctx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
172
+ function evaluateLifecycle(entity) {
173
+ const decay = computeDecayScore(entity.memory_tier, entity.last_accessed_at, entity.access_count);
174
+ const promotion = checkPromotion(entity.memory_tier, entity.access_count, entity.confidence, entity.created_at);
175
+ const shouldArchive = entity.confidence < ARCHIVE_THRESHOLD;
176
+ const archiveReason = shouldArchive ? `Confidence ${entity.confidence} below threshold ${ARCHIVE_THRESHOLD}` : undefined;
177
+ const shouldFlagForReview = decay.daysSinceAccess >= STALE_DAYS && entity.access_count < STALE_MIN_ACCESS;
178
+ const reviewReason = shouldFlagForReview ? `Not accessed in ${Math.round(decay.daysSinceAccess)} days with only ${entity.access_count} accesses` : undefined;
179
+ return {
180
+ decay,
181
+ promotion,
182
+ shouldArchive,
183
+ shouldFlagForReview,
184
+ archiveReason,
185
+ reviewReason
186
+ };
125
187
  }
126
- /**
127
- * Truncate entity content to fit within token limit.
128
- * Keeps first paragraph + bullet points if present.
129
- */
130
- function truncateContent(content, maxTokens) {
131
- const currentTokens = estimateTokens(content);
132
- if (currentTokens <= maxTokens) {
133
- return { text: content, truncated: false };
188
+ var DECAY_HALF_LIVES, PROMOTION_RULES, ARCHIVE_THRESHOLD = 0.3, STALE_DAYS = 90, STALE_MIN_ACCESS = 3;
189
+ var init_lifecycle = __esm(() => {
190
+ DECAY_HALF_LIVES = {
191
+ draft: 7,
192
+ episode: 30,
193
+ reference: 180
194
+ };
195
+ PROMOTION_RULES = {
196
+ draftToEpisode: {
197
+ minAccessCount: 5,
198
+ minConfidence: 0.8,
199
+ minAgeDays: 1
200
+ },
201
+ episodeToReference: {
202
+ minAccessCount: 10,
203
+ minConfidence: 0.9,
204
+ minAgeDays: 7
134
205
  }
135
- // Try to keep first paragraph
136
- const paragraphs = content.split(/\n\n+/);
137
- let result = paragraphs[0];
138
- // Add bullet points from subsequent paragraphs if they fit
139
- for (let i = 1; i < paragraphs.length; i++) {
140
- const lines = paragraphs[i]
141
- .split("\n")
142
- .filter((l) => l.startsWith("- ") || l.startsWith("* "));
143
- if (lines.length > 0) {
144
- const bulletSection = lines.join("\n");
145
- if (estimateTokens(result + "\n\n" + bulletSection) <= maxTokens) {
146
- result += "\n\n" + bulletSection;
147
- }
148
- }
149
- }
150
- // Hard truncate if still too long
151
- if (estimateTokens(result) > maxTokens) {
152
- const maxChars = maxTokens * 4;
153
- result = result.slice(0, maxChars - 3) + "...";
206
+ };
207
+ });
208
+
209
+ // ../memory/dist/sync-storage.js
210
+ function parseSyncMarkdown(markdown) {
211
+ const trimmed = markdown.trim();
212
+ let frontmatter = {
213
+ type: "context",
214
+ scope: "project",
215
+ tier: "reference",
216
+ confidence: 1,
217
+ tags: []
218
+ };
219
+ let body = trimmed;
220
+ const fmMatch = trimmed.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
221
+ if (fmMatch) {
222
+ frontmatter = parseSyncYamlFrontmatter(fmMatch[1]);
223
+ body = fmMatch[2].trim();
224
+ }
225
+ let title = "";
226
+ const titleMatch = body.match(/^#\s+(.+)/m);
227
+ if (titleMatch) {
228
+ title = titleMatch[1].trim();
229
+ body = body.replace(/^#\s+.+\n?/, "").trim();
230
+ }
231
+ return { frontmatter, title, content: body };
232
+ }
233
+ function serializeSyncMarkdown(entity) {
234
+ const lines = ["---"];
235
+ lines.push(`id: ${entity.id}`);
236
+ lines.push(`workspace_id: ${entity.workspace_id}`);
237
+ if (entity.project_id) {
238
+ lines.push(`project_id: ${entity.project_id}`);
239
+ }
240
+ lines.push(`type: ${entity.type}`);
241
+ lines.push(`scope: ${entity.scope}`);
242
+ lines.push(`tier: ${entity.memory_tier || "reference"}`);
243
+ lines.push(`confidence: ${entity.confidence}`);
244
+ if (entity.tags.length > 0) {
245
+ lines.push(`tags: [${entity.tags.join(", ")}]`);
246
+ } else {
247
+ lines.push("tags: []");
248
+ }
249
+ if (entity.agent_identifier) {
250
+ lines.push(`agent: ${entity.agent_identifier}`);
251
+ }
252
+ lines.push(`created_at: ${entity.created_at}`);
253
+ lines.push(`updated_at: ${entity.updated_at}`);
254
+ lines.push("---");
255
+ lines.push("");
256
+ lines.push(`# ${entity.title}`);
257
+ lines.push("");
258
+ lines.push(entity.content);
259
+ return lines.join(`
260
+ `);
261
+ }
262
+ function parseSyncYamlFrontmatter(yaml) {
263
+ const result = {
264
+ type: "context",
265
+ scope: "project",
266
+ tier: "reference",
267
+ confidence: 1,
268
+ tags: []
269
+ };
270
+ for (const line of yaml.split(`
271
+ `)) {
272
+ const colonIndex = line.indexOf(":");
273
+ if (colonIndex === -1)
274
+ continue;
275
+ const key = line.slice(0, colonIndex).trim();
276
+ const value = line.slice(colonIndex + 1).trim();
277
+ switch (key) {
278
+ case "id":
279
+ result.id = value;
280
+ break;
281
+ case "workspace_id":
282
+ result.workspace_id = value;
283
+ break;
284
+ case "project_id":
285
+ result.project_id = value;
286
+ break;
287
+ case "type":
288
+ result.type = value;
289
+ break;
290
+ case "scope":
291
+ result.scope = value;
292
+ break;
293
+ case "tier":
294
+ result.tier = value;
295
+ break;
296
+ case "confidence":
297
+ result.confidence = parseFloat(value) || 1;
298
+ break;
299
+ case "tags":
300
+ result.tags = parseYamlArray(value);
301
+ break;
302
+ case "related":
303
+ result.related = parseYamlArray(value);
304
+ break;
305
+ case "agent":
306
+ result.agent = value;
307
+ break;
308
+ case "created_at":
309
+ result.created_at = value;
310
+ break;
311
+ case "updated_at":
312
+ result.updated_at = value;
313
+ break;
154
314
  }
155
- return { text: result, truncated: true };
315
+ }
316
+ return result;
156
317
  }
157
- /**
158
- * Escape regex metacharacters in a string for safe use in RegExp constructor.
159
- */
160
- function escapeRegex(str) {
161
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
162
- }
163
- /**
164
- * Expand a query into multiple search variations using synonym substitution.
165
- * Returns the original query plus up to 3 additional variations (4 total).
166
- */
167
- export function expandQuery(taskContext) {
168
- const queries = [taskContext];
169
- const lowerQueries = [taskContext.toLowerCase()];
170
- const words = taskContext
171
- .toLowerCase()
172
- .split(/\W+/)
173
- .filter((w) => w.length > 2);
174
- // Find words that have synonym expansions
175
- const expandableWords = words.filter((w) => QUERY_SYNONYMS[w]);
176
- for (const word of expandableWords) {
177
- const synonyms = QUERY_SYNONYMS[word];
178
- if (!synonyms)
179
- continue;
180
- // Create a variation by replacing the word with its first synonym
181
- const variation = taskContext.replace(new RegExp(`\\b${escapeRegex(word)}\\b`, "gi"), synonyms[0]);
182
- const lowerVariation = variation.toLowerCase();
183
- if (lowerVariation !== taskContext.toLowerCase() &&
184
- !lowerQueries.includes(lowerVariation)) {
185
- queries.push(variation);
186
- lowerQueries.push(lowerVariation);
187
- }
188
- if (queries.length >= MAX_QUERY_VARIATIONS)
189
- break;
318
+ function parseYamlArray(value) {
319
+ const match = value.match(/^\[(.*)]\s*$/);
320
+ if (!match)
321
+ return [];
322
+ return match[1].split(",").map((s) => s.trim()).filter(Boolean);
323
+ }
324
+
325
+ // ../memory/dist/sync.js
326
+ import { createHash } from "node:crypto";
327
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
328
+ import { join, relative, sep } from "node:path";
329
+ function computeFileHash(content) {
330
+ return `sha256:${createHash("sha256").update(content).digest("hex")}`;
331
+ }
332
+ function slugifyTitle(title) {
333
+ return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
334
+ }
335
+ function entityToFilename(entity) {
336
+ const slug = slugifyTitle(entity.title);
337
+ const shortId = entity.id.slice(0, 8);
338
+ return `${entity.type}--${slug}--${shortId}.md`;
339
+ }
340
+ function entityToDirectoryPath(entity, memoryDir) {
341
+ const wsDir = join(memoryDir, entity.workspace_id);
342
+ if (entity.scope === "private") {
343
+ return join(wsDir, "_private");
344
+ }
345
+ if (entity.scope === "workspace" || !entity.project_id) {
346
+ return join(wsDir, "_workspace");
347
+ }
348
+ return join(wsDir, entity.project_id);
349
+ }
350
+ function emptySyncState() {
351
+ return { version: 1, lastPullAt: null, entities: {} };
352
+ }
353
+ function loadSyncState(memoryDir) {
354
+ const statePath = join(memoryDir, ".sync-state.json");
355
+ if (!existsSync(statePath))
356
+ return emptySyncState();
357
+ try {
358
+ return JSON.parse(readFileSync(statePath, "utf-8"));
359
+ } catch {
360
+ return emptySyncState();
361
+ }
362
+ }
363
+ function saveSyncState(memoryDir, state) {
364
+ if (!existsSync(memoryDir)) {
365
+ mkdirSync(memoryDir, { recursive: true });
366
+ }
367
+ writeFileSync(join(memoryDir, ".sync-state.json"), JSON.stringify(state, null, 2));
368
+ }
369
+ function writeEntityFile(entity, memoryDir) {
370
+ const dir = entityToDirectoryPath(entity, memoryDir);
371
+ if (!existsSync(dir))
372
+ mkdirSync(dir, { recursive: true });
373
+ const filename = entityToFilename(entity);
374
+ const filePath = join(dir, filename);
375
+ const markdown = serializeSyncMarkdown(entity);
376
+ writeFileSync(filePath, markdown);
377
+ return relative(memoryDir, filePath);
378
+ }
379
+ function deleteEntityFile(relPath, memoryDir) {
380
+ const absPath = join(memoryDir, relPath);
381
+ if (existsSync(absPath)) {
382
+ rmSync(absPath);
383
+ }
384
+ }
385
+ function findMarkdownFiles(dir) {
386
+ const results = [];
387
+ if (!existsSync(dir))
388
+ return results;
389
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
390
+ const fullPath = join(dir, entry.name);
391
+ if (entry.isDirectory()) {
392
+ results.push(...findMarkdownFiles(fullPath));
393
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
394
+ results.push(fullPath);
190
395
  }
191
- // Also extract key noun phrases as a compact query
192
- if (words.length >= 3) {
193
- const keyPhrases = words
194
- .filter((w) => ![
195
- "the",
196
- "and",
197
- "for",
198
- "with",
199
- "this",
200
- "that",
201
- "from",
202
- "into",
203
- ].includes(w))
204
- .slice(0, 4)
205
- .join(" ");
206
- if (!lowerQueries.includes(keyPhrases)) {
207
- queries.push(keyPhrases);
208
- }
396
+ }
397
+ return results;
398
+ }
399
+ async function syncPull(client, config, workspaceId, projectId) {
400
+ const { memoryDir } = config;
401
+ const state = loadSyncState(memoryDir);
402
+ const result = {
403
+ pulled: 0,
404
+ pushed: 0,
405
+ deleted: 0,
406
+ conflicts: 0,
407
+ errors: []
408
+ };
409
+ const allEntities = [];
410
+ let offset = 0;
411
+ const batchSize = 100;
412
+ while (true) {
413
+ try {
414
+ const resp = await client.listMemoryEntities({
415
+ workspace_id: workspaceId,
416
+ project_id: projectId,
417
+ limit: batchSize,
418
+ offset
419
+ });
420
+ const batch = resp.entities;
421
+ allEntities.push(...batch);
422
+ if (batch.length < batchSize)
423
+ break;
424
+ offset += batchSize;
425
+ } catch (err) {
426
+ result.errors.push(`Failed to fetch entities: ${err}`);
427
+ return result;
209
428
  }
210
- return queries.slice(0, MAX_QUERY_VARIATIONS);
211
- }
212
- /**
213
- * Compute relevance score for an entity against task context.
214
- */
215
- export function computeRelevanceScore(entity, taskContext, cardLabels, graphRelations) {
216
- const reasons = [];
217
- let score = 0;
218
- // 0. DB hybrid search signal (RRF score from FTS + vector fusion)
219
- // Scaled to 0-0.3 contribution; when present, reduces reliance on word-overlap
220
- const hasRrfScore = entity.rrf_score !== undefined && entity.rrf_score > 0;
221
- if (hasRrfScore) {
222
- // RRF scores are typically 0-0.04; normalize to 0-1 range then scale
223
- const normalizedRrf = Math.min(entity.rrf_score / 0.04, 1.0);
224
- const rrfContribution = normalizedRrf * 0.3;
225
- score += rrfContribution;
226
- reasons.push(`hybrid_search(rrf=${entity.rrf_score.toFixed(4)})`);
429
+ }
430
+ const remoteIds = new Set(allEntities.map((e) => e.id));
431
+ for (const entity of allEntities) {
432
+ const existing = state.entities[entity.id];
433
+ const markdown = serializeSyncMarkdown(entity);
434
+ const hash = computeFileHash(markdown);
435
+ if (!existing) {
436
+ const relPath = writeEntityFile(entity, memoryDir);
437
+ state.entities[entity.id] = {
438
+ filePath: relPath,
439
+ remoteUpdatedAt: entity.updated_at,
440
+ lastSyncedHash: hash
441
+ };
442
+ result.pulled++;
443
+ } else {
444
+ const remoteChanged = entity.updated_at > existing.remoteUpdatedAt;
445
+ if (!remoteChanged)
446
+ continue;
447
+ const absPath = join(memoryDir, existing.filePath);
448
+ let localChanged = false;
449
+ if (existsSync(absPath)) {
450
+ const localContent = readFileSync(absPath, "utf-8");
451
+ const localHash = computeFileHash(localContent);
452
+ localChanged = localHash !== existing.lastSyncedHash;
453
+ }
454
+ if (localChanged) {
455
+ result.conflicts++;
456
+ }
457
+ const newRelPath = writeEntityFile(entity, memoryDir);
458
+ if (existing.filePath !== newRelPath) {
459
+ deleteEntityFile(existing.filePath, memoryDir);
460
+ }
461
+ state.entities[entity.id] = {
462
+ filePath: newRelPath,
463
+ remoteUpdatedAt: entity.updated_at,
464
+ lastSyncedHash: hash
465
+ };
466
+ result.pulled++;
227
467
  }
228
- // 1. Text match: simple word overlap scoring (reduced weight when RRF available)
229
- const textMatchWeight = hasRrfScore ? 0.15 : 0.4;
230
- const taskWords = new Set(taskContext
231
- .toLowerCase()
232
- .split(/\W+/)
233
- .filter((w) => w.length > 2));
234
- const entityWords = new Set(`${entity.title} ${entity.content}`
235
- .toLowerCase()
236
- .split(/\W+/)
237
- .filter((w) => w.length > 2));
238
- const overlap = [...taskWords].filter((w) => entityWords.has(w));
239
- if (overlap.length > 0) {
240
- const textScore = Math.min(overlap.length / Math.max(taskWords.size, 1), 1.0) *
241
- textMatchWeight;
242
- score += textScore;
243
- reasons.push(`text_match(${overlap.length} words)`);
468
+ }
469
+ for (const [entityId, entry] of Object.entries(state.entities)) {
470
+ if (!remoteIds.has(entityId)) {
471
+ deleteEntityFile(entry.filePath, memoryDir);
472
+ delete state.entities[entityId];
473
+ result.deleted++;
244
474
  }
245
- // 2. Tag overlap with card labels
246
- if (cardLabels.length > 0 && entity.tags.length > 0) {
247
- const labelSet = new Set(cardLabels.map((l) => l.toLowerCase()));
248
- const tagOverlap = entity.tags.filter((t) => labelSet.has(t.toLowerCase()));
249
- if (tagOverlap.length > 0) {
250
- const tagScore = (tagOverlap.length / cardLabels.length) * 0.3;
251
- score += tagScore;
252
- reasons.push(`tag_match(${tagOverlap.join(",")})`);
475
+ }
476
+ state.lastPullAt = new Date().toISOString();
477
+ saveSyncState(memoryDir, state);
478
+ return result;
479
+ }
480
+ async function syncPush(client, config, workspaceId) {
481
+ const { memoryDir } = config;
482
+ const state = loadSyncState(memoryDir);
483
+ const result = {
484
+ pulled: 0,
485
+ pushed: 0,
486
+ deleted: 0,
487
+ conflicts: 0,
488
+ errors: []
489
+ };
490
+ const mdFiles = findMarkdownFiles(memoryDir);
491
+ for (const absPath of mdFiles) {
492
+ const content = readFileSync(absPath, "utf-8");
493
+ const hash = computeFileHash(content);
494
+ const parsed = parseSyncMarkdown(content);
495
+ if (parsed.frontmatter.id) {
496
+ const entityId = parsed.frontmatter.id;
497
+ const existing = state.entities[entityId];
498
+ if (existing && hash === existing.lastSyncedHash)
499
+ continue;
500
+ try {
501
+ const resp = await client.updateMemoryEntity(entityId, {
502
+ title: parsed.title || "Untitled",
503
+ content: parsed.content,
504
+ type: parsed.frontmatter.type,
505
+ scope: parsed.frontmatter.scope,
506
+ confidence: parsed.frontmatter.confidence,
507
+ tags: parsed.frontmatter.tags
508
+ });
509
+ const updated = resp.entity;
510
+ const newMarkdown = serializeSyncMarkdown(updated);
511
+ writeFileSync(absPath, newMarkdown);
512
+ const relPath = relative(memoryDir, absPath);
513
+ state.entities[entityId] = {
514
+ filePath: relPath,
515
+ remoteUpdatedAt: updated.updated_at,
516
+ lastSyncedHash: computeFileHash(newMarkdown)
517
+ };
518
+ result.pushed++;
519
+ } catch (err) {
520
+ result.errors.push(`Failed to update ${entityId}: ${err}`);
521
+ }
522
+ } else {
523
+ const relPath = relative(memoryDir, absPath);
524
+ const parts = relPath.split(sep);
525
+ const fileWorkspaceId = parts.length >= 2 ? parts[0] : workspaceId;
526
+ let fileProjectId;
527
+ if (parts.length >= 3) {
528
+ const scopeDir = parts[1];
529
+ if (scopeDir !== "_workspace" && scopeDir !== "_private") {
530
+ fileProjectId = scopeDir;
253
531
  }
532
+ }
533
+ let scope = parsed.frontmatter.scope || "project";
534
+ if (parts.length >= 3) {
535
+ const scopeDir = parts[1];
536
+ if (scopeDir === "_private")
537
+ scope = "private";
538
+ else if (scopeDir === "_workspace")
539
+ scope = "workspace";
540
+ }
541
+ try {
542
+ const resp = await client.createMemoryEntity({
543
+ workspace_id: fileWorkspaceId,
544
+ project_id: fileProjectId,
545
+ type: parsed.frontmatter.type,
546
+ scope,
547
+ title: parsed.title || "Untitled",
548
+ content: parsed.content,
549
+ confidence: parsed.frontmatter.confidence,
550
+ tags: parsed.frontmatter.tags,
551
+ agent_identifier: parsed.frontmatter.agent
552
+ });
553
+ const created = resp.entity;
554
+ const dir = entityToDirectoryPath(created, memoryDir);
555
+ if (!existsSync(dir))
556
+ mkdirSync(dir, { recursive: true });
557
+ const newFilename = entityToFilename(created);
558
+ const newAbsPath = join(dir, newFilename);
559
+ const newMarkdown = serializeSyncMarkdown(created);
560
+ writeFileSync(newAbsPath, newMarkdown);
561
+ if (absPath !== newAbsPath && existsSync(absPath)) {
562
+ rmSync(absPath);
563
+ }
564
+ const newRelPath = relative(memoryDir, newAbsPath);
565
+ state.entities[created.id] = {
566
+ filePath: newRelPath,
567
+ remoteUpdatedAt: created.updated_at,
568
+ lastSyncedHash: computeFileHash(newMarkdown)
569
+ };
570
+ result.pushed++;
571
+ } catch (err) {
572
+ result.errors.push(`Failed to create entity from ${relPath}: ${err}`);
573
+ }
254
574
  }
255
- // 3. Confidence as a quality signal
256
- score += entity.confidence * 0.15;
257
- if (entity.confidence >= 0.9) {
258
- reasons.push("high_confidence");
259
- }
260
- // 4. Recency: decay based on last access with tier-specific half-lives
261
- if (entity.last_accessed_at) {
262
- const daysSinceAccess = (Date.now() - new Date(entity.last_accessed_at).getTime()) /
263
- (1000 * 60 * 60 * 24);
264
- const halfLife = { draft: 7, episode: 30, reference: 180 }[entity.memory_tier];
265
- const recencyScore = 0.5 ** (daysSinceAccess / halfLife) * 0.1;
266
- score += recencyScore;
267
- if (daysSinceAccess < 7)
268
- reasons.push("recently_accessed");
269
- }
270
- // 5. Access frequency (log-scaled)
271
- if (entity.access_count > 0) {
272
- const freqScore = Math.log10(entity.access_count + 1) * 0.05;
273
- score += Math.min(freqScore, 0.1);
274
- if (entity.access_count >= 5)
275
- reasons.push(`frequently_used(${entity.access_count})`);
276
- }
277
- // 6. Usefulness score from feedback loop (0-0.15 weight)
278
- const usefulnessScore = entity.metadata?.usefulness_score ?? 0;
279
- if (usefulnessScore >= 3) {
280
- const usefulnessBoost = Math.min(usefulnessScore / 20, 0.15);
281
- score += usefulnessBoost;
282
- reasons.push(`useful(${usefulnessScore})`);
575
+ }
576
+ saveSyncState(memoryDir, state);
577
+ return result;
578
+ }
579
+ async function syncFull(client, config, workspaceId, projectId) {
580
+ const pullResult = await syncPull(client, config, workspaceId, projectId);
581
+ const pushResult = await syncPush(client, config, workspaceId);
582
+ return {
583
+ pulled: pullResult.pulled,
584
+ pushed: pushResult.pushed,
585
+ deleted: pullResult.deleted,
586
+ conflicts: pullResult.conflicts,
587
+ errors: [...pullResult.errors, ...pushResult.errors]
588
+ };
589
+ }
590
+ var init_sync = () => {};
591
+
592
+ // ../memory/dist/index.js
593
+ var init_dist = __esm(() => {
594
+ init_client();
595
+ init_constraints();
596
+ init_lifecycle();
597
+ init_schema();
598
+ init_sync();
599
+ });
600
+
601
+ // src/context-assembly.ts
602
+ var exports_context_assembly = {};
603
+ __export(exports_context_assembly, {
604
+ trackSessionAssembly: () => trackSessionAssembly,
605
+ recordContextFeedback: () => recordContextFeedback,
606
+ mapToContextEntity: () => mapToContextEntity,
607
+ getSessionAssemblyId: () => getSessionAssemblyId,
608
+ getCachedManifest: () => getCachedManifest,
609
+ expandQuery: () => expandQuery,
610
+ computeRelevanceScore: () => computeRelevanceScore,
611
+ cacheManifest: () => cacheManifest,
612
+ assembleContext: () => assembleContext
613
+ });
614
+ function estimateTokens(text) {
615
+ return Math.ceil(text.length / 4);
616
+ }
617
+ function passesQualityGate(entity) {
618
+ const content = entity.content.trim();
619
+ if (content.length < 50)
620
+ return false;
621
+ const normalizedTitle = entity.title.toLowerCase().replace(/[^a-z0-9\s]/g, "").trim();
622
+ const normalizedContent = content.toLowerCase().replace(/[^a-z0-9\s]/g, "").trim();
623
+ if (normalizedContent.length < normalizedTitle.length * 1.5) {
624
+ return false;
625
+ }
626
+ if (entity.type === "pattern" && /recurring .+ \(\d+ instances\)/i.test(entity.title)) {
627
+ const lines = content.split(`
628
+ `).filter((l) => l.trim().length > 0);
629
+ const bulletLines = lines.filter((l) => l.trim().startsWith("- "));
630
+ if (bulletLines.length > lines.length * 0.6)
631
+ return false;
632
+ }
633
+ if (entity.type === "procedure") {
634
+ const stepCount = (content.match(/^\d+\.\s/gm) || []).length;
635
+ if (stepCount < 3)
636
+ return false;
637
+ }
638
+ return true;
639
+ }
640
+ function generateAssemblyId() {
641
+ return `ctx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
642
+ }
643
+ function truncateContent(content, maxTokens) {
644
+ const currentTokens = estimateTokens(content);
645
+ if (currentTokens <= maxTokens) {
646
+ return { text: content, truncated: false };
647
+ }
648
+ const paragraphs = content.split(/\n\n+/);
649
+ let result = paragraphs[0];
650
+ for (let i = 1;i < paragraphs.length; i++) {
651
+ const lines = paragraphs[i].split(`
652
+ `).filter((l) => l.startsWith("- ") || l.startsWith("* "));
653
+ if (lines.length > 0) {
654
+ const bulletSection = lines.join(`
655
+ `);
656
+ if (estimateTokens(result + `
657
+
658
+ ` + bulletSection) <= maxTokens) {
659
+ result += `
660
+
661
+ ` + bulletSection;
662
+ }
283
663
  }
284
- else if (usefulnessScore === 0 && entity.access_count >= 5) {
285
- // Accessed many times but never marked useful — slight penalty
286
- score -= 0.02;
287
- reasons.push("low_usefulness");
664
+ }
665
+ if (estimateTokens(result) > maxTokens) {
666
+ const maxChars = maxTokens * 4;
667
+ result = result.slice(0, maxChars - 3) + "...";
668
+ }
669
+ return { text: result, truncated: true };
670
+ }
671
+ function escapeRegex(str) {
672
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
673
+ }
674
+ function expandQuery(taskContext) {
675
+ const queries = [taskContext];
676
+ const lowerQueries = [taskContext.toLowerCase()];
677
+ const words = taskContext.toLowerCase().split(/\W+/).filter((w) => w.length > 2);
678
+ const expandableWords = words.filter((w) => QUERY_SYNONYMS[w]);
679
+ for (const word of expandableWords) {
680
+ const synonyms = QUERY_SYNONYMS[word];
681
+ if (!synonyms)
682
+ continue;
683
+ const variation = taskContext.replace(new RegExp(`\\b${escapeRegex(word)}\\b`, "gi"), synonyms[0]);
684
+ const lowerVariation = variation.toLowerCase();
685
+ if (lowerVariation !== taskContext.toLowerCase() && !lowerQueries.includes(lowerVariation)) {
686
+ queries.push(variation);
687
+ lowerQueries.push(lowerVariation);
288
688
  }
289
- // Procedure boost: actionable step-by-step instructions are highly valuable
290
- if (entity.type === "procedure") {
291
- score += 0.1;
292
- reasons.push("procedure_boost");
689
+ if (queries.length >= MAX_QUERY_VARIATIONS)
690
+ break;
691
+ }
692
+ if (words.length >= 3) {
693
+ const keyPhrases = words.filter((w) => ![
694
+ "the",
695
+ "and",
696
+ "for",
697
+ "with",
698
+ "this",
699
+ "that",
700
+ "from",
701
+ "into"
702
+ ].includes(w)).slice(0, 4).join(" ");
703
+ if (!lowerQueries.includes(keyPhrases)) {
704
+ queries.push(keyPhrases);
293
705
  }
294
- // 7. Graph walk relation bonus: boost entities discovered via knowledge graph
295
- if (graphRelations && graphRelations.length > 0) {
296
- const entityRelations = graphRelations.filter((r) => r.source_id === entity.id || r.target_id === entity.id);
297
- if (entityRelations.length > 0) {
298
- // Take the highest relation bonus (don't stack all of them)
299
- let bestBonus = 0;
300
- let bestRelType = "";
301
- for (const rel of entityRelations) {
302
- const bonus = RELATION_BONUSES[rel.relation_type] ?? 0.1;
303
- if (bonus > bestBonus) {
304
- bestBonus = bonus;
305
- bestRelType = rel.relation_type;
306
- }
307
- }
308
- score += bestBonus;
309
- reasons.push(`graph_walk(${bestRelType})`);
310
- }
706
+ }
707
+ return queries.slice(0, MAX_QUERY_VARIATIONS);
708
+ }
709
+ function computeRelevanceScore(entity, taskContext, cardLabels, graphRelations) {
710
+ const reasons = [];
711
+ let score = 0;
712
+ const hasRrfScore = entity.rrf_score !== undefined && entity.rrf_score > 0;
713
+ if (hasRrfScore) {
714
+ const normalizedRrf = Math.min(entity.rrf_score / 0.04, 1);
715
+ const rrfContribution = normalizedRrf * 0.3;
716
+ score += rrfContribution;
717
+ reasons.push(`hybrid_search(rrf=${entity.rrf_score.toFixed(4)})`);
718
+ }
719
+ const textMatchWeight = hasRrfScore ? 0.15 : 0.4;
720
+ const taskWords = new Set(taskContext.toLowerCase().split(/\W+/).filter((w) => w.length > 2));
721
+ const entityWords = new Set(`${entity.title} ${entity.content}`.toLowerCase().split(/\W+/).filter((w) => w.length > 2));
722
+ const overlap = [...taskWords].filter((w) => entityWords.has(w));
723
+ if (overlap.length > 0) {
724
+ const textScore = Math.min(overlap.length / Math.max(taskWords.size, 1), 1) * textMatchWeight;
725
+ score += textScore;
726
+ reasons.push(`text_match(${overlap.length} words)`);
727
+ }
728
+ if (cardLabels.length > 0 && entity.tags.length > 0) {
729
+ const labelSet = new Set(cardLabels.map((l) => l.toLowerCase()));
730
+ const tagOverlap = entity.tags.filter((t) => labelSet.has(t.toLowerCase()));
731
+ if (tagOverlap.length > 0) {
732
+ const tagScore = tagOverlap.length / cardLabels.length * 0.3;
733
+ score += tagScore;
734
+ reasons.push(`tag_match(${tagOverlap.join(",")})`);
311
735
  }
312
- // Clamp raw score to 0-1 range before applying tier weight
313
- score = Math.max(0, Math.min(score, 1.0));
314
- // Apply tier weight
315
- const tierWeight = TIER_WEIGHTS[entity.memory_tier];
316
- score *= tierWeight;
317
- return { score, reasons };
318
- }
319
- /**
320
- * Assemble context from knowledge graph entities with token budget management.
321
- */
322
- export async function assembleContext(options) {
323
- const { workspaceId, projectId, taskContext, cardLabels = [], tokenBudget = DEFAULT_TOKEN_BUDGET, client, graphWalkEnabled = true, queryExpansionEnabled = true, enableLlmReranking = false, rerankFn, } = options;
324
- const assemblyId = generateAssemblyId();
325
- const manifest = {
326
- assemblyId,
327
- timestamp: new Date().toISOString(),
328
- included: [],
329
- excluded: [],
330
- budgetUsed: 0,
331
- budgetTotal: tokenBudget,
332
- tierBreakdown: {
333
- draft: { count: 0, tokens: 0 },
334
- episode: { count: 0, tokens: 0 },
335
- reference: { count: 0, tokens: 0 },
336
- },
337
- };
338
- // Fetch candidate entities: search by task context (with query expansion) + list by project
339
- const candidates = [];
340
- // P1: Query expansion — search with multiple query variations to catch synonym mismatches
341
- const queries = queryExpansionEnabled
342
- ? expandQuery(taskContext)
343
- : [taskContext];
344
- const searchResults = await Promise.allSettled(queries.map((query) => client.searchMemoryEntities(workspaceId, query, {
345
- project_id: projectId,
346
- limit: 30,
347
- })));
348
- const candidateIds = new Set();
349
- for (const result of searchResults) {
350
- if (result.status !== "fulfilled")
351
- continue;
352
- if (result.value.entities?.length > 0) {
353
- for (const raw of result.value.entities) {
354
- const entity = mapToContextEntity(raw);
355
- if (!candidateIds.has(entity.id)) {
356
- candidateIds.add(entity.id);
357
- candidates.push(entity);
358
- }
359
- }
736
+ }
737
+ score += entity.confidence * 0.15;
738
+ if (entity.confidence >= 0.9) {
739
+ reasons.push("high_confidence");
740
+ }
741
+ if (entity.last_accessed_at) {
742
+ const daysSinceAccess = (Date.now() - new Date(entity.last_accessed_at).getTime()) / (1000 * 60 * 60 * 24);
743
+ const halfLife = { draft: 7, episode: 30, reference: 180 }[entity.memory_tier];
744
+ const recencyScore = 0.5 ** (daysSinceAccess / halfLife) * 0.1;
745
+ score += recencyScore;
746
+ if (daysSinceAccess < 7)
747
+ reasons.push("recently_accessed");
748
+ }
749
+ if (entity.access_count > 0) {
750
+ const freqScore = Math.log10(entity.access_count + 1) * 0.05;
751
+ score += Math.min(freqScore, 0.1);
752
+ if (entity.access_count >= 5)
753
+ reasons.push(`frequently_used(${entity.access_count})`);
754
+ }
755
+ const usefulnessScore = entity.metadata?.usefulness_score ?? 0;
756
+ if (usefulnessScore >= 3) {
757
+ const usefulnessBoost = Math.min(usefulnessScore / 20, 0.15);
758
+ score += usefulnessBoost;
759
+ reasons.push(`useful(${usefulnessScore})`);
760
+ } else if (usefulnessScore === 0 && entity.access_count >= 5) {
761
+ score -= 0.02;
762
+ reasons.push("low_usefulness");
763
+ }
764
+ if (entity.type === "procedure") {
765
+ score += 0.1;
766
+ reasons.push("procedure_boost");
767
+ }
768
+ if (graphRelations && graphRelations.length > 0) {
769
+ const entityRelations = graphRelations.filter((r) => r.source_id === entity.id || r.target_id === entity.id);
770
+ if (entityRelations.length > 0) {
771
+ let bestBonus = 0;
772
+ let bestRelType = "";
773
+ for (const rel of entityRelations) {
774
+ const bonus = RELATION_BONUSES[rel.relation_type] ?? 0.1;
775
+ if (bonus > bestBonus) {
776
+ bestBonus = bonus;
777
+ bestRelType = rel.relation_type;
360
778
  }
779
+ }
780
+ score += bestBonus;
781
+ reasons.push(`graph_walk(${bestRelType})`);
361
782
  }
362
- // Also fetch by project scope if we have few candidates
363
- if (candidates.length < 10 && projectId) {
364
- try {
365
- const listResult = await client.listMemoryEntities({
366
- workspace_id: workspaceId,
367
- project_id: projectId,
368
- limit: 30,
369
- });
370
- if (listResult.entities?.length > 0) {
371
- for (const raw of listResult.entities) {
372
- const entity = mapToContextEntity(raw);
373
- if (!candidateIds.has(entity.id)) {
374
- candidateIds.add(entity.id);
375
- candidates.push(entity);
376
- }
377
- }
378
- }
379
- }
380
- catch {
381
- // List failed, continue with what we have
382
- }
783
+ }
784
+ score = Math.max(0, Math.min(score, 1));
785
+ const tierWeight = TIER_WEIGHTS[entity.memory_tier];
786
+ score *= tierWeight;
787
+ return { score, reasons };
788
+ }
789
+ async function assembleContext(options) {
790
+ const {
791
+ workspaceId,
792
+ projectId,
793
+ taskContext,
794
+ cardLabels = [],
795
+ tokenBudget = DEFAULT_TOKEN_BUDGET,
796
+ client: client2,
797
+ graphWalkEnabled = true,
798
+ queryExpansionEnabled = true,
799
+ enableLlmReranking = false,
800
+ rerankFn
801
+ } = options;
802
+ const assemblyId = generateAssemblyId();
803
+ const manifest = {
804
+ assemblyId,
805
+ timestamp: new Date().toISOString(),
806
+ included: [],
807
+ excluded: [],
808
+ budgetUsed: 0,
809
+ budgetTotal: tokenBudget,
810
+ tierBreakdown: {
811
+ draft: { count: 0, tokens: 0 },
812
+ episode: { count: 0, tokens: 0 },
813
+ reference: { count: 0, tokens: 0 }
383
814
  }
384
- // Cross-project memory: fetch workspace-scoped entities only
385
- // This ensures shared decisions/patterns are available without leaking project-private data
386
- if (candidates.length < 20) {
387
- try {
388
- const wsResult = await client.listMemoryEntities({
389
- workspace_id: workspaceId,
390
- scope: "workspace",
391
- limit: 20,
392
- });
393
- if (wsResult.entities?.length > 0) {
394
- for (const raw of wsResult.entities) {
395
- const entity = mapToContextEntity(raw);
396
- if (!candidateIds.has(entity.id)) {
397
- candidateIds.add(entity.id);
398
- candidates.push(entity);
399
- }
400
- }
401
- }
402
- }
403
- catch {
404
- // Continue with what we have
815
+ };
816
+ const candidates = [];
817
+ const queries = queryExpansionEnabled ? expandQuery(taskContext) : [taskContext];
818
+ const searchResults = await Promise.allSettled(queries.map((query) => client2.searchMemoryEntities(workspaceId, query, {
819
+ project_id: projectId,
820
+ limit: 30
821
+ })));
822
+ const candidateIds = new Set;
823
+ for (const result of searchResults) {
824
+ if (result.status !== "fulfilled")
825
+ continue;
826
+ if (result.value.entities?.length > 0) {
827
+ for (const raw of result.value.entities) {
828
+ const entity = mapToContextEntity(raw);
829
+ if (!candidateIds.has(entity.id)) {
830
+ candidateIds.add(entity.id);
831
+ candidates.push(entity);
405
832
  }
833
+ }
406
834
  }
407
- // P0: Graph walk enrichment — discover related entities via knowledge graph
408
- let graphRelations = [];
409
- if (graphWalkEnabled && candidates.length > 0) {
410
- try {
411
- // Take top candidates by RRF score (or first N if no RRF scores)
412
- const seedCandidates = [...candidates]
413
- .sort((a, b) => (b.rrf_score ?? 0) - (a.rrf_score ?? 0))
414
- .slice(0, GRAPH_WALK_SEED_COUNT);
415
- const seedIds = seedCandidates.map((c) => c.id);
416
- const walkResult = await discoverRelatedContext(client, seedIds, GRAPH_WALK_MAX_DEPTH, GRAPH_WALK_MAX_ENTITIES, GRAPH_WALK_MIN_CONFIDENCE);
417
- graphRelations = walkResult.relations;
418
- // Add discovered entities to candidate pool (skip those already present)
419
- const newEntityIds = walkResult.entities
420
- .filter((e) => !candidateIds.has(e.id))
421
- .map((e) => e.id);
422
- if (newEntityIds.length > 0) {
423
- // Fetch full entity data in parallel (graph walk only returns summary fields)
424
- const fetchResults = await Promise.allSettled(newEntityIds.map((id) => client.getMemoryEntity(id)));
425
- for (const result of fetchResults) {
426
- if (result.status !== "fulfilled" || !result.value.entity)
427
- continue;
428
- const mapped = mapToContextEntity(result.value.entity);
429
- candidateIds.add(mapped.id);
430
- candidates.push(mapped);
431
- }
432
- }
835
+ }
836
+ if (candidates.length < 10 && projectId) {
837
+ try {
838
+ const listResult = await client2.listMemoryEntities({
839
+ workspace_id: workspaceId,
840
+ project_id: projectId,
841
+ limit: 30
842
+ });
843
+ if (listResult.entities?.length > 0) {
844
+ for (const raw of listResult.entities) {
845
+ const entity = mapToContextEntity(raw);
846
+ if (!candidateIds.has(entity.id)) {
847
+ candidateIds.add(entity.id);
848
+ candidates.push(entity);
849
+ }
433
850
  }
434
- catch {
435
- // Graph walk failed, continue with search-only candidates
851
+ }
852
+ } catch {}
853
+ }
854
+ if (candidates.length < 20) {
855
+ try {
856
+ const wsResult = await client2.listMemoryEntities({
857
+ workspace_id: workspaceId,
858
+ scope: "workspace",
859
+ limit: 20
860
+ });
861
+ if (wsResult.entities?.length > 0) {
862
+ for (const raw of wsResult.entities) {
863
+ const entity = mapToContextEntity(raw);
864
+ if (!candidateIds.has(entity.id)) {
865
+ candidateIds.add(entity.id);
866
+ candidates.push(entity);
867
+ }
436
868
  }
437
- }
438
- if (candidates.length === 0) {
439
- return {
440
- context: "",
441
- manifest,
442
- memories: [],
443
- };
444
- }
445
- // Quality gate: filter out low-value entities before scoring
446
- const qualityCandidates = candidates.filter((entity) => {
447
- if (passesQualityGate(entity))
448
- return true;
449
- manifest.excluded.push({
450
- entityId: entity.id,
451
- title: entity.title,
452
- type: entity.type,
453
- tier: entity.memory_tier,
454
- relevanceScore: 0,
455
- reason: "failed_quality_gate",
456
- });
457
- return false;
458
- });
459
- if (qualityCandidates.length === 0) {
460
- return {
461
- context: "",
462
- manifest,
463
- memories: [],
464
- };
465
- }
466
- // Score all candidates (pass graph relations for relation-type bonuses)
467
- const scored = qualityCandidates.map((entity) => {
468
- const { score, reasons } = computeRelevanceScore(entity, taskContext, cardLabels, graphRelations.length > 0 ? graphRelations : undefined);
469
- return { entity, score, reasons };
470
- });
471
- // Sort by score descending
472
- scored.sort((a, b) => b.score - a.score);
473
- // P2: Optional LLM re-ranking when top scores are clustered
474
- if (enableLlmReranking &&
475
- rerankFn &&
476
- scored.length >= RERANK_MIN_CANDIDATES) {
477
- const topN = scored.slice(0, RERANK_TOP_N);
478
- const scoreRange = topN[0].score - topN[topN.length - 1].score;
479
- // Only re-rank when scores are tightly clustered
480
- if (scoreRange <= RERANK_CLUSTER_THRESHOLD) {
481
- try {
482
- const rerankCandidates = topN.map((s) => ({
483
- id: s.entity.id,
484
- title: s.entity.title,
485
- snippet: s.entity.content.slice(0, 200),
486
- }));
487
- const rerankedIds = await rerankFn(taskContext, rerankCandidates);
488
- // Reorder based on LLM ranking
489
- const idOrder = new Map(rerankedIds.map((id, i) => [id, i]));
490
- topN.sort((a, b) => {
491
- const aIdx = idOrder.get(a.entity.id) ?? 999;
492
- const bIdx = idOrder.get(b.entity.id) ?? 999;
493
- return aIdx - bIdx;
494
- });
495
- // Splice reranked items back in
496
- scored.splice(0, topN.length, ...topN);
497
- }
498
- catch {
499
- // Re-ranking failed, continue with static ordering
500
- }
869
+ }
870
+ } catch {}
871
+ }
872
+ let graphRelations = [];
873
+ if (graphWalkEnabled && candidates.length > 0) {
874
+ try {
875
+ const seedCandidates = [...candidates].sort((a, b) => (b.rrf_score ?? 0) - (a.rrf_score ?? 0)).slice(0, GRAPH_WALK_SEED_COUNT);
876
+ const seedIds = seedCandidates.map((c) => c.id);
877
+ const walkResult = await discoverRelatedContext(client2, seedIds, GRAPH_WALK_MAX_DEPTH, GRAPH_WALK_MAX_ENTITIES, GRAPH_WALK_MIN_CONFIDENCE);
878
+ graphRelations = walkResult.relations;
879
+ const newEntityIds = walkResult.entities.filter((e) => !candidateIds.has(e.id)).map((e) => e.id);
880
+ if (newEntityIds.length > 0) {
881
+ const fetchResults = await Promise.allSettled(newEntityIds.map((id) => client2.getMemoryEntity(id)));
882
+ for (const result of fetchResults) {
883
+ if (result.status !== "fulfilled" || !result.value.entity)
884
+ continue;
885
+ const mapped = mapToContextEntity(result.value.entity);
886
+ candidateIds.add(mapped.id);
887
+ candidates.push(mapped);
501
888
  }
502
- }
503
- // Reserve dedicated procedure budget, allocate remaining to tiers
504
- const procedureBudget = Math.floor(tokenBudget * PROCEDURE_BUDGET_FRACTION);
505
- const remainingBudget = tokenBudget - procedureBudget;
506
- const tierBudgets = {
507
- reference: Math.floor(remainingBudget * TIER_BUDGET_ALLOCATION.reference),
508
- episode: Math.floor(remainingBudget * TIER_BUDGET_ALLOCATION.episode),
509
- draft: Math.floor(remainingBudget * TIER_BUDGET_ALLOCATION.draft),
889
+ }
890
+ } catch {}
891
+ }
892
+ if (candidates.length === 0) {
893
+ return {
894
+ context: "",
895
+ manifest,
896
+ memories: []
510
897
  };
511
- const tierUsed = {
512
- reference: 0,
513
- episode: 0,
514
- draft: 0,
898
+ }
899
+ const qualityCandidates = candidates.filter((entity) => {
900
+ if (passesQualityGate(entity))
901
+ return true;
902
+ manifest.excluded.push({
903
+ entityId: entity.id,
904
+ title: entity.title,
905
+ type: entity.type,
906
+ tier: entity.memory_tier,
907
+ relevanceScore: 0,
908
+ reason: "failed_quality_gate"
909
+ });
910
+ return false;
911
+ });
912
+ if (qualityCandidates.length === 0) {
913
+ return {
914
+ context: "",
915
+ manifest,
916
+ memories: []
515
917
  };
516
- let procedureUsed = 0;
517
- const included = [];
518
- let totalUsed = 0;
519
- // First pass: guarantee minimum reference slots
520
- let referenceCount = 0;
521
- for (const item of scored) {
522
- if (item.entity.memory_tier === "reference" &&
523
- item.entity.type !== "procedure" &&
524
- referenceCount < MIN_REFERENCE_SLOTS) {
525
- const { text, truncated } = truncateContent(item.entity.content, MAX_TOKENS_PER_ENTITY);
526
- const tokens = estimateTokens(`### ${item.entity.title}\n${text}`);
527
- if (totalUsed + tokens <= tokenBudget) {
528
- included.push({ ...item, tokens, truncated });
529
- item.entity.content = text;
530
- totalUsed += tokens;
531
- tierUsed.reference += tokens;
532
- referenceCount++;
533
- }
534
- }
918
+ }
919
+ const scored = qualityCandidates.map((entity) => {
920
+ const { score, reasons } = computeRelevanceScore(entity, taskContext, cardLabels, graphRelations.length > 0 ? graphRelations : undefined);
921
+ return { entity, score, reasons };
922
+ });
923
+ scored.sort((a, b) => b.score - a.score);
924
+ if (enableLlmReranking && rerankFn && scored.length >= RERANK_MIN_CANDIDATES) {
925
+ const topN = scored.slice(0, RERANK_TOP_N);
926
+ const scoreRange = topN[0].score - topN[topN.length - 1].score;
927
+ if (scoreRange <= RERANK_CLUSTER_THRESHOLD) {
928
+ try {
929
+ const rerankCandidates = topN.map((s) => ({
930
+ id: s.entity.id,
931
+ title: s.entity.title,
932
+ snippet: s.entity.content.slice(0, 200)
933
+ }));
934
+ const rerankedIds = await rerankFn(taskContext, rerankCandidates);
935
+ const idOrder = new Map(rerankedIds.map((id, i) => [id, i]));
936
+ topN.sort((a, b) => {
937
+ const aIdx = idOrder.get(a.entity.id) ?? 999;
938
+ const bIdx = idOrder.get(b.entity.id) ?? 999;
939
+ return aIdx - bIdx;
940
+ });
941
+ scored.splice(0, topN.length, ...topN);
942
+ } catch {}
535
943
  }
536
- // Second pass: include procedure entities with dedicated budget
537
- const includedIds = new Set(included.map((i) => i.entity.id));
538
- const procedureCandidates = scored.filter((item) => item.entity.type === "procedure" && !includedIds.has(item.entity.id));
539
- for (const item of procedureCandidates) {
540
- if (item.score < MIN_RELEVANCE_THRESHOLD) {
541
- manifest.excluded.push({
542
- entityId: item.entity.id,
543
- title: item.entity.title,
544
- type: item.entity.type,
545
- tier: item.entity.memory_tier,
546
- relevanceScore: item.score,
547
- reason: "below_relevance_threshold",
548
- });
549
- continue;
550
- }
551
- const { text, truncated } = truncateContent(item.entity.content, MAX_TOKENS_PER_ENTITY);
552
- const tokens = estimateTokens(`### ${item.entity.title}\n${text}`);
553
- // Check dedicated procedure budget, allow overflow to total remaining
554
- if (procedureUsed + tokens > procedureBudget) {
555
- const totalRemaining = tokenBudget - totalUsed;
556
- if (tokens > totalRemaining) {
557
- manifest.excluded.push({
558
- entityId: item.entity.id,
559
- title: item.entity.title,
560
- type: item.entity.type,
561
- tier: item.entity.memory_tier,
562
- relevanceScore: item.score,
563
- reason: "procedure_budget_exceeded",
564
- });
565
- continue;
566
- }
567
- }
568
- if (totalUsed + tokens > tokenBudget) {
569
- manifest.excluded.push({
570
- entityId: item.entity.id,
571
- title: item.entity.title,
572
- type: item.entity.type,
573
- tier: item.entity.memory_tier,
574
- relevanceScore: item.score,
575
- reason: "total_budget_exceeded",
576
- });
577
- continue;
578
- }
944
+ }
945
+ const procedureBudget = Math.floor(tokenBudget * PROCEDURE_BUDGET_FRACTION);
946
+ const remainingBudget = tokenBudget - procedureBudget;
947
+ const tierBudgets = {
948
+ reference: Math.floor(remainingBudget * TIER_BUDGET_ALLOCATION.reference),
949
+ episode: Math.floor(remainingBudget * TIER_BUDGET_ALLOCATION.episode),
950
+ draft: Math.floor(remainingBudget * TIER_BUDGET_ALLOCATION.draft)
951
+ };
952
+ const tierUsed = {
953
+ reference: 0,
954
+ episode: 0,
955
+ draft: 0
956
+ };
957
+ let procedureUsed = 0;
958
+ const included = [];
959
+ let totalUsed = 0;
960
+ let referenceCount = 0;
961
+ for (const item of scored) {
962
+ if (item.entity.memory_tier === "reference" && item.entity.type !== "procedure" && referenceCount < MIN_REFERENCE_SLOTS) {
963
+ const { text, truncated } = truncateContent(item.entity.content, MAX_TOKENS_PER_ENTITY);
964
+ const tokens = estimateTokens(`### ${item.entity.title}
965
+ ${text}`);
966
+ if (totalUsed + tokens <= tokenBudget) {
579
967
  included.push({ ...item, tokens, truncated });
580
968
  item.entity.content = text;
581
969
  totalUsed += tokens;
582
- procedureUsed += tokens;
583
- includedIds.add(item.entity.id);
970
+ tierUsed.reference += tokens;
971
+ referenceCount++;
972
+ }
584
973
  }
585
- // Third pass: fill remaining budget by score (non-procedure entities)
586
- for (const item of scored) {
587
- if (includedIds.has(item.entity.id))
588
- continue;
589
- if (item.entity.type === "procedure")
590
- continue; // Already handled
591
- if (item.score < MIN_RELEVANCE_THRESHOLD) {
592
- manifest.excluded.push({
593
- entityId: item.entity.id,
594
- title: item.entity.title,
595
- type: item.entity.type,
596
- tier: item.entity.memory_tier,
597
- relevanceScore: item.score,
598
- reason: "below_relevance_threshold",
599
- });
600
- continue;
601
- }
602
- const tier = item.entity.memory_tier;
603
- const { text, truncated } = truncateContent(item.entity.content, MAX_TOKENS_PER_ENTITY);
604
- const tokens = estimateTokens(`### ${item.entity.title}\n${text}`);
605
- // Check tier budget (allow overflow to unused tiers)
606
- if (tierUsed[tier] + tokens > tierBudgets[tier]) {
607
- // Check if there's unused budget from other tiers
608
- const totalRemaining = tokenBudget - totalUsed;
609
- if (tokens > totalRemaining) {
610
- manifest.excluded.push({
611
- entityId: item.entity.id,
612
- title: item.entity.title,
613
- type: item.entity.type,
614
- tier,
615
- relevanceScore: item.score,
616
- reason: "budget_exceeded",
617
- });
618
- continue;
619
- }
620
- }
621
- if (totalUsed + tokens > tokenBudget) {
622
- manifest.excluded.push({
623
- entityId: item.entity.id,
624
- title: item.entity.title,
625
- type: item.entity.type,
626
- tier,
627
- relevanceScore: item.score,
628
- reason: "total_budget_exceeded",
629
- });
630
- continue;
631
- }
632
- included.push({ ...item, tokens, truncated });
633
- item.entity.content = text;
634
- totalUsed += tokens;
635
- tierUsed[tier] += tokens;
636
- includedIds.add(item.entity.id);
974
+ }
975
+ const includedIds = new Set(included.map((i) => i.entity.id));
976
+ const procedureCandidates = scored.filter((item) => item.entity.type === "procedure" && !includedIds.has(item.entity.id));
977
+ for (const item of procedureCandidates) {
978
+ if (item.score < MIN_RELEVANCE_THRESHOLD) {
979
+ manifest.excluded.push({
980
+ entityId: item.entity.id,
981
+ title: item.entity.title,
982
+ type: item.entity.type,
983
+ tier: item.entity.memory_tier,
984
+ relevanceScore: item.score,
985
+ reason: "below_relevance_threshold"
986
+ });
987
+ continue;
637
988
  }
638
- // Build manifest
639
- manifest.budgetUsed = totalUsed;
640
- const procedureItems = included.filter((i) => i.entity.type === "procedure");
641
- manifest.tierBreakdown = {
642
- reference: {
643
- count: included.filter((i) => i.entity.memory_tier === "reference" && i.entity.type !== "procedure").length,
644
- tokens: tierUsed.reference,
645
- },
646
- episode: {
647
- count: included.filter((i) => i.entity.memory_tier === "episode" && i.entity.type !== "procedure").length,
648
- tokens: tierUsed.episode,
649
- },
650
- draft: {
651
- count: included.filter((i) => i.entity.memory_tier === "draft" && i.entity.type !== "procedure").length,
652
- tokens: tierUsed.draft,
653
- },
654
- };
655
- manifest.procedureBreakdown = {
656
- count: procedureItems.length,
657
- tokens: procedureUsed,
658
- budget: procedureBudget,
659
- };
660
- for (const item of included) {
661
- manifest.included.push({
662
- entityId: item.entity.id,
663
- title: item.entity.title,
664
- type: item.entity.type,
665
- tier: item.entity.memory_tier,
666
- relevanceScore: item.score,
667
- reasons: item.reasons,
668
- tokenCount: item.tokens,
669
- truncated: item.truncated,
989
+ const { text, truncated } = truncateContent(item.entity.content, MAX_TOKENS_PER_ENTITY);
990
+ const tokens = estimateTokens(`### ${item.entity.title}
991
+ ${text}`);
992
+ if (procedureUsed + tokens > procedureBudget) {
993
+ const totalRemaining = tokenBudget - totalUsed;
994
+ if (tokens > totalRemaining) {
995
+ manifest.excluded.push({
996
+ entityId: item.entity.id,
997
+ title: item.entity.title,
998
+ type: item.entity.type,
999
+ tier: item.entity.memory_tier,
1000
+ relevanceScore: item.score,
1001
+ reason: "procedure_budget_exceeded"
670
1002
  });
1003
+ continue;
1004
+ }
671
1005
  }
672
- // Build context string procedures in their own section
673
- const contextSections = [];
674
- const nonProcedureItems = included.filter((i) => i.entity.type !== "procedure");
675
- if (included.length > 0) {
676
- // Procedure section first (actionable instructions)
677
- if (procedureItems.length > 0) {
678
- contextSections.push(`## Procedures (${procedureItems.length} loaded, ${procedureUsed}/${procedureBudget} tokens)`);
679
- for (const item of procedureItems) {
680
- const tags = item.entity.tags.length > 0
681
- ? ` [${item.entity.tags.join(", ")}]`
682
- : "";
683
- const tierLabel = item.entity.memory_tier !== "reference"
684
- ? ` (${item.entity.memory_tier})`
685
- : "";
686
- contextSections.push(`\n### ${item.entity.title} (confidence: ${item.entity.confidence})${tierLabel}${tags}`);
687
- contextSections.push(item.entity.content);
688
- }
689
- }
690
- // Non-procedure memories
691
- if (nonProcedureItems.length > 0) {
692
- contextSections.push(`\n## Relevant Memories (${nonProcedureItems.length} loaded, ${manifest.excluded.length} excluded)`);
693
- contextSections.push(`*Assembly: ${assemblyId} | Budget: ${totalUsed}/${tokenBudget} tokens*`);
694
- for (const item of nonProcedureItems) {
695
- const tags = item.entity.tags.length > 0
696
- ? ` [${item.entity.tags.join(", ")}]`
697
- : "";
698
- const tierLabel = item.entity.memory_tier !== "reference"
699
- ? ` (${item.entity.memory_tier})`
700
- : "";
701
- contextSections.push(`\n### ${item.entity.title} (${item.entity.type}, confidence: ${item.entity.confidence})${tierLabel}${tags}`);
702
- contextSections.push(item.entity.content);
703
- }
704
- }
1006
+ if (totalUsed + tokens > tokenBudget) {
1007
+ manifest.excluded.push({
1008
+ entityId: item.entity.id,
1009
+ title: item.entity.title,
1010
+ type: item.entity.type,
1011
+ tier: item.entity.memory_tier,
1012
+ relevanceScore: item.score,
1013
+ reason: "total_budget_exceeded"
1014
+ });
1015
+ continue;
705
1016
  }
706
- // Increment access_count for included entities (fire-and-forget)
707
- incrementAccessCounts(client, included.map((i) => i.entity.id)).catch(() => { });
708
- // Auto-promote entities that cross access thresholds after the bump (fire-and-forget)
709
- promoteEligibleEntities(client, included.map((i) => i.entity)).catch(() => { });
710
- return {
711
- context: contextSections.join("\n"),
712
- manifest,
713
- memories: included.map((i) => i.entity),
714
- };
715
- }
716
- /**
717
- * Map raw API entity to ContextEntity
718
- */
719
- export function mapToContextEntity(raw) {
720
- const e = raw;
721
- return {
722
- id: e.id,
723
- type: e.type,
724
- title: e.title,
725
- content: e.content,
726
- confidence: e.confidence ?? 1.0,
727
- tags: e.tags || [],
728
- memory_tier: e.memory_tier || "reference",
729
- access_count: e.access_count || 0,
730
- last_accessed_at: e.last_accessed_at || null,
731
- created_at: e.created_at || "",
732
- updated_at: e.updated_at || "",
733
- metadata: e.metadata ?? undefined,
734
- // Hybrid search signals (present when results come from RPC)
735
- rrf_score: e.rrf_score ?? undefined,
736
- fts_rank: e.fts_rank ?? undefined,
737
- semantic_rank: e.semantic_rank ?? undefined,
738
- };
739
- }
740
- /**
741
- * Increment access counts for entities loaded into context.
742
- * Uses batch_touch_knowledge_entities RPC for a single-roundtrip update.
743
- * Falls back to individual touches if the batch endpoint is unavailable.
744
- */
745
- async function incrementAccessCounts(client, entityIds) {
746
- if (entityIds.length === 0)
747
- return;
748
- try {
749
- await client.batchTouchMemoryEntities(entityIds);
1017
+ included.push({ ...item, tokens, truncated });
1018
+ item.entity.content = text;
1019
+ totalUsed += tokens;
1020
+ procedureUsed += tokens;
1021
+ includedIds.add(item.entity.id);
1022
+ }
1023
+ for (const item of scored) {
1024
+ if (includedIds.has(item.entity.id))
1025
+ continue;
1026
+ if (item.entity.type === "procedure")
1027
+ continue;
1028
+ if (item.score < MIN_RELEVANCE_THRESHOLD) {
1029
+ manifest.excluded.push({
1030
+ entityId: item.entity.id,
1031
+ title: item.entity.title,
1032
+ type: item.entity.type,
1033
+ tier: item.entity.memory_tier,
1034
+ relevanceScore: item.score,
1035
+ reason: "below_relevance_threshold"
1036
+ });
1037
+ continue;
750
1038
  }
751
- catch {
752
- // Fallback: individual touches (e.g. older server version)
753
- await Promise.allSettled(entityIds.map((id) => client.touchMemoryEntity(id)));
1039
+ const tier = item.entity.memory_tier;
1040
+ const { text, truncated } = truncateContent(item.entity.content, MAX_TOKENS_PER_ENTITY);
1041
+ const tokens = estimateTokens(`### ${item.entity.title}
1042
+ ${text}`);
1043
+ if (tierUsed[tier] + tokens > tierBudgets[tier]) {
1044
+ const totalRemaining = tokenBudget - totalUsed;
1045
+ if (tokens > totalRemaining) {
1046
+ manifest.excluded.push({
1047
+ entityId: item.entity.id,
1048
+ title: item.entity.title,
1049
+ type: item.entity.type,
1050
+ tier,
1051
+ relevanceScore: item.score,
1052
+ reason: "budget_exceeded"
1053
+ });
1054
+ continue;
1055
+ }
754
1056
  }
755
- }
756
- /**
757
- * Check included entities for promotion eligibility after access count bump.
758
- * Uses access_count + 1 to reflect the touch that just happened.
759
- */
760
- async function promoteEligibleEntities(client, entities) {
761
- for (const entity of entities) {
762
- if (entity.memory_tier === "reference")
763
- continue;
764
- if (!entity.created_at)
765
- continue;
766
- // +1 because incrementAccessCounts just bumped it
767
- const promotion = checkPromotion(entity.memory_tier, entity.access_count + 1, entity.confidence, entity.created_at);
768
- if (promotion.eligible && promotion.targetTier) {
769
- try {
770
- await client.updateMemoryEntity(entity.id, {
771
- memory_tier: promotion.targetTier,
772
- metadata: {
773
- ...(entity.metadata || {}),
774
- promoted_at: new Date().toISOString(),
775
- promotion_reason: promotion.reason,
776
- promoted_from: entity.memory_tier,
777
- },
778
- });
779
- }
780
- catch {
781
- // Non-fatal: promotion is best-effort
782
- }
783
- }
1057
+ if (totalUsed + tokens > tokenBudget) {
1058
+ manifest.excluded.push({
1059
+ entityId: item.entity.id,
1060
+ title: item.entity.title,
1061
+ type: item.entity.type,
1062
+ tier,
1063
+ relevanceScore: item.score,
1064
+ reason: "total_budget_exceeded"
1065
+ });
1066
+ continue;
784
1067
  }
785
- }
786
- // In-memory manifest cache (keyed by assemblyId)
787
- const manifestCache = new Map();
788
- const MAX_CACHE_SIZE = 50;
789
- /**
790
- * Store a manifest for later retrieval.
791
- */
792
- export function cacheManifest(manifest) {
793
- if (manifestCache.size >= MAX_CACHE_SIZE) {
794
- // Remove oldest entry
795
- const firstKey = manifestCache.keys().next().value;
796
- if (firstKey)
797
- manifestCache.delete(firstKey);
1068
+ included.push({ ...item, tokens, truncated });
1069
+ item.entity.content = text;
1070
+ totalUsed += tokens;
1071
+ tierUsed[tier] += tokens;
1072
+ includedIds.add(item.entity.id);
1073
+ }
1074
+ manifest.budgetUsed = totalUsed;
1075
+ const procedureItems = included.filter((i) => i.entity.type === "procedure");
1076
+ manifest.tierBreakdown = {
1077
+ reference: {
1078
+ count: included.filter((i) => i.entity.memory_tier === "reference" && i.entity.type !== "procedure").length,
1079
+ tokens: tierUsed.reference
1080
+ },
1081
+ episode: {
1082
+ count: included.filter((i) => i.entity.memory_tier === "episode" && i.entity.type !== "procedure").length,
1083
+ tokens: tierUsed.episode
1084
+ },
1085
+ draft: {
1086
+ count: included.filter((i) => i.entity.memory_tier === "draft" && i.entity.type !== "procedure").length,
1087
+ tokens: tierUsed.draft
798
1088
  }
799
- manifestCache.set(manifest.assemblyId, manifest);
800
- }
801
- /**
802
- * Retrieve a cached manifest by assembly ID.
803
- */
804
- export function getCachedManifest(assemblyId) {
805
- return manifestCache.get(assemblyId);
806
- }
807
- // --- Feedback-Driven Scoring ---
808
- /** Track which assemblyId was used for which card session */
809
- const sessionAssemblyMap = new Map();
810
- const MAX_SESSION_MAP_SIZE = 100;
811
- /**
812
- * Associate an assemblyId with a card session for later feedback.
813
- * Called when context is assembled during session start or prompt generation.
814
- */
815
- export function trackSessionAssembly(cardId, assemblyId) {
816
- if (sessionAssemblyMap.size >= MAX_SESSION_MAP_SIZE) {
817
- const firstKey = sessionAssemblyMap.keys().next().value;
818
- if (firstKey)
819
- sessionAssemblyMap.delete(firstKey);
1089
+ };
1090
+ manifest.procedureBreakdown = {
1091
+ count: procedureItems.length,
1092
+ tokens: procedureUsed,
1093
+ budget: procedureBudget
1094
+ };
1095
+ for (const item of included) {
1096
+ manifest.included.push({
1097
+ entityId: item.entity.id,
1098
+ title: item.entity.title,
1099
+ type: item.entity.type,
1100
+ tier: item.entity.memory_tier,
1101
+ relevanceScore: item.score,
1102
+ reasons: item.reasons,
1103
+ tokenCount: item.tokens,
1104
+ truncated: item.truncated
1105
+ });
1106
+ }
1107
+ const contextSections = [];
1108
+ const nonProcedureItems = included.filter((i) => i.entity.type !== "procedure");
1109
+ if (included.length > 0) {
1110
+ if (procedureItems.length > 0) {
1111
+ contextSections.push(`## Procedures (${procedureItems.length} loaded, ${procedureUsed}/${procedureBudget} tokens)`);
1112
+ for (const item of procedureItems) {
1113
+ const tags = item.entity.tags.length > 0 ? ` [${item.entity.tags.join(", ")}]` : "";
1114
+ const tierLabel = item.entity.memory_tier !== "reference" ? ` (${item.entity.memory_tier})` : "";
1115
+ contextSections.push(`
1116
+ ### ${item.entity.title} (confidence: ${item.entity.confidence})${tierLabel}${tags}`);
1117
+ contextSections.push(item.entity.content);
1118
+ }
820
1119
  }
821
- sessionAssemblyMap.set(cardId, assemblyId);
822
- }
823
- /**
824
- * Get the assemblyId associated with a card session.
825
- */
826
- export function getSessionAssemblyId(cardId) {
827
- return sessionAssemblyMap.get(cardId);
828
- }
829
- /**
830
- * Record context feedback based on session outcome.
831
- * Adjusts entity confidence based on whether the session completed successfully.
832
- *
833
- * - Completed successfully (status=completed, progress>=100): boost included entities
834
- * - Paused/blocked: neutral or slight penalty for included entities
835
- */
836
- export async function recordContextFeedback(client, cardId, sessionStatus, progressPercent, hadBlockers) {
837
- const assemblyId = sessionAssemblyMap.get(cardId);
838
- if (!assemblyId)
839
- return { adjusted: 0 };
840
- const manifest = manifestCache.get(assemblyId);
841
- if (!manifest || manifest.included.length === 0)
842
- return { adjusted: 0 };
843
- let adjusted = 0;
844
- const isSuccess = sessionStatus === "completed" && (progressPercent ?? 0) >= 100;
845
- for (const entry of manifest.included) {
846
- try {
847
- if (isSuccess) {
848
- // Boost confidence by +0.05 (max 1.0) and increment usefulness_score
849
- const { entity } = await client.getMemoryEntity(entry.entityId);
850
- const e = entity;
851
- const currentUsefulness = e.metadata?.usefulness_score ?? 0;
852
- const newConfidence = Math.min((e.confidence ?? 0.5) + 0.05, 1.0);
853
- await client.updateMemoryEntity(entry.entityId, {
854
- confidence: newConfidence,
855
- metadata: {
856
- usefulness_score: currentUsefulness + 1,
857
- last_feedback_at: new Date().toISOString(),
858
- },
859
- });
860
- adjusted++;
861
- }
862
- else if (hadBlockers) {
863
- // Slight penalty for entities included when session had blockers
864
- const { entity } = await client.getMemoryEntity(entry.entityId);
865
- const e = entity;
866
- const newConfidence = Math.max((e.confidence ?? 0.5) - 0.02, 0.1);
867
- await client.updateMemoryEntity(entry.entityId, {
868
- confidence: newConfidence,
869
- metadata: {
870
- last_feedback_at: new Date().toISOString(),
871
- },
872
- });
873
- adjusted++;
874
- }
875
- // Paused without blockers: no change (neutral signal)
876
- }
877
- catch {
878
- // Non-fatal: individual entity update failure
879
- }
1120
+ if (nonProcedureItems.length > 0) {
1121
+ contextSections.push(`
1122
+ ## Relevant Memories (${nonProcedureItems.length} loaded, ${manifest.excluded.length} excluded)`);
1123
+ contextSections.push(`*Assembly: ${assemblyId} | Budget: ${totalUsed}/${tokenBudget} tokens*`);
1124
+ for (const item of nonProcedureItems) {
1125
+ const tags = item.entity.tags.length > 0 ? ` [${item.entity.tags.join(", ")}]` : "";
1126
+ const tierLabel = item.entity.memory_tier !== "reference" ? ` (${item.entity.memory_tier})` : "";
1127
+ contextSections.push(`
1128
+ ### ${item.entity.title} (${item.entity.type}, confidence: ${item.entity.confidence})${tierLabel}${tags}`);
1129
+ contextSections.push(item.entity.content);
1130
+ }
1131
+ }
1132
+ }
1133
+ incrementAccessCounts(client2, included.map((i) => i.entity.id)).catch(() => {});
1134
+ promoteEligibleEntities(client2, included.map((i) => i.entity)).catch(() => {});
1135
+ return {
1136
+ context: contextSections.join(`
1137
+ `),
1138
+ manifest,
1139
+ memories: included.map((i) => i.entity)
1140
+ };
1141
+ }
1142
+ function mapToContextEntity(raw) {
1143
+ const e = raw;
1144
+ return {
1145
+ id: e.id,
1146
+ type: e.type,
1147
+ title: e.title,
1148
+ content: e.content,
1149
+ confidence: e.confidence ?? 1,
1150
+ tags: e.tags || [],
1151
+ memory_tier: e.memory_tier || "reference",
1152
+ access_count: e.access_count || 0,
1153
+ last_accessed_at: e.last_accessed_at || null,
1154
+ created_at: e.created_at || "",
1155
+ updated_at: e.updated_at || "",
1156
+ metadata: e.metadata ?? undefined,
1157
+ rrf_score: e.rrf_score ?? undefined,
1158
+ fts_rank: e.fts_rank ?? undefined,
1159
+ semantic_rank: e.semantic_rank ?? undefined
1160
+ };
1161
+ }
1162
+ async function incrementAccessCounts(client2, entityIds) {
1163
+ if (entityIds.length === 0)
1164
+ return;
1165
+ try {
1166
+ await client2.batchTouchMemoryEntities(entityIds);
1167
+ } catch {
1168
+ await Promise.allSettled(entityIds.map((id) => client2.touchMemoryEntity(id)));
1169
+ }
1170
+ }
1171
+ async function promoteEligibleEntities(client2, entities) {
1172
+ for (const entity of entities) {
1173
+ if (entity.memory_tier === "reference")
1174
+ continue;
1175
+ if (!entity.created_at)
1176
+ continue;
1177
+ const promotion = checkPromotion(entity.memory_tier, entity.access_count + 1, entity.confidence, entity.created_at);
1178
+ if (promotion.eligible && promotion.targetTier) {
1179
+ try {
1180
+ await client2.updateMemoryEntity(entity.id, {
1181
+ memory_tier: promotion.targetTier,
1182
+ metadata: {
1183
+ ...entity.metadata || {},
1184
+ promoted_at: new Date().toISOString(),
1185
+ promotion_reason: promotion.reason,
1186
+ promoted_from: entity.memory_tier
1187
+ }
1188
+ });
1189
+ } catch {}
880
1190
  }
881
- // Clean up tracking
882
- sessionAssemblyMap.delete(cardId);
883
- return { adjusted };
1191
+ }
884
1192
  }
1193
+ function cacheManifest(manifest) {
1194
+ if (manifestCache.size >= MAX_CACHE_SIZE) {
1195
+ const firstKey = manifestCache.keys().next().value;
1196
+ if (firstKey)
1197
+ manifestCache.delete(firstKey);
1198
+ }
1199
+ manifestCache.set(manifest.assemblyId, manifest);
1200
+ }
1201
+ function getCachedManifest(assemblyId) {
1202
+ return manifestCache.get(assemblyId);
1203
+ }
1204
+ function trackSessionAssembly(cardId, assemblyId) {
1205
+ if (sessionAssemblyMap.size >= MAX_SESSION_MAP_SIZE) {
1206
+ const firstKey = sessionAssemblyMap.keys().next().value;
1207
+ if (firstKey)
1208
+ sessionAssemblyMap.delete(firstKey);
1209
+ }
1210
+ sessionAssemblyMap.set(cardId, assemblyId);
1211
+ }
1212
+ function getSessionAssemblyId(cardId) {
1213
+ return sessionAssemblyMap.get(cardId);
1214
+ }
1215
+ async function recordContextFeedback(client2, cardId, sessionStatus, progressPercent, hadBlockers) {
1216
+ const assemblyId = sessionAssemblyMap.get(cardId);
1217
+ if (!assemblyId)
1218
+ return { adjusted: 0 };
1219
+ const manifest = manifestCache.get(assemblyId);
1220
+ if (!manifest || manifest.included.length === 0)
1221
+ return { adjusted: 0 };
1222
+ let adjusted = 0;
1223
+ const isSuccess = sessionStatus === "completed" && (progressPercent ?? 0) >= 100;
1224
+ for (const entry of manifest.included) {
1225
+ try {
1226
+ if (isSuccess) {
1227
+ const { entity } = await client2.getMemoryEntity(entry.entityId);
1228
+ const e = entity;
1229
+ const currentUsefulness = e.metadata?.usefulness_score ?? 0;
1230
+ const newConfidence = Math.min((e.confidence ?? 0.5) + 0.05, 1);
1231
+ await client2.updateMemoryEntity(entry.entityId, {
1232
+ confidence: newConfidence,
1233
+ metadata: {
1234
+ usefulness_score: currentUsefulness + 1,
1235
+ last_feedback_at: new Date().toISOString()
1236
+ }
1237
+ });
1238
+ adjusted++;
1239
+ } else if (hadBlockers) {
1240
+ const { entity } = await client2.getMemoryEntity(entry.entityId);
1241
+ const e = entity;
1242
+ const newConfidence = Math.max((e.confidence ?? 0.5) - 0.02, 0.1);
1243
+ await client2.updateMemoryEntity(entry.entityId, {
1244
+ confidence: newConfidence,
1245
+ metadata: {
1246
+ last_feedback_at: new Date().toISOString()
1247
+ }
1248
+ });
1249
+ adjusted++;
1250
+ }
1251
+ } catch {}
1252
+ }
1253
+ sessionAssemblyMap.delete(cardId);
1254
+ return { adjusted };
1255
+ }
1256
+ var DEFAULT_TOKEN_BUDGET = 4000, MAX_TOKENS_PER_ENTITY = 500, MIN_RELEVANCE_THRESHOLD = 0.15, TIER_WEIGHTS, PROCEDURE_BUDGET_FRACTION = 0.15, TIER_BUDGET_ALLOCATION, MIN_REFERENCE_SLOTS = 1, GRAPH_WALK_MAX_DEPTH = 1, GRAPH_WALK_MAX_ENTITIES = 10, GRAPH_WALK_MIN_CONFIDENCE = 0.5, GRAPH_WALK_SEED_COUNT = 5, MAX_QUERY_VARIATIONS = 4, RERANK_CLUSTER_THRESHOLD = 0.05, RERANK_TOP_N = 10, RERANK_MIN_CANDIDATES = 5, RELATION_BONUSES, QUERY_SYNONYMS, manifestCache, MAX_CACHE_SIZE = 50, sessionAssemblyMap, MAX_SESSION_MAP_SIZE = 100;
1257
+ var init_context_assembly = __esm(() => {
1258
+ init_dist();
1259
+ TIER_WEIGHTS = {
1260
+ reference: 1,
1261
+ episode: 0.7,
1262
+ draft: 0.4
1263
+ };
1264
+ TIER_BUDGET_ALLOCATION = {
1265
+ reference: 0.6,
1266
+ episode: 0.3,
1267
+ draft: 0.1
1268
+ };
1269
+ RELATION_BONUSES = {
1270
+ depends_on: 0.15,
1271
+ resolved_by: 0.2,
1272
+ relates_to: 0.1,
1273
+ implements: 0.15,
1274
+ blocks: 0.15,
1275
+ references: 0.1,
1276
+ extends: 0.1,
1277
+ caused_by: 0.15
1278
+ };
1279
+ QUERY_SYNONYMS = {
1280
+ auth: ["authentication", "authorization", "session"],
1281
+ authentication: ["auth", "session", "sign-in"],
1282
+ login: ["sign-in", "authentication", "session"],
1283
+ bug: ["error", "issue", "defect", "problem"],
1284
+ error: ["exception", "failure", "issue"],
1285
+ fix: ["resolve", "patch", "repair", "correct"],
1286
+ deploy: ["deployment", "release", "ship", "publish"],
1287
+ test: ["testing", "spec", "assertion", "verify"],
1288
+ config: ["configuration", "settings", "setup"],
1289
+ db: ["database", "storage", "persistence"],
1290
+ database: ["storage", "persistence", "data store"],
1291
+ api: ["endpoint", "route", "service"],
1292
+ ui: ["frontend", "component", "view"],
1293
+ perf: ["performance", "speed", "latency"],
1294
+ performance: ["speed", "latency", "optimization"]
1295
+ };
1296
+ manifestCache = new Map;
1297
+ sessionAssemblyMap = new Map;
1298
+ });
1299
+ init_context_assembly();
1300
+
1301
+ export {
1302
+ trackSessionAssembly,
1303
+ recordContextFeedback,
1304
+ mapToContextEntity,
1305
+ getSessionAssemblyId,
1306
+ getCachedManifest,
1307
+ expandQuery,
1308
+ computeRelevanceScore,
1309
+ cacheManifest,
1310
+ assembleContext
1311
+ };