@marsnme/mcp-gateway 0.1.1

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/server.mjs ADDED
@@ -0,0 +1,4347 @@
1
+ #!/usr/bin/env node
2
+ import http from 'node:http';
3
+ import crypto from 'node:crypto';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { dirname } from 'node:path';
7
+
8
+ const PROFILE_CONFIGS = {
9
+ coco: {
10
+ schema: 'coco',
11
+ displayName: 'CoCo',
12
+ defaultPort: 18790,
13
+ gatewayDir: 'coco-mcp-gateway',
14
+ publicHostSuffix: 'coco-mcp.marsgroup.asia',
15
+ sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw', 'hermes'],
16
+ recallBodyEnum: ['coco', 'toto', 'system'],
17
+ digestDefaultOrigin: 'hermes-coco-digest',
18
+ memoryIngestToolName: 'memory_ingest',
19
+ memoryIngestLegacyToolNames: ['coco_memory_ingest'],
20
+ memoryIngestDefaultSourceFile: 'Coco/Memory/Ingest/auto.md',
21
+ memoryIngestDefaultOrigin: 'warp-coco',
22
+ memoryIngestOriginEnum: [
23
+ 'perplexity-coco',
24
+ 'cursor-coco',
25
+ 'warp-coco',
26
+ 'leo-manual',
27
+ 'hermes-coco-digest'
28
+ ],
29
+ memoryIngestFixedTags: ['coco', 'insight']
30
+ },
31
+ toto: {
32
+ schema: 'toto',
33
+ displayName: 'Toto',
34
+ defaultPort: 18791,
35
+ gatewayDir: 'toto-mcp-gateway',
36
+ publicHostSuffix: 'toto-mcp.marsgroup.asia',
37
+ sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw'],
38
+ recallBodyEnum: ['toto', 'system'],
39
+ digestDefaultOrigin: 'hermes-toto-digest',
40
+ memoryIngestToolName: 'memory_ingest',
41
+ memoryIngestLegacyToolNames: ['toto_memory_ingest'],
42
+ memoryIngestDefaultSourceFile: 'Toto/Memory/Ingest/auto.md',
43
+ memoryIngestDefaultOrigin: 'warp-toto',
44
+ memoryIngestOriginEnum: null,
45
+ memoryIngestFixedTags: ['toto', 'insight']
46
+ }
47
+ };
48
+ const MCP_PROFILE = String(process.env.MCP_PROFILE || 'coco')
49
+ .trim()
50
+ .toLowerCase();
51
+ const PROFILE = PROFILE_CONFIGS[MCP_PROFILE];
52
+ if (!PROFILE) {
53
+ throw new Error(
54
+ `Invalid MCP_PROFILE: ${MCP_PROFILE}. Supported values: ${Object.keys(PROFILE_CONFIGS).join('/')}`
55
+ );
56
+ }
57
+
58
+ const DB_PROFILE = PROFILE.schema;
59
+ const SERVER_NAME = `${PROFILE.schema}-memory-mcp`;
60
+ const RECALL_BODY_VALIDATION_MESSAGE = `body must be one of ${PROFILE.recallBodyEnum.join('/')}`;
61
+ const SOURCE_NAME_PATTERN = /^[a-z][a-z0-9_-]{1,31}$/;
62
+ const SOURCE_MODE_VALUES = new Set(['core', 'extended', 'registry']);
63
+ const CORE_SOURCE_LIST = PROFILE.sourceWhitelist.slice();
64
+ const CORE_SOURCE_WHITELIST = new Set(CORE_SOURCE_LIST);
65
+ const CORE_SOURCE_VALIDATION_MESSAGE =
66
+ `source must be one of ${CORE_SOURCE_LIST.join('/')}`;
67
+ function normalizeSourceName(value, fieldName = 'source') {
68
+ const normalized = String(value || '')
69
+ .trim()
70
+ .toLowerCase();
71
+ if (!normalized) return '';
72
+ if (!SOURCE_NAME_PATTERN.test(normalized)) {
73
+ throw new Error(`${fieldName} must match ^[a-z][a-z0-9_-]{1,31}$`);
74
+ }
75
+ return normalized;
76
+ }
77
+ function parseExtraSourceList(rawValue) {
78
+ const raw = String(rawValue || '').trim();
79
+ if (!raw) return [];
80
+ const extras = [];
81
+ const seen = new Set();
82
+ const entries = raw.split(',');
83
+ for (const entry of entries) {
84
+ const source = normalizeSourceName(entry, 'MCP_EXTRA_SOURCES entry');
85
+ if (!source) continue;
86
+ if (CORE_SOURCE_WHITELIST.has(source) || seen.has(source)) continue;
87
+ seen.add(source);
88
+ extras.push(source);
89
+ }
90
+ return extras;
91
+ }
92
+ const SOURCE_MODE_RAW = String(process.env.MCP_SOURCE_MODE || 'core')
93
+ .trim()
94
+ .toLowerCase();
95
+ if (SOURCE_MODE_RAW && !SOURCE_MODE_VALUES.has(SOURCE_MODE_RAW)) {
96
+ console.warn(
97
+ `[config] invalid MCP_SOURCE_MODE=${SOURCE_MODE_RAW}; fallback to core`
98
+ );
99
+ }
100
+ const SOURCE_MODE = SOURCE_MODE_VALUES.has(SOURCE_MODE_RAW)
101
+ ? SOURCE_MODE_RAW
102
+ : 'core';
103
+ const EXTRA_SOURCE_LIST =
104
+ SOURCE_MODE === 'extended'
105
+ ? parseExtraSourceList(process.env.MCP_EXTRA_SOURCES || '')
106
+ : [];
107
+ const SOURCE_REGISTRY_TABLE = 'source_registry';
108
+ const SOURCE_REGISTRY_SELECT_COLUMNS = 'source,enabled';
109
+ const SOURCE_REGISTRY_CACHE = {
110
+ loaded: false,
111
+ loaded_at: null,
112
+ enabled_sources: [],
113
+ last_error: ''
114
+ };
115
+ let MEMORY_SOURCE_LIST = CORE_SOURCE_LIST.slice();
116
+ let MEMORY_SOURCE_WHITELIST = new Set(MEMORY_SOURCE_LIST);
117
+ let MEMORY_SOURCE_VALIDATION_MESSAGE =
118
+ `source must be one of ${MEMORY_SOURCE_LIST.join('/')}`;
119
+
120
+ function buildMemorySourceListForMode(registryEnabledSources = []) {
121
+ if (SOURCE_MODE === 'extended') {
122
+ return Array.from(new Set([...CORE_SOURCE_LIST, ...EXTRA_SOURCE_LIST]));
123
+ }
124
+ if (SOURCE_MODE === 'registry') {
125
+ return Array.from(new Set([...CORE_SOURCE_LIST, ...registryEnabledSources]));
126
+ }
127
+ return CORE_SOURCE_LIST.slice();
128
+ }
129
+ function setMemorySourceWhitelist(nextSources) {
130
+ const sourceList = Array.from(new Set(Array.isArray(nextSources) ? nextSources : []));
131
+ MEMORY_SOURCE_LIST = sourceList;
132
+ MEMORY_SOURCE_WHITELIST = new Set(sourceList);
133
+ MEMORY_SOURCE_VALIDATION_MESSAGE =
134
+ `source must be one of ${MEMORY_SOURCE_LIST.join('/')}`;
135
+ }
136
+ setMemorySourceWhitelist(buildMemorySourceListForMode());
137
+
138
+ const PORT = Number.parseInt(process.env.PORT || String(PROFILE.defaultPort), 10);
139
+ const SUPABASE_BASE_URL = process.env.SUPABASE_BASE_URL || 'http://127.0.0.1:8100';
140
+ const OAUTH_ENABLED = process.env.MCP_OAUTH_ENABLED !== 'false';
141
+ const REQUIRE_BEARER = process.env.MCP_REQUIRE_BEARER === 'true';
142
+ const BYPASS_BEARER_FOR_PRIVATE = process.env.MCP_BYPASS_BEARER_FOR_PRIVATE !== 'false';
143
+ const OAUTH_CLIENTS_FILE =
144
+ process.env.MCP_OAUTH_CLIENTS_FILE || `/opt/${PROFILE.gatewayDir}/oauth-clients.json`;
145
+ const OAUTH_ALLOW_UNKNOWN_CLIENT_SEED =
146
+ process.env.MCP_OAUTH_ALLOW_UNKNOWN_CLIENT_SEED !== 'false';
147
+ const PUBLIC_HOST_SUFFIXES = String(
148
+ process.env.MCP_PUBLIC_HOST_SUFFIXES || PROFILE.publicHostSuffix
149
+ )
150
+ .split(',')
151
+ .map((item) => item.trim().toLowerCase())
152
+ .filter(Boolean);
153
+ const OAUTH_CODE_TTL_SECONDS = Number.parseInt(process.env.OAUTH_CODE_TTL_SECONDS || '300', 10);
154
+ const OAUTH_TOKEN_TTL_SECONDS = Number.parseInt(process.env.OAUTH_TOKEN_TTL_SECONDS || '3600', 10);
155
+ const OAUTH_REFRESH_TOKEN_TTL_SECONDS = Number.parseInt(
156
+ process.env.OAUTH_REFRESH_TOKEN_TTL_SECONDS || '2592000',
157
+ 10
158
+ );
159
+ const STATIC_CLIENT_ID = (process.env.MCP_CLIENT_ID || '').trim();
160
+ const STATIC_CLIENT_SECRET = (process.env.MCP_CLIENT_SECRET || '').trim();
161
+ const JINA_API_KEY = (process.env.JINA_API_KEY || '').trim();
162
+ const JINA_EMBEDDING_API_URL = String(
163
+ process.env.JINA_EMBEDDING_API_URL || 'https://api.jina.ai/v1/embeddings'
164
+ ).trim();
165
+ const JINA_EMBEDDING_MODEL = String(
166
+ process.env.JINA_EMBEDDING_MODEL || 'jina-embeddings-v3'
167
+ ).trim();
168
+ const JINA_EMBEDDING_DIMENSIONS = Number.parseInt(
169
+ process.env.JINA_EMBEDDING_DIMENSIONS || '1024',
170
+ 10
171
+ );
172
+ const JINA_EMBEDDING_DIMENSIONS_SAFE =
173
+ Number.isFinite(JINA_EMBEDDING_DIMENSIONS) && JINA_EMBEDDING_DIMENSIONS > 0
174
+ ? JINA_EMBEDDING_DIMENSIONS
175
+ : 1024;
176
+ const CHUNK_VISIBILITY_WHITELIST = new Set(['private', 'shared', 'global']);
177
+ const CHUNK_BODY_WHITELIST = new Set(['coco', 'toto', 'system']);
178
+ const COCO_MEMORY_INGEST_ORIGIN_WHITELIST = new Set([
179
+ 'perplexity-coco',
180
+ 'cursor-coco',
181
+ 'warp-coco',
182
+ 'leo-manual',
183
+ 'hermes-coco-digest'
184
+ ]);
185
+ const DAILY_BOOT_QUERY_DEFAULTS = {
186
+ coco: {
187
+ identity_query: 'CoCo SOUL',
188
+ workflow_subject: 'CoCo'
189
+ },
190
+ toto: {
191
+ identity_query: 'Toto SOUL',
192
+ workflow_subject: 'Toto'
193
+ }
194
+ };
195
+ const DAILY_BOOT_STATUS_QUERY_SUFFIX = '最新狀態 本週任務';
196
+ const MEMORY_SCOPE_VALUES = new Set(['this_body', 'all_bodies']);
197
+ const SOURCE_TO_AGENT_BODY_MAP = new Map([
198
+ ['perplexity', 'perplexity-web'],
199
+ ['cursor', 'cursor'],
200
+ ['warp', 'warp'],
201
+ ['openclaw', 'desktop'],
202
+ ['hermes', 'desktop']
203
+ ]);
204
+ const DEFAULT_AGENT_BODY = String(process.env.MCP_AGENT_BODY || '')
205
+ .trim()
206
+ .toLowerCase();
207
+ const DEFAULT_ENVIRONMENT = String(process.env.MCP_ENVIRONMENT || 'desktop')
208
+ .trim()
209
+ .toLowerCase();
210
+ const MEMORY_SELECT_COLUMNS =
211
+ 'id,body,source,session_id,tags,agent_body,environment,promoted,promoted_at,created_at,expires_at';
212
+ const TOOL_USAGE_INSERT_SELECT_COLUMNS =
213
+ 'id,tool_name,timestamp,latency_ms,tokens_estimate,agent_body';
214
+ const TOOL_USAGE_TOKEN_CHAR_CAP = 400000;
215
+ const TOOL_USAGE_INGEST_NAMES = new Set([
216
+ 'dream_ingest',
217
+ PROFILE.memoryIngestToolName,
218
+ ...PROFILE.memoryIngestLegacyToolNames,
219
+ 'ingest_marsvault_digest'
220
+ ]);
221
+ function buildMemoryIngestOriginSchema() {
222
+ if (Array.isArray(PROFILE.memoryIngestOriginEnum) && PROFILE.memoryIngestOriginEnum.length > 0) {
223
+ return {
224
+ type: 'string',
225
+ enum: PROFILE.memoryIngestOriginEnum,
226
+ default: PROFILE.memoryIngestDefaultOrigin
227
+ };
228
+ }
229
+ return {
230
+ type: 'string',
231
+ description: 'Origin marker',
232
+ default: PROFILE.memoryIngestDefaultOrigin
233
+ };
234
+ }
235
+ function buildMemorySourceSchema() {
236
+ if (SOURCE_MODE === 'registry') {
237
+ return {
238
+ type: 'string',
239
+ description:
240
+ `Source key validated against core sources + enabled entries in ${DB_PROFILE}.${SOURCE_REGISTRY_TABLE}`
241
+ };
242
+ }
243
+ return {
244
+ type: 'string',
245
+ enum: MEMORY_SOURCE_LIST
246
+ };
247
+ }
248
+
249
+ function buildTools() {
250
+ return [
251
+ {
252
+ name: 'insert_memory',
253
+ description: `Insert a short-term ${PROFILE.displayName} memory into ${DB_PROFILE}.memories (ephemeral context)`,
254
+ inputSchema: {
255
+ type: 'object',
256
+ properties: {
257
+ body: { type: 'string', description: 'Memory content text' },
258
+ source: buildMemorySourceSchema(),
259
+ session_id: { type: 'string', description: 'Session trace id' },
260
+ tags: {
261
+ type: 'array',
262
+ items: { type: 'string' },
263
+ description: 'Optional tags list'
264
+ },
265
+ expires_at: {
266
+ type: 'string',
267
+ description: 'Optional ISO timestamp override'
268
+ },
269
+ agent_body: {
270
+ type: 'string',
271
+ description: 'Optional body label (default inferred from source)'
272
+ },
273
+ environment: {
274
+ type: 'string',
275
+ description: 'Optional environment label (default from MCP_ENVIRONMENT)'
276
+ }
277
+ },
278
+ required: ['body', 'source', 'session_id'],
279
+ additionalProperties: false
280
+ }
281
+ },
282
+ {
283
+ name: 'list_memories',
284
+ description: `List recent memories from ${DB_PROFILE}.memories`,
285
+ inputSchema: {
286
+ type: 'object',
287
+ properties: {
288
+ limit: { type: 'number', minimum: 1, maximum: 100, default: 20 },
289
+ source: buildMemorySourceSchema(),
290
+ unexpired_only: { type: 'boolean', default: true }
291
+ },
292
+ additionalProperties: false
293
+ }
294
+ },
295
+ {
296
+ name: 'search_memories',
297
+ description: `Semantic search memories from ${DB_PROFILE}.memories using Jina embeddings`,
298
+ inputSchema: {
299
+ type: 'object',
300
+ properties: {
301
+ query: { type: 'string', description: 'Semantic search query text' },
302
+ limit: { type: 'number', minimum: 1, maximum: 100, default: 20 },
303
+ source: buildMemorySourceSchema(),
304
+ unexpired_only: { type: 'boolean', default: true },
305
+ min_similarity: { type: 'number', minimum: -1, maximum: 1 },
306
+ scope: { type: 'string', enum: ['this_body', 'all_bodies'], default: 'this_body' },
307
+ agent_body: {
308
+ type: 'string',
309
+ description: 'Optional body scope key; defaults to source/body/env inference'
310
+ },
311
+ environment: {
312
+ type: 'string',
313
+ description: 'Optional environment filter'
314
+ }
315
+ },
316
+ required: ['query'],
317
+ additionalProperties: false
318
+ }
319
+ },
320
+ {
321
+ name: 'reload_source_registry',
322
+ description:
323
+ `Reload enabled sources cache from ${DB_PROFILE}.${SOURCE_REGISTRY_TABLE} (effective when MCP_SOURCE_MODE=registry)`,
324
+ inputSchema: {
325
+ type: 'object',
326
+ properties: {},
327
+ additionalProperties: false
328
+ }
329
+ },
330
+ {
331
+ name: 'recall',
332
+ description: `Semantic recall from ${DB_PROFILE}.marsvault_chunks using Jina embeddings`,
333
+ inputSchema: {
334
+ type: 'object',
335
+ properties: {
336
+ query: { type: 'string', description: 'Semantic recall query text' },
337
+ limit: { type: 'number', minimum: 1, maximum: 50, default: 5 },
338
+ body: { type: 'string', enum: PROFILE.recallBodyEnum, default: DB_PROFILE },
339
+ include_global: { type: 'boolean', default: true },
340
+ include_shared: { type: 'boolean', default: true },
341
+ include_private: { type: 'boolean', default: true },
342
+ type: { type: 'string', description: 'Optional chunk type filter' },
343
+ min_similarity: { type: 'number', minimum: -1, maximum: 1 },
344
+ scope: { type: 'string', enum: ['this_body', 'all_bodies'], default: 'this_body' },
345
+ agent_body: {
346
+ type: 'string',
347
+ description: 'Optional body scope key; defaults to MCP_AGENT_BODY when present'
348
+ },
349
+ environment: {
350
+ type: 'string',
351
+ description: 'Optional environment filter'
352
+ },
353
+ debug_explain: {
354
+ type: 'boolean',
355
+ default: false,
356
+ description: 'Include query/hit token overlap debug details'
357
+ }
358
+ },
359
+ required: ['query'],
360
+ additionalProperties: false
361
+ }
362
+ },
363
+ {
364
+ name: 'health_check',
365
+ description: `Run ${PROFILE.displayName} memory health diagnostics (count_chunks, expiry_alert, coverage_map, detect_conflicts)`,
366
+ inputSchema: {
367
+ type: 'object',
368
+ properties: {
369
+ alert_window_hours: {
370
+ type: 'number',
371
+ minimum: 1,
372
+ maximum: 720,
373
+ default: 48,
374
+ description: 'Alert when short-term memories will expire within this window'
375
+ },
376
+ gap_days: {
377
+ type: 'number',
378
+ minimum: 7,
379
+ maximum: 365,
380
+ default: 30,
381
+ description: 'Detect long-memory timeline gaps over this day threshold'
382
+ },
383
+ topic_limit: {
384
+ type: 'number',
385
+ minimum: 1,
386
+ maximum: 20,
387
+ default: 5,
388
+ description: 'Maximum topics to return for rich/sparse/volatile sections'
389
+ },
390
+ page_size: {
391
+ type: 'number',
392
+ minimum: 100,
393
+ maximum: 2000,
394
+ default: 1000,
395
+ description: 'Pagination size for loading rows from Supabase'
396
+ },
397
+ max_rows: {
398
+ type: 'number',
399
+ minimum: 1000,
400
+ maximum: 50000,
401
+ default: 20000,
402
+ description: 'Safety cap for total rows loaded per table'
403
+ },
404
+ conflict_similarity_threshold: {
405
+ type: 'number',
406
+ minimum: 0,
407
+ maximum: 1,
408
+ default: 0.85,
409
+ description: 'Similarity threshold for conflict candidate detection'
410
+ },
411
+ conflict_window_days: {
412
+ type: 'number',
413
+ minimum: 1,
414
+ maximum: 120,
415
+ default: 14,
416
+ description: 'Within this day window classify similar pairs as CONFLICT (otherwise SUPERSEDED)'
417
+ },
418
+ conflict_match_count: {
419
+ type: 'number',
420
+ minimum: 1,
421
+ maximum: 200,
422
+ default: 20,
423
+ description: 'Maximum similar pair records to return from conflict detection'
424
+ },
425
+ conflict_scan_limit: {
426
+ type: 'number',
427
+ minimum: 50,
428
+ maximum: 5000,
429
+ default: 400,
430
+ description: 'Maximum recent chunks scanned for conflict detection'
431
+ },
432
+ conflict_neighbor_limit: {
433
+ type: 'number',
434
+ minimum: 1,
435
+ maximum: 20,
436
+ default: 6,
437
+ description: 'Nearest neighbors compared per chunk in conflict detection'
438
+ },
439
+ forget_candidate_days: {
440
+ type: 'number',
441
+ minimum: 3,
442
+ maximum: 180,
443
+ default: 14,
444
+ description: 'Only suggest forget candidates older than this number of days'
445
+ },
446
+ forget_candidate_limit: {
447
+ type: 'number',
448
+ minimum: 1,
449
+ maximum: 50,
450
+ default: 10,
451
+ description: 'Maximum forget candidates returned by health_check'
452
+ }
453
+ },
454
+ additionalProperties: false
455
+ }
456
+ },
457
+ {
458
+ name: 'session_boot',
459
+ description:
460
+ 'One-call session boot rhythm: always run identity/workflow/status recall + expiry-focused health snapshot + heartbeat sign-in',
461
+ inputSchema: {
462
+ type: 'object',
463
+ properties: {
464
+ source: { type: 'string', enum: PROFILE.sourceWhitelist },
465
+ body_name: {
466
+ type: 'string',
467
+ description: 'Optional body/persona name (例如:大家姐、三哥、五妹、Toto)'
468
+ },
469
+ user_name: {
470
+ type: 'string',
471
+ description: 'Optional user/owner name for status recall (例如:Leo、Yvonne)'
472
+ },
473
+ topic: {
474
+ type: 'string',
475
+ description: 'Optional current focus topic for heartbeat'
476
+ },
477
+ mood: {
478
+ type: 'string',
479
+ description: 'Optional mood marker'
480
+ },
481
+ identity_query: {
482
+ type: 'string',
483
+ description: 'Optional override for identity recall query'
484
+ },
485
+ workflow_query: {
486
+ type: 'string',
487
+ description: 'Optional override for workflow recall query'
488
+ },
489
+ status_query: {
490
+ type: 'string',
491
+ description: 'Optional override for status recall query'
492
+ },
493
+ recall_limit: {
494
+ type: 'number',
495
+ minimum: 1,
496
+ maximum: 10,
497
+ default: 5
498
+ },
499
+ alert_window_hours: {
500
+ type: 'number',
501
+ minimum: 1,
502
+ maximum: 720,
503
+ description: 'Optional override for expiry snapshot window (hours)'
504
+ },
505
+ gap_days: {
506
+ type: 'number',
507
+ minimum: 7,
508
+ maximum: 365,
509
+ description: 'Deprecated compatibility field; ignored by session_boot'
510
+ },
511
+ topic_limit: {
512
+ type: 'number',
513
+ minimum: 1,
514
+ maximum: 20,
515
+ description: 'Deprecated compatibility field; ignored by session_boot'
516
+ }
517
+ },
518
+ required: ['source'],
519
+ additionalProperties: false
520
+ }
521
+ },
522
+ {
523
+ name: 'session_close',
524
+ description:
525
+ 'Store session close summary with 7-day retention for CoCo/Toto rhythm closure',
526
+ inputSchema: {
527
+ type: 'object',
528
+ properties: {
529
+ source: { type: 'string', enum: PROFILE.sourceWhitelist },
530
+ summary: {
531
+ type: 'string',
532
+ description: 'Session close summary'
533
+ },
534
+ topics: {
535
+ type: 'array',
536
+ items: { type: 'string' },
537
+ description: 'Optional topic list'
538
+ },
539
+ mood: {
540
+ type: 'string',
541
+ description: 'Optional mood marker'
542
+ }
543
+ },
544
+ required: ['source', 'summary'],
545
+ additionalProperties: false
546
+ }
547
+ },
548
+ {
549
+ name: 'dream_ingest',
550
+ description: `Chunk and ingest Hermes digest text into ${DB_PROFILE}.marsvault_chunks as long-term insight data`,
551
+ inputSchema: {
552
+ type: 'object',
553
+ properties: {
554
+ content: { type: 'string', description: 'Digest full text content' },
555
+ source_file: { type: 'string', description: 'Logical source path for digest' },
556
+ section: { type: 'string', description: 'Section label prefix' },
557
+ tags: {
558
+ type: 'array',
559
+ items: { type: 'string' },
560
+ description: 'Optional tags list'
561
+ },
562
+ type: { type: 'string', description: 'Chunk type', default: 'digest' },
563
+ date: { type: 'string', description: 'Optional YYYY-MM-DD date override' },
564
+ body: { type: 'string', enum: PROFILE.recallBodyEnum, default: DB_PROFILE },
565
+ visibility: { type: 'string', enum: ['private', 'shared', 'global'], default: 'private' },
566
+ origin: { type: 'string', description: 'Origin marker', default: PROFILE.digestDefaultOrigin },
567
+ max_chunk_chars: { type: 'number', minimum: 300, maximum: 3000, default: 1200 }
568
+ },
569
+ required: ['content'],
570
+ additionalProperties: false
571
+ }
572
+ },
573
+ {
574
+ name: PROFILE.memoryIngestToolName,
575
+ description: `Chunk and ingest ${PROFILE.displayName} long-term insight content into ${DB_PROFILE}.marsvault_chunks (not short-memory)`,
576
+ inputSchema: {
577
+ type: 'object',
578
+ properties: {
579
+ content: { type: 'string', description: 'Insight full text content' },
580
+ source_file: { type: 'string', description: 'Logical source path for insight content' },
581
+ section: { type: 'string', description: 'Section label prefix' },
582
+ tags: {
583
+ type: 'array',
584
+ items: { type: 'string' },
585
+ description: 'Optional tags list'
586
+ },
587
+ type: { type: 'string', description: 'Chunk type', default: 'insight' },
588
+ date: { type: 'string', description: 'Optional YYYY-MM-DD date override' },
589
+ visibility: { type: 'string', enum: ['private', 'shared', 'global'], default: 'private' },
590
+ origin: buildMemoryIngestOriginSchema(),
591
+ source_memory_id: {
592
+ type: 'string',
593
+ description: 'Optional short-memory id to link promoted long-memory chunks'
594
+ },
595
+ source_session_id: {
596
+ type: 'string',
597
+ description: 'Optional source session id for provenance'
598
+ },
599
+ source_tool: {
600
+ type: 'string',
601
+ enum: PROFILE.sourceWhitelist,
602
+ description: 'Optional source tool for provenance'
603
+ },
604
+ source_user_note: {
605
+ type: 'string',
606
+ description: 'Optional user note describing why this memory was promoted'
607
+ },
608
+ agent_body: {
609
+ type: 'string',
610
+ description: 'Optional body label for memory boundary'
611
+ },
612
+ environment: {
613
+ type: 'string',
614
+ description: 'Optional environment label'
615
+ },
616
+ max_chunk_chars: { type: 'number', minimum: 300, maximum: 3000, default: 1200 }
617
+ },
618
+ required: ['content'],
619
+ additionalProperties: false
620
+ }
621
+ },
622
+ {
623
+ name: 'demote_memory',
624
+ description: `Mark a long-memory chunk as deprecated/superseded in ${DB_PROFILE}.marsvault_chunks`,
625
+ inputSchema: {
626
+ type: 'object',
627
+ properties: {
628
+ id: { type: 'string', description: 'Long-memory chunk id (UUID)' },
629
+ deprecated_reason: { type: 'string', description: 'Reason for deprecation' },
630
+ superseded_by: {
631
+ type: 'string',
632
+ description: 'Optional replacement chunk id (UUID) when this memory is superseded'
633
+ },
634
+ deprecated_at: {
635
+ type: 'string',
636
+ description: 'Optional ISO timestamp override; defaults to now'
637
+ }
638
+ },
639
+ required: ['id', 'deprecated_reason'],
640
+ additionalProperties: false
641
+ }
642
+ },
643
+ {
644
+ name: 'soft_forget',
645
+ description: `Expire short-memory rows early in ${DB_PROFILE}.memories (manual cleanup without hard delete)`,
646
+ inputSchema: {
647
+ type: 'object',
648
+ properties: {
649
+ ids: {
650
+ type: 'array',
651
+ items: { type: 'string' },
652
+ minItems: 1,
653
+ maxItems: 50,
654
+ description: 'Short-memory IDs (UUID) to expire immediately'
655
+ },
656
+ reason: {
657
+ type: 'string',
658
+ description: 'Optional note for why these memories are being soft-forgotten'
659
+ },
660
+ forgotten_at: {
661
+ type: 'string',
662
+ description: 'Optional ISO timestamp override; defaults to now'
663
+ }
664
+ },
665
+ required: ['ids'],
666
+ additionalProperties: false
667
+ }
668
+ },
669
+ {
670
+ name: 'explain_memory',
671
+ description: `Explain provenance of a long-memory chunk in ${DB_PROFILE}.marsvault_chunks (source memory/session/tool/timeline)`,
672
+ inputSchema: {
673
+ type: 'object',
674
+ properties: {
675
+ id: { type: 'string', description: 'Long-memory chunk id (UUID)' }
676
+ },
677
+ required: ['id'],
678
+ additionalProperties: false
679
+ }
680
+ }
681
+ ];
682
+ }
683
+
684
+ const TOOLS = buildTools();
685
+
686
+ const OAUTH_CLIENTS = new Map();
687
+ const OAUTH_CODES = new Map();
688
+ const OAUTH_TOKENS = new Map();
689
+ const OAUTH_REFRESH_TOKENS = new Map();
690
+
691
+ function persistOauthClients() {
692
+ if (!OAUTH_ENABLED) return;
693
+ try {
694
+ mkdirSync(dirname(OAUTH_CLIENTS_FILE), { recursive: true });
695
+ writeFileSync(
696
+ OAUTH_CLIENTS_FILE,
697
+ JSON.stringify(Array.from(OAUTH_CLIENTS.values()), null, 2),
698
+ 'utf8'
699
+ );
700
+ } catch (error) {
701
+ console.warn(
702
+ `[oauth] failed to persist clients store: ${String(error?.message || error)}`
703
+ );
704
+ }
705
+ }
706
+
707
+ function loadPersistedOauthClients() {
708
+ if (!OAUTH_ENABLED) return;
709
+ try {
710
+ if (!existsSync(OAUTH_CLIENTS_FILE)) return;
711
+ const raw = readFileSync(OAUTH_CLIENTS_FILE, 'utf8').trim();
712
+ if (!raw) return;
713
+ const parsed = JSON.parse(raw);
714
+ const clients = Array.isArray(parsed) ? parsed : parsed?.clients;
715
+ if (!Array.isArray(clients)) return;
716
+ for (const client of clients) {
717
+ if (!client || typeof client.client_id !== 'string') continue;
718
+ OAUTH_CLIENTS.set(client.client_id, client);
719
+ }
720
+ console.log(
721
+ `[oauth] loaded ${OAUTH_CLIENTS.size} persisted clients from ${OAUTH_CLIENTS_FILE}`
722
+ );
723
+ } catch (error) {
724
+ console.warn(
725
+ `[oauth] failed to load clients store: ${String(error?.message || error)}`
726
+ );
727
+ }
728
+ }
729
+
730
+ function upsertOauthClient(client, options = {}) {
731
+ OAUTH_CLIENTS.set(client.client_id, client);
732
+ if (options.persist !== false) {
733
+ persistOauthClients();
734
+ }
735
+ return client;
736
+ }
737
+
738
+ function randomId(prefix) {
739
+ return `${prefix}_${crypto.randomBytes(20).toString('hex')}`;
740
+ }
741
+
742
+ function nowMs() {
743
+ return Date.now();
744
+ }
745
+
746
+ function toBase64Url(buffer) {
747
+ return buffer
748
+ .toString('base64')
749
+ .replace(/\+/g, '-')
750
+ .replace(/\//g, '_')
751
+ .replace(/=+$/g, '');
752
+ }
753
+
754
+ function sha256Base64Url(input) {
755
+ return toBase64Url(crypto.createHash('sha256').update(input).digest());
756
+ }
757
+ function extractServiceKeyFromDockerEnv(envOutput) {
758
+ const lines = envOutput.split('\n');
759
+ const keyPrefixesByPriority = [
760
+ 'SUPABASE_SERVICE_ROLE_KEY=',
761
+ 'SUPABASE_SERVICE_KEY=',
762
+ 'SERVICE_ROLE_KEY='
763
+ ];
764
+ for (const prefix of keyPrefixesByPriority) {
765
+ const line = lines.find((entry) => entry.startsWith(prefix));
766
+ if (line) {
767
+ const rawValue = line.slice(prefix.length).trim();
768
+ return rawValue
769
+ .replace(/^"(.*)"$/, '$1')
770
+ .replace(/^'(.*)'$/, '$1')
771
+ .trim();
772
+ }
773
+ }
774
+ return null;
775
+ }
776
+
777
+ function resolveKongContainerNames() {
778
+ try {
779
+ const output = execFileSync('docker', ['ps', '--format', '{{.Names}}'], {
780
+ encoding: 'utf8'
781
+ });
782
+ const names = output
783
+ .split('\n')
784
+ .map((entry) => entry.trim())
785
+ .filter(Boolean);
786
+ const preferred = [
787
+ 'supabase-kong',
788
+ 'supabase_kong',
789
+ ...names.filter((name) => name.startsWith('supabase-kong_')),
790
+ ...names.filter((name) => name.startsWith('supabase_kong_')),
791
+ ...names.filter((name) => /supabase[-_]kong/.test(name))
792
+ ];
793
+ return [...new Set(preferred)];
794
+ } catch {
795
+ return ['supabase-kong', 'supabase_kong'];
796
+ }
797
+ }
798
+
799
+ function getServiceKey() {
800
+ const keyFromEnv =
801
+ String(process.env.SUPABASE_SERVICE_ROLE_KEY || '').trim() ||
802
+ String(process.env.SUPABASE_SERVICE_KEY || '').trim();
803
+ if (keyFromEnv) {
804
+ return keyFromEnv;
805
+ }
806
+ const containerNames = resolveKongContainerNames();
807
+ for (const containerName of containerNames) {
808
+ try {
809
+ const envOutput = execFileSync(
810
+ 'docker',
811
+ [
812
+ 'inspect',
813
+ containerName,
814
+ '--format',
815
+ '{{range .Config.Env}}{{println .}}{{end}}'
816
+ ],
817
+ { encoding: 'utf8' }
818
+ );
819
+ const key = extractServiceKeyFromDockerEnv(envOutput);
820
+ if (key) {
821
+ return key;
822
+ }
823
+ } catch {
824
+ // try next container candidate
825
+ }
826
+ }
827
+
828
+ try {
829
+ const statusOutput = execFileSync('supabase', ['status', '-o', 'env'], {
830
+ encoding: 'utf8'
831
+ });
832
+ const keyFromStatus = extractServiceKeyFromDockerEnv(statusOutput);
833
+ if (keyFromStatus) {
834
+ return keyFromStatus;
835
+ }
836
+ } catch {
837
+ // fallback exhausted; throw below
838
+ }
839
+
840
+ throw new Error(
841
+ 'Cannot resolve Supabase service key. Set SUPABASE_SERVICE_ROLE_KEY (or SUPABASE_SERVICE_KEY), or start Docker and ensure `supabase status -o env` works.'
842
+ );
843
+ }
844
+
845
+ const SERVICE_KEY = getServiceKey();
846
+ loadPersistedOauthClients();
847
+
848
+ if (STATIC_CLIENT_ID && STATIC_CLIENT_SECRET && OAUTH_ENABLED) {
849
+ upsertOauthClient(
850
+ {
851
+ client_id: STATIC_CLIENT_ID,
852
+ client_secret: STATIC_CLIENT_SECRET,
853
+ redirect_uris: [],
854
+ scope: 'mcp',
855
+ token_endpoint_auth_method: 'client_secret_post',
856
+ created_at: Math.floor(nowMs() / 1000),
857
+ static: true
858
+ },
859
+ { persist: false }
860
+ );
861
+ }
862
+
863
+ function json(res, statusCode, payload, extraHeaders = {}) {
864
+ res.writeHead(statusCode, {
865
+ 'content-type': 'application/json; charset=utf-8',
866
+ ...extraHeaders
867
+ });
868
+ res.end(JSON.stringify(payload));
869
+ }
870
+
871
+ function mcpResult(id, result) {
872
+ return { jsonrpc: '2.0', id, result };
873
+ }
874
+
875
+ function mcpError(id, code, message) {
876
+ return { jsonrpc: '2.0', id: id ?? null, error: { code, message } };
877
+ }
878
+
879
+ async function readRawBody(req) {
880
+ const chunks = [];
881
+ for await (const chunk of req) {
882
+ chunks.push(chunk);
883
+ }
884
+ return Buffer.concat(chunks).toString('utf8');
885
+ }
886
+
887
+ async function readRequestBody(req) {
888
+ const raw = (await readRawBody(req)).trim();
889
+ if (!raw) return {};
890
+ const contentType = String(req.headers['content-type'] || '').toLowerCase();
891
+ if (contentType.includes('application/x-www-form-urlencoded')) {
892
+ return Object.fromEntries(new URLSearchParams(raw));
893
+ }
894
+ if (contentType.includes('application/json') || raw.startsWith('{') || raw.startsWith('[')) {
895
+ return JSON.parse(raw);
896
+ }
897
+ try {
898
+ return JSON.parse(raw);
899
+ } catch {
900
+ return Object.fromEntries(new URLSearchParams(raw));
901
+ }
902
+ }
903
+
904
+ async function supabaseRequest(path, options = {}) {
905
+ const headers = {
906
+ apikey: SERVICE_KEY,
907
+ Authorization: `Bearer ${SERVICE_KEY}`,
908
+ 'content-type': 'application/json'
909
+ };
910
+ if (options.profile) {
911
+ headers['accept-profile'] = options.profile;
912
+ headers['content-profile'] = options.profile;
913
+ }
914
+ if (options.prefer) {
915
+ headers.prefer = options.prefer;
916
+ }
917
+ const response = await fetch(`${SUPABASE_BASE_URL}${path}`, {
918
+ method: options.method || 'GET',
919
+ headers,
920
+ body: options.body ? JSON.stringify(options.body) : undefined
921
+ });
922
+
923
+ const text = await response.text();
924
+ let parsed;
925
+ try {
926
+ parsed = text ? JSON.parse(text) : null;
927
+ } catch {
928
+ parsed = text;
929
+ }
930
+
931
+ if (!response.ok) {
932
+ const detail =
933
+ typeof parsed === 'string' ? parsed : JSON.stringify(parsed ?? {});
934
+ throw new Error(`Supabase ${response.status}: ${detail}`);
935
+ }
936
+
937
+ return parsed;
938
+ }
939
+
940
+ function validateMemorySource(value, options = {}) {
941
+ const required = options.required !== false;
942
+ const source = String(value || '').trim();
943
+ if (!source) {
944
+ if (required) {
945
+ throw new Error('source is required');
946
+ }
947
+ return '';
948
+ }
949
+ if (MEMORY_SOURCE_WHITELIST.has(source)) {
950
+ return source;
951
+ }
952
+ if (SOURCE_MODE === 'registry') {
953
+ if (!SOURCE_NAME_PATTERN.test(source)) {
954
+ throw new Error('source must match ^[a-z][a-z0-9_-]{1,31}$');
955
+ }
956
+ throw new Error(
957
+ `source '${source}' is disabled or not registered in ${DB_PROFILE}.${SOURCE_REGISTRY_TABLE}`
958
+ );
959
+ }
960
+ throw new Error(MEMORY_SOURCE_VALIDATION_MESSAGE);
961
+ }
962
+
963
+ function buildSourceRegistryHealthPayload() {
964
+ if (SOURCE_MODE !== 'registry') {
965
+ return null;
966
+ }
967
+ return {
968
+ loaded: SOURCE_REGISTRY_CACHE.loaded,
969
+ loaded_at: SOURCE_REGISTRY_CACHE.loaded_at,
970
+ enabled_count: SOURCE_REGISTRY_CACHE.enabled_sources.length,
971
+ enabled_sources: SOURCE_REGISTRY_CACHE.enabled_sources.slice(),
972
+ effective_sources: MEMORY_SOURCE_LIST.slice(),
973
+ last_error: SOURCE_REGISTRY_CACHE.last_error || null
974
+ };
975
+ }
976
+
977
+ async function reloadSourceRegistryCache(options = {}) {
978
+ const manual = options.manual === true;
979
+ if (SOURCE_MODE !== 'registry') {
980
+ return {
981
+ ok: true,
982
+ mode: SOURCE_MODE,
983
+ reloaded: false,
984
+ manual,
985
+ message: 'MCP_SOURCE_MODE is not registry; source_registry cache is bypassed',
986
+ enabled_sources: [],
987
+ effective_sources: MEMORY_SOURCE_LIST.slice()
988
+ };
989
+ }
990
+
991
+ const query = new URLSearchParams();
992
+ query.set('select', SOURCE_REGISTRY_SELECT_COLUMNS);
993
+ query.set('enabled', 'eq.true');
994
+ query.set('order', 'source.asc');
995
+
996
+ try {
997
+ const rows = await supabaseRequest(
998
+ `/rest/v1/${SOURCE_REGISTRY_TABLE}?${query.toString()}`,
999
+ { profile: DB_PROFILE }
1000
+ );
1001
+ const enabledSources = [];
1002
+ const seen = new Set();
1003
+ for (const row of Array.isArray(rows) ? rows : []) {
1004
+ const source = String(row?.source || '').trim();
1005
+ if (!source || !SOURCE_NAME_PATTERN.test(source)) continue;
1006
+ if (CORE_SOURCE_WHITELIST.has(source) || seen.has(source)) continue;
1007
+ seen.add(source);
1008
+ enabledSources.push(source);
1009
+ }
1010
+ SOURCE_REGISTRY_CACHE.loaded = true;
1011
+ SOURCE_REGISTRY_CACHE.loaded_at = new Date().toISOString();
1012
+ SOURCE_REGISTRY_CACHE.enabled_sources = enabledSources;
1013
+ SOURCE_REGISTRY_CACHE.last_error = '';
1014
+ setMemorySourceWhitelist(buildMemorySourceListForMode(enabledSources));
1015
+ return {
1016
+ ok: true,
1017
+ mode: SOURCE_MODE,
1018
+ reloaded: true,
1019
+ manual,
1020
+ loaded_at: SOURCE_REGISTRY_CACHE.loaded_at,
1021
+ enabled_sources: enabledSources,
1022
+ effective_sources: MEMORY_SOURCE_LIST.slice()
1023
+ };
1024
+ } catch (error) {
1025
+ const errorMessage = String(error?.message || error);
1026
+ SOURCE_REGISTRY_CACHE.loaded = false;
1027
+ SOURCE_REGISTRY_CACHE.last_error = normalizeOptionalText(errorMessage, 280);
1028
+ if (!SOURCE_REGISTRY_CACHE.loaded_at) {
1029
+ SOURCE_REGISTRY_CACHE.enabled_sources = [];
1030
+ setMemorySourceWhitelist(buildMemorySourceListForMode([]));
1031
+ }
1032
+ return {
1033
+ ok: false,
1034
+ mode: SOURCE_MODE,
1035
+ reloaded: false,
1036
+ manual,
1037
+ loaded_at: SOURCE_REGISTRY_CACHE.loaded_at,
1038
+ enabled_sources: SOURCE_REGISTRY_CACHE.enabled_sources.slice(),
1039
+ effective_sources: MEMORY_SOURCE_LIST.slice(),
1040
+ error: errorMessage
1041
+ };
1042
+ }
1043
+ }
1044
+
1045
+ function normalizeTags(tags) {
1046
+ if (!Array.isArray(tags)) return [];
1047
+ return tags
1048
+ .map((tag) => String(tag).trim())
1049
+ .filter(Boolean)
1050
+ .slice(0, 30);
1051
+ }
1052
+
1053
+ function normalizeChunkVisibility(value) {
1054
+ const normalized = String(value || 'private')
1055
+ .trim()
1056
+ .toLowerCase();
1057
+ if (!CHUNK_VISIBILITY_WHITELIST.has(normalized)) {
1058
+ throw new Error('visibility must be one of private/shared/global');
1059
+ }
1060
+ return normalized;
1061
+ }
1062
+
1063
+ function normalizeChunkBody(value) {
1064
+ const normalized = String(value || DB_PROFILE)
1065
+ .trim()
1066
+ .toLowerCase();
1067
+ if (!CHUNK_BODY_WHITELIST.has(normalized)) {
1068
+ throw new Error('body must be one of coco/toto/system');
1069
+ }
1070
+ return normalized;
1071
+ }
1072
+
1073
+ function normalizeChunkDate(value) {
1074
+ const raw = String(value || '').trim();
1075
+ if (!raw) {
1076
+ return new Date().toISOString().slice(0, 10);
1077
+ }
1078
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
1079
+ throw new Error('date must be YYYY-MM-DD');
1080
+ }
1081
+ return raw;
1082
+ }
1083
+ function normalizeOptionalUuid(value, fieldName = 'uuid') {
1084
+ const raw = String(value || '').trim();
1085
+ if (!raw) return '';
1086
+ if (
1087
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(raw)
1088
+ ) {
1089
+ throw new Error(`${fieldName} must be a valid UUID`);
1090
+ }
1091
+ return raw.toLowerCase();
1092
+ }
1093
+
1094
+ function splitDigestContent(rawContent, maxChunkChars = 1200) {
1095
+ const text = String(rawContent || '').trim();
1096
+ if (!text) return [];
1097
+
1098
+ const capRaw = Number(maxChunkChars);
1099
+ const cap = Number.isFinite(capRaw) ? Math.max(300, Math.min(3000, Math.trunc(capRaw))) : 1200;
1100
+ const paragraphs = text
1101
+ .split(/\n{2,}/)
1102
+ .map((segment) => segment.trim())
1103
+ .filter(Boolean);
1104
+
1105
+ if (paragraphs.length === 0) return [];
1106
+
1107
+ const chunks = [];
1108
+ let current = '';
1109
+
1110
+ const flushCurrent = () => {
1111
+ const chunk = current.trim();
1112
+ if (chunk) chunks.push(chunk);
1113
+ current = '';
1114
+ };
1115
+
1116
+ for (const paragraph of paragraphs) {
1117
+ if (paragraph.length > cap) {
1118
+ flushCurrent();
1119
+ for (let i = 0; i < paragraph.length; i += cap) {
1120
+ const piece = paragraph.slice(i, i + cap).trim();
1121
+ if (piece) chunks.push(piece);
1122
+ }
1123
+ continue;
1124
+ }
1125
+
1126
+ if (!current) {
1127
+ current = paragraph;
1128
+ continue;
1129
+ }
1130
+
1131
+ const merged = `${current}\n\n${paragraph}`;
1132
+ if (merged.length <= cap) {
1133
+ current = merged;
1134
+ } else {
1135
+ flushCurrent();
1136
+ current = paragraph;
1137
+ }
1138
+ }
1139
+ flushCurrent();
1140
+ return chunks;
1141
+ }
1142
+
1143
+ function clampInteger(value, min, max, fallback) {
1144
+ const raw = Number(value);
1145
+ if (!Number.isFinite(raw)) return fallback;
1146
+ return Math.max(min, Math.min(max, Math.trunc(raw)));
1147
+ }
1148
+
1149
+ function parseTimestamp(value) {
1150
+ if (!value) return null;
1151
+ const parsed = new Date(value);
1152
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
1153
+ }
1154
+ function normalizeOptionalIsoTimestamp(value, fieldName = 'timestamp') {
1155
+ const raw = String(value || '').trim();
1156
+ if (!raw) return '';
1157
+ const parsed = parseTimestamp(raw);
1158
+ if (!parsed) {
1159
+ throw new Error(`${fieldName} must be a valid ISO timestamp`);
1160
+ }
1161
+ return parsed.toISOString();
1162
+ }
1163
+
1164
+ function toUtcStartOfDay(date) {
1165
+ return new Date(
1166
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
1167
+ );
1168
+ }
1169
+
1170
+ function parseDateOnly(value) {
1171
+ const timestamp = parseTimestamp(value);
1172
+ if (!timestamp) return null;
1173
+ return toUtcStartOfDay(timestamp);
1174
+ }
1175
+
1176
+ function formatDateOnly(date) {
1177
+ return date.toISOString().slice(0, 10);
1178
+ }
1179
+
1180
+ function diffDaysUtc(fromDate, toDate) {
1181
+ const ms = toDate.getTime() - fromDate.getTime();
1182
+ return Math.floor(ms / 86400000);
1183
+ }
1184
+
1185
+ function normalizeTopicTag(tag) {
1186
+ return String(tag || '').trim().toLowerCase();
1187
+ }
1188
+
1189
+ function collectTagCounts(rows) {
1190
+ const counts = new Map();
1191
+ for (const row of rows) {
1192
+ for (const tag of normalizeTags(row?.tags)) {
1193
+ const normalized = normalizeTopicTag(tag);
1194
+ if (!normalized) continue;
1195
+ counts.set(normalized, (counts.get(normalized) || 0) + 1);
1196
+ }
1197
+ }
1198
+ return counts;
1199
+ }
1200
+
1201
+ function sortTagEntries(tagMap) {
1202
+ return Array.from(tagMap.entries()).sort(
1203
+ (left, right) => right[1] - left[1] || left[0].localeCompare(right[0])
1204
+ );
1205
+ }
1206
+
1207
+ function normalizeDailySource(value) {
1208
+ const source = String(value || '').trim();
1209
+ if (!CORE_SOURCE_WHITELIST.has(source)) {
1210
+ throw new Error(CORE_SOURCE_VALIDATION_MESSAGE);
1211
+ }
1212
+ return source;
1213
+ }
1214
+
1215
+ function normalizeOptionalText(value, maxLength = 200) {
1216
+ if (value === undefined || value === null) return '';
1217
+ const text = String(value).trim();
1218
+ if (!text) return '';
1219
+ return text.slice(0, Math.max(1, maxLength));
1220
+ }
1221
+ function normalizeOptionalSourceTool(value) {
1222
+ const normalized = String(value || '').trim();
1223
+ if (!normalized) return '';
1224
+ if (!CORE_SOURCE_WHITELIST.has(normalized)) {
1225
+ throw new Error(CORE_SOURCE_VALIDATION_MESSAGE);
1226
+ }
1227
+ return normalized;
1228
+ }
1229
+ function normalizeOptionalAgentBody(value) {
1230
+ const normalized = String(value || '')
1231
+ .trim()
1232
+ .toLowerCase()
1233
+ .replace(/\s+/g, '-');
1234
+ if (!normalized) return '';
1235
+ return normalized.slice(0, 80);
1236
+ }
1237
+ function normalizeOptionalEnvironment(value) {
1238
+ const normalized = String(value || '')
1239
+ .trim()
1240
+ .toLowerCase()
1241
+ .replace(/\s+/g, '-');
1242
+ if (!normalized) return '';
1243
+ return normalized.slice(0, 80);
1244
+ }
1245
+ function normalizeMemoryScope(value) {
1246
+ const normalized = String(value || 'this_body')
1247
+ .trim()
1248
+ .toLowerCase();
1249
+ if (!MEMORY_SCOPE_VALUES.has(normalized)) {
1250
+ throw new Error('scope must be one of this_body/all_bodies');
1251
+ }
1252
+ return normalized;
1253
+ }
1254
+ function inferAgentBodyFromSource(source) {
1255
+ const normalizedSource = String(source || '')
1256
+ .trim()
1257
+ .toLowerCase();
1258
+ if (!normalizedSource) return '';
1259
+ return SOURCE_TO_AGENT_BODY_MAP.get(normalizedSource) || normalizedSource;
1260
+ }
1261
+ function inferAgentBodyFromOrigin(origin) {
1262
+ const normalizedOrigin = String(origin || '')
1263
+ .trim()
1264
+ .toLowerCase();
1265
+ if (!normalizedOrigin) return '';
1266
+ const [originPrefix] = normalizedOrigin.split('-', 1);
1267
+ return inferAgentBodyFromSource(originPrefix);
1268
+ }
1269
+ function resolveWriteAgentBody(args = {}) {
1270
+ const explicit = normalizeOptionalAgentBody(args.agent_body);
1271
+ if (explicit) return explicit;
1272
+ const fromSourceTool = inferAgentBodyFromSource(args.source_tool);
1273
+ if (fromSourceTool) return fromSourceTool;
1274
+ const fromSource = inferAgentBodyFromSource(args.source);
1275
+ if (fromSource) return fromSource;
1276
+ const fromOrigin = inferAgentBodyFromOrigin(args.origin);
1277
+ if (fromOrigin) return fromOrigin;
1278
+ const fromDefault = normalizeOptionalAgentBody(DEFAULT_AGENT_BODY);
1279
+ if (fromDefault) return fromDefault;
1280
+ return DB_PROFILE;
1281
+ }
1282
+ function resolveWriteEnvironment(args = {}) {
1283
+ const explicit = normalizeOptionalEnvironment(args.environment);
1284
+ if (explicit) return explicit;
1285
+ const fromDefault = normalizeOptionalEnvironment(DEFAULT_ENVIRONMENT);
1286
+ if (fromDefault) return fromDefault;
1287
+ return 'desktop';
1288
+ }
1289
+ function resolveScopeFilters(args = {}, fallbackAgentBody = '') {
1290
+ const requestedScope = normalizeMemoryScope(args.scope);
1291
+ const explicitAgentBody = normalizeOptionalAgentBody(args.agent_body);
1292
+ const fallbackBody = normalizeOptionalAgentBody(fallbackAgentBody);
1293
+ const defaultBody = normalizeOptionalAgentBody(DEFAULT_AGENT_BODY);
1294
+ const agentBody = explicitAgentBody || fallbackBody || defaultBody || '';
1295
+ const explicitEnvironment = normalizeOptionalEnvironment(args.environment);
1296
+ const defaultEnvironment = normalizeOptionalEnvironment(DEFAULT_ENVIRONMENT);
1297
+ const environment = explicitEnvironment || defaultEnvironment || '';
1298
+ const effectiveScope =
1299
+ requestedScope === 'this_body' && !agentBody ? 'all_bodies' : requestedScope;
1300
+ return {
1301
+ requested_scope: requestedScope,
1302
+ scope: effectiveScope,
1303
+ agent_body: agentBody || null,
1304
+ environment: environment || null,
1305
+ fallback_to_all_bodies: requestedScope === 'this_body' && effectiveScope === 'all_bodies'
1306
+ };
1307
+ }
1308
+ function normalizeMemoryTagsWithContext(tags, agentBody, environment) {
1309
+ return normalizeTags([
1310
+ agentBody ? `agent_body:${agentBody}` : '',
1311
+ environment ? `environment:${environment}` : '',
1312
+ ...(Array.isArray(tags) ? tags : []),
1313
+ ]);
1314
+ }
1315
+ function appendTokenCharCount(value, state) {
1316
+ if (state.totalChars >= TOOL_USAGE_TOKEN_CHAR_CAP) return;
1317
+ if (value === undefined || value === null) return;
1318
+ if (Array.isArray(value)) {
1319
+ for (const item of value) {
1320
+ appendTokenCharCount(item, state);
1321
+ if (state.totalChars >= TOOL_USAGE_TOKEN_CHAR_CAP) break;
1322
+ }
1323
+ return;
1324
+ }
1325
+ let chunk = '';
1326
+ if (typeof value === 'object') {
1327
+ try {
1328
+ chunk = JSON.stringify(value);
1329
+ } catch {
1330
+ chunk = '';
1331
+ }
1332
+ } else {
1333
+ chunk = String(value);
1334
+ }
1335
+ if (!chunk) return;
1336
+ state.totalChars = Math.min(
1337
+ TOOL_USAGE_TOKEN_CHAR_CAP,
1338
+ state.totalChars + chunk.length
1339
+ );
1340
+ }
1341
+ function estimateToolUsageTokens(toolName, args = {}) {
1342
+ const normalizedToolName = String(toolName || '')
1343
+ .trim()
1344
+ .toLowerCase();
1345
+ const safeArgs = args && typeof args === 'object' ? args : {};
1346
+ const state = { totalChars: 0 };
1347
+ switch (normalizedToolName) {
1348
+ case 'insert_memory':
1349
+ appendTokenCharCount(safeArgs.body, state);
1350
+ appendTokenCharCount(safeArgs.tags, state);
1351
+ break;
1352
+ case 'search_memories':
1353
+ case 'recall':
1354
+ appendTokenCharCount(safeArgs.query, state);
1355
+ appendTokenCharCount(safeArgs.type, state);
1356
+ break;
1357
+ case 'session_close':
1358
+ appendTokenCharCount(safeArgs.summary, state);
1359
+ appendTokenCharCount(safeArgs.topics, state);
1360
+ appendTokenCharCount(safeArgs.mood, state);
1361
+ break;
1362
+ case 'session_boot':
1363
+ appendTokenCharCount(safeArgs.topic, state);
1364
+ appendTokenCharCount(safeArgs.mood, state);
1365
+ appendTokenCharCount(safeArgs.identity_query, state);
1366
+ appendTokenCharCount(safeArgs.workflow_query, state);
1367
+ appendTokenCharCount(safeArgs.status_query, state);
1368
+ break;
1369
+ case 'demote_memory':
1370
+ appendTokenCharCount(safeArgs.deprecated_reason, state);
1371
+ break;
1372
+ case 'soft_forget':
1373
+ appendTokenCharCount(safeArgs.reason, state);
1374
+ break;
1375
+ default:
1376
+ if (TOOL_USAGE_INGEST_NAMES.has(normalizedToolName)) {
1377
+ appendTokenCharCount(safeArgs.content, state);
1378
+ appendTokenCharCount(safeArgs.section, state);
1379
+ appendTokenCharCount(safeArgs.source_file, state);
1380
+ appendTokenCharCount(safeArgs.source_user_note, state);
1381
+ } else {
1382
+ appendTokenCharCount(safeArgs, state);
1383
+ }
1384
+ break;
1385
+ }
1386
+ return Math.max(0, Math.round(state.totalChars / 4));
1387
+ }
1388
+ function resolveToolUsageAgentBody(toolName, args = {}) {
1389
+ const safeArgs = args && typeof args === 'object' ? args : {};
1390
+ const explicit = normalizeOptionalAgentBody(safeArgs.agent_body);
1391
+ if (explicit) return explicit;
1392
+ if (toolName === 'recall') {
1393
+ const recallBody = normalizeOptionalAgentBody(safeArgs.body);
1394
+ if (recallBody) return recallBody;
1395
+ }
1396
+ const fromSource = inferAgentBodyFromSource(safeArgs.source);
1397
+ if (fromSource) return fromSource;
1398
+ const fromOrigin = inferAgentBodyFromOrigin(safeArgs.origin);
1399
+ if (fromOrigin) return fromOrigin;
1400
+ const fromDefault = normalizeOptionalAgentBody(DEFAULT_AGENT_BODY);
1401
+ if (fromDefault) return fromDefault;
1402
+ return DB_PROFILE;
1403
+ }
1404
+ async function writeToolUsageTelemetry(payload = {}) {
1405
+ const toolName = String(payload.tool_name || '')
1406
+ .trim()
1407
+ .toLowerCase();
1408
+ if (!toolName) return;
1409
+ const latencyMsRaw = Number(payload.latency_ms);
1410
+ const latencyMs =
1411
+ Number.isFinite(latencyMsRaw) && latencyMsRaw >= 0
1412
+ ? Math.trunc(latencyMsRaw)
1413
+ : 0;
1414
+ const tokensEstimateRaw = Number(payload.tokens_estimate);
1415
+ const tokensEstimate =
1416
+ Number.isFinite(tokensEstimateRaw) && tokensEstimateRaw >= 0
1417
+ ? Math.trunc(tokensEstimateRaw)
1418
+ : 0;
1419
+ const timestamp =
1420
+ normalizeOptionalIsoTimestamp(payload.timestamp, 'timestamp') ||
1421
+ new Date().toISOString();
1422
+ const agentBody =
1423
+ normalizeOptionalAgentBody(payload.agent_body) || DB_PROFILE;
1424
+ try {
1425
+ await supabaseRequest(
1426
+ `/rest/v1/memory_tool_usage?select=${TOOL_USAGE_INSERT_SELECT_COLUMNS}`,
1427
+ {
1428
+ method: 'POST',
1429
+ profile: DB_PROFILE,
1430
+ prefer: 'return=minimal',
1431
+ body: [
1432
+ {
1433
+ tool_name: toolName,
1434
+ timestamp,
1435
+ latency_ms: latencyMs,
1436
+ tokens_estimate: tokensEstimate,
1437
+ agent_body: agentBody
1438
+ }
1439
+ ]
1440
+ }
1441
+ );
1442
+ } catch (error) {
1443
+ console.warn(
1444
+ `[telemetry] failed to persist memory_tool_usage: ${String(error?.message || error)}`
1445
+ );
1446
+ }
1447
+ }
1448
+
1449
+ function normalizeDailyTopics(value) {
1450
+ if (!Array.isArray(value)) return [];
1451
+ return value
1452
+ .map((item) => normalizeOptionalText(item, 60))
1453
+ .filter(Boolean)
1454
+ .slice(0, 20);
1455
+ }
1456
+
1457
+ function dailyDateKey(date = new Date()) {
1458
+ return formatDateOnly(toUtcStartOfDay(date));
1459
+ }
1460
+ async function fetchToolUsageDailySummary(dateKey) {
1461
+ const normalizedDate = normalizeChunkDate(dateKey);
1462
+ try {
1463
+ const result = await supabaseRequest('/rest/v1/rpc/memory_tool_usage_daily_summary', {
1464
+ method: 'POST',
1465
+ profile: DB_PROFILE,
1466
+ body: {
1467
+ p_date: normalizedDate
1468
+ }
1469
+ });
1470
+ const row = Array.isArray(result) ? result[0] : null;
1471
+ return {
1472
+ status: 'ok',
1473
+ date: normalizedDate,
1474
+ recall_count: Math.max(0, Number(row?.recall_count) || 0),
1475
+ insert_count: Math.max(0, Number(row?.insert_count) || 0),
1476
+ ingest_count: Math.max(0, Number(row?.ingest_count) || 0),
1477
+ total_count: Math.max(0, Number(row?.total_count) || 0)
1478
+ };
1479
+ } catch (error) {
1480
+ return {
1481
+ status: 'unavailable',
1482
+ date: normalizedDate,
1483
+ recall_count: 0,
1484
+ insert_count: 0,
1485
+ ingest_count: 0,
1486
+ total_count: 0,
1487
+ message: normalizeOptionalText(error?.message || String(error), 280)
1488
+ };
1489
+ }
1490
+ }
1491
+
1492
+ function buildLifecycleSessionId(kind, source, dateKey) {
1493
+ return `${kind}:${source}:${dateKey}`;
1494
+ }
1495
+
1496
+ function parseJsonObject(raw) {
1497
+ if (typeof raw !== 'string' || !raw.trim()) return null;
1498
+ try {
1499
+ const parsed = JSON.parse(raw);
1500
+ return parsed && typeof parsed === 'object' ? parsed : null;
1501
+ } catch {
1502
+ return null;
1503
+ }
1504
+ }
1505
+
1506
+ function isSessionCloseSessionId(sessionId) {
1507
+ const normalized = String(sessionId || '').trim().toLowerCase();
1508
+ return (
1509
+ normalized.startsWith('session_close:') ||
1510
+ normalized.startsWith('daily_close:')
1511
+ );
1512
+ }
1513
+
1514
+ function extractSessionCloseSnapshot(memory) {
1515
+ const sessionId = String(memory?.session_id || '').trim();
1516
+ const payload = parseJsonObject(memory?.body) || {};
1517
+ const payloadType = normalizeOptionalText(payload.type, 80).toLowerCase();
1518
+ const isCloseType =
1519
+ payloadType === 'session_close' ||
1520
+ payloadType === 'session_close_auto_recovery' ||
1521
+ payloadType === 'daily_close';
1522
+ const isCloseRecord = isCloseType || isSessionCloseSessionId(sessionId);
1523
+ if (!isCloseRecord) return null;
1524
+
1525
+ const summaryText =
1526
+ normalizeOptionalText(payload.summary, 1000) ||
1527
+ normalizeOptionalText(memory?.body, 240);
1528
+
1529
+ return {
1530
+ id: memory?.id || null,
1531
+ source: memory?.source || null,
1532
+ session_id: sessionId || null,
1533
+ date: normalizeOptionalText(payload.date, 20) || null,
1534
+ summary: summaryText || null,
1535
+ topics: normalizeDailyTopics(payload.topics),
1536
+ mood: normalizeOptionalText(payload.mood, 80) || null,
1537
+ created_at: memory?.created_at || null,
1538
+ close_type: payloadType || 'session_close'
1539
+ };
1540
+ }
1541
+
1542
+ async function fetchLatestSessionCloseSnapshot(source) {
1543
+ const loadRecentRows = async (sourceFilter, limit) => {
1544
+ const query = new URLSearchParams();
1545
+ query.set('select', MEMORY_SELECT_COLUMNS);
1546
+ query.set('order', 'created_at.desc');
1547
+ query.set('limit', String(limit));
1548
+ if (sourceFilter) {
1549
+ query.set('source', `eq.${sourceFilter}`);
1550
+ }
1551
+ const rows = await supabaseRequest(`/rest/v1/memories?${query.toString()}`, {
1552
+ profile: DB_PROFILE
1553
+ });
1554
+ return Array.isArray(rows) ? rows : [];
1555
+ };
1556
+
1557
+ const sourceScopedRows = await loadRecentRows(source, 80);
1558
+ for (const row of sourceScopedRows) {
1559
+ const snapshot = extractSessionCloseSnapshot(row);
1560
+ if (snapshot) return snapshot;
1561
+ }
1562
+
1563
+ const crossSourceRows = await loadRecentRows('', 120);
1564
+ for (const row of crossSourceRows) {
1565
+ const snapshot = extractSessionCloseSnapshot(row);
1566
+ if (snapshot) return snapshot;
1567
+ }
1568
+
1569
+ return null;
1570
+ }
1571
+
1572
+ function buildDailyBootQueries(args = {}) {
1573
+ const defaults = DAILY_BOOT_QUERY_DEFAULTS[DB_PROFILE] || DAILY_BOOT_QUERY_DEFAULTS.coco;
1574
+ const agentName =
1575
+ normalizeOptionalText(args.agent_name || args.body_name, 80) || defaults.workflow_subject;
1576
+ const userName = normalizeOptionalText(args.user_name, 80);
1577
+ const identityQuery =
1578
+ normalizeOptionalText(args.identity_query, 300) || defaults.identity_query;
1579
+ const workflowQuery =
1580
+ normalizeOptionalText(args.workflow_query, 300) ||
1581
+ `${agentName} workflow 符號 操作姿勢`;
1582
+ const statusQuery =
1583
+ normalizeOptionalText(args.status_query, 300) ||
1584
+ (userName
1585
+ ? `${userName} ${DAILY_BOOT_STATUS_QUERY_SUFFIX}`
1586
+ : DAILY_BOOT_STATUS_QUERY_SUFFIX);
1587
+ const recallLimit = clampInteger(args.recall_limit, 1, 10, 5);
1588
+
1589
+ return {
1590
+ agent_name: agentName,
1591
+ user_name: userName,
1592
+ identity_query: identityQuery,
1593
+ workflow_query: workflowQuery,
1594
+ status_query: statusQuery,
1595
+ recall_limit: recallLimit
1596
+ };
1597
+ }
1598
+
1599
+ function summarizeRecallStep(result) {
1600
+ const items = Array.isArray(result?.items) ? result.items : [];
1601
+ const countRaw = Number(result?.count);
1602
+ const count = Number.isFinite(countRaw) ? countRaw : items.length;
1603
+ const topMatches = items.slice(0, 3).map((item) => ({
1604
+ source_file: item?.source_file || null,
1605
+ section: item?.section || null,
1606
+ similarity: Number.isFinite(Number(item?.similarity))
1607
+ ? Number(item.similarity)
1608
+ : null,
1609
+ excerpt: normalizeOptionalText(item?.content || '', 160)
1610
+ }));
1611
+ return {
1612
+ count,
1613
+ top_matches: topMatches
1614
+ };
1615
+ }
1616
+ function tokenizeRecallText(text, options = {}) {
1617
+ const raw = String(text || '').trim();
1618
+ if (!raw) return [];
1619
+ const maxTokens = clampInteger(options.max_tokens, 5, 80, 40);
1620
+ const maxHanNgram = clampInteger(options.max_han_ngram, 2, 4, 3);
1621
+ const tokenSet = new Set();
1622
+
1623
+ const latinTokens = raw.toLowerCase().match(/[a-z0-9][a-z0-9_-]{1,}/g) || [];
1624
+ for (const token of latinTokens) {
1625
+ tokenSet.add(token);
1626
+ if (tokenSet.size >= maxTokens) {
1627
+ return Array.from(tokenSet).slice(0, maxTokens);
1628
+ }
1629
+ }
1630
+
1631
+ const hanGroups = raw.match(/\p{Script=Han}+/gu) || [];
1632
+ for (const group of hanGroups) {
1633
+ const chars = Array.from(group);
1634
+ if (chars.length < 2) continue;
1635
+ const maxGramSize = Math.min(chars.length, maxHanNgram);
1636
+ for (let gramSize = maxGramSize; gramSize >= 2; gramSize -= 1) {
1637
+ for (let index = 0; index <= chars.length - gramSize; index += 1) {
1638
+ tokenSet.add(chars.slice(index, index + gramSize).join(''));
1639
+ if (tokenSet.size >= maxTokens) {
1640
+ return Array.from(tokenSet).slice(0, maxTokens);
1641
+ }
1642
+ }
1643
+ }
1644
+ }
1645
+
1646
+ return Array.from(tokenSet).slice(0, maxTokens);
1647
+ }
1648
+ function findRecallTokenIndex(text, token) {
1649
+ const content = String(text || '');
1650
+ const keyword = String(token || '');
1651
+ if (!content || !keyword) return -1;
1652
+ if (/^[a-z0-9_-]+$/i.test(keyword)) {
1653
+ return content.toLowerCase().indexOf(keyword.toLowerCase());
1654
+ }
1655
+ return content.indexOf(keyword);
1656
+ }
1657
+ function buildRecallMatchedSpans(content, overlapTokens, options = {}) {
1658
+ const text = String(content || '');
1659
+ if (!text) return [];
1660
+ const maxSpans = clampInteger(options.max_spans, 1, 8, 3);
1661
+ const contextRadius = clampInteger(options.context_radius, 10, 120, 24);
1662
+ const spans = [];
1663
+ const seen = new Set();
1664
+
1665
+ for (const token of Array.isArray(overlapTokens) ? overlapTokens : []) {
1666
+ if (spans.length >= maxSpans) break;
1667
+ const index = findRecallTokenIndex(text, token);
1668
+ if (index < 0) continue;
1669
+ const start = Math.max(0, index - contextRadius);
1670
+ const end = Math.min(text.length, index + String(token).length + contextRadius);
1671
+ const segment = text.slice(start, end).replace(/\s+/g, ' ').trim();
1672
+ if (!segment) continue;
1673
+ const span = `${start > 0 ? '…' : ''}${segment}${end < text.length ? '…' : ''}`;
1674
+ const dedupeKey = `${token}::${span}`;
1675
+ if (seen.has(dedupeKey)) continue;
1676
+ seen.add(dedupeKey);
1677
+ spans.push({
1678
+ keyword: token,
1679
+ span
1680
+ });
1681
+ }
1682
+
1683
+ return spans;
1684
+ }
1685
+ function buildRecallTimeHint(item, nowTs = new Date()) {
1686
+ const dateText = normalizeOptionalText(item?.date, 20);
1687
+ const referenceTs = parseTimestamp(
1688
+ dateText ? `${dateText}T00:00:00.000Z` : item?.created_at
1689
+ );
1690
+ if (!referenceTs) return '';
1691
+ const deltaDays = Math.abs(
1692
+ diffDaysUtc(toUtcStartOfDay(referenceTs), toUtcStartOfDay(nowTs))
1693
+ );
1694
+ if (deltaDays <= 14) {
1695
+ return `時間接近近期(約 ${deltaDays} 日內)`;
1696
+ }
1697
+ const normalizedDate = dateText || referenceTs.toISOString().slice(0, 10);
1698
+ return `記錄時間 ${normalizedDate}`;
1699
+ }
1700
+ function buildRecallExplainSentence(item, overlapTokens, options = {}) {
1701
+ const reasons = [];
1702
+ if (overlapTokens.length === 1) {
1703
+ reasons.push(`命中關鍵詞「${overlapTokens[0]}」`);
1704
+ } else if (overlapTokens.length > 1) {
1705
+ reasons.push(`同時命中關鍵詞「${overlapTokens.slice(0, 3).join('、')}」`);
1706
+ }
1707
+ const similarity = Number(item?.similarity);
1708
+ if (Number.isFinite(similarity)) {
1709
+ reasons.push(`語義相似度 ${(similarity * 100).toFixed(1)}%`);
1710
+ }
1711
+ const timeHint = buildRecallTimeHint(item, options.now || new Date());
1712
+ if (timeHint) {
1713
+ reasons.push(timeHint);
1714
+ }
1715
+ if (reasons.length === 0) {
1716
+ return '主要由語義向量相近命中。';
1717
+ }
1718
+ return `因為${reasons.join(',')}。`;
1719
+ }
1720
+ function explainRecall(queryText, results = [], options = {}) {
1721
+ const debugExplain = options.debug_explain === true;
1722
+ const queryTokens = tokenizeRecallText(queryText, {
1723
+ max_tokens: 40,
1724
+ max_han_ngram: 3
1725
+ });
1726
+ const nowTs = parseTimestamp(options.now) || new Date();
1727
+ const items = Array.isArray(results) ? results : [];
1728
+ const explainedItems = items.map((item) => {
1729
+ const hitTokens = tokenizeRecallText(item?.content || '', {
1730
+ max_tokens: 60,
1731
+ max_han_ngram: 3
1732
+ });
1733
+ const hitTokenSet = new Set(hitTokens);
1734
+ const overlapTokens = queryTokens
1735
+ .filter((token) => hitTokenSet.has(token))
1736
+ .slice(0, 8);
1737
+ const matchedSpans = buildRecallMatchedSpans(item?.content || '', overlapTokens, {
1738
+ max_spans: 3,
1739
+ context_radius: 24
1740
+ });
1741
+ const recallExplain = buildRecallExplainSentence(item, overlapTokens, {
1742
+ now: nowTs
1743
+ });
1744
+ return {
1745
+ ...item,
1746
+ keywords_overlap: overlapTokens,
1747
+ matched_spans: matchedSpans,
1748
+ recall_explain: recallExplain,
1749
+ ...(debugExplain
1750
+ ? {
1751
+ debug_explain: {
1752
+ query_tokens: queryTokens,
1753
+ hit_tokens: hitTokens,
1754
+ overlap_tokens: overlapTokens
1755
+ }
1756
+ }
1757
+ : {})
1758
+ };
1759
+ });
1760
+
1761
+ return {
1762
+ query_tokens: queryTokens,
1763
+ items: explainedItems
1764
+ };
1765
+ }
1766
+
1767
+ function summarizeHealthStep(result) {
1768
+ return {
1769
+ total_chunks: Number(result?.count_chunks?.total_chunks || 0),
1770
+ soon_expiring_count: Number(result?.expiry_alert?.soon_expiring_count || 0),
1771
+ recommended_for_promotion_count: Number(
1772
+ result?.expiry_alert?.recommended_for_promotion_count || 0
1773
+ ),
1774
+ narrative: normalizeOptionalText(result?.narrative || '', 1200)
1775
+ };
1776
+ }
1777
+ function summarizeSessionBootHealthStep(result) {
1778
+ return {
1779
+ mode: 'expiry_only',
1780
+ alert_window_hours: Number(result?.expiry_alert?.alert_window_hours || 0),
1781
+ soon_expiring_count: Number(result?.expiry_alert?.soon_expiring_count || 0),
1782
+ recommended_for_promotion_count: Number(
1783
+ result?.expiry_alert?.recommended_for_promotion_count || 0
1784
+ ),
1785
+ narrative: normalizeOptionalText(result?.narrative || '', 600)
1786
+ };
1787
+ }
1788
+
1789
+ function buildLifecycleMemoryTags(baseTags, source, dateKey, extraTags = []) {
1790
+ return normalizeTags([
1791
+ ...baseTags,
1792
+ `source:${source}`,
1793
+ `date:${dateKey}`,
1794
+ `profile:${DB_PROFILE}`,
1795
+ ...extraTags
1796
+ ]);
1797
+ }
1798
+
1799
+ async function fetchMemoryBySessionId(sessionId, source) {
1800
+ const query = new URLSearchParams();
1801
+ query.set('select', MEMORY_SELECT_COLUMNS);
1802
+ query.set('source', `eq.${source}`);
1803
+ query.set('session_id', `eq.${sessionId}`);
1804
+ query.set('order', 'created_at.desc');
1805
+ query.set('limit', '1');
1806
+ const rows = await supabaseRequest(`/rest/v1/memories?${query.toString()}`, {
1807
+ profile: DB_PROFILE
1808
+ });
1809
+ return Array.isArray(rows) && rows.length > 0 ? rows[0] : null;
1810
+ }
1811
+ async function fetchMemoryById(memoryId) {
1812
+ const query = new URLSearchParams();
1813
+ query.set('select', MEMORY_SELECT_COLUMNS);
1814
+ query.set('id', `eq.${memoryId}`);
1815
+ query.set('limit', '1');
1816
+ const rows = await supabaseRequest(`/rest/v1/memories?${query.toString()}`, {
1817
+ profile: DB_PROFILE
1818
+ });
1819
+ return Array.isArray(rows) && rows.length > 0 ? rows[0] : null;
1820
+ }
1821
+ async function fetchLatestMemoryBySession(sessionId, source = '') {
1822
+ const query = new URLSearchParams();
1823
+ query.set('select', MEMORY_SELECT_COLUMNS);
1824
+ query.set('session_id', `eq.${sessionId}`);
1825
+ if (source) {
1826
+ query.set('source', `eq.${source}`);
1827
+ }
1828
+ query.set('order', 'created_at.desc');
1829
+ query.set('limit', '1');
1830
+ const rows = await supabaseRequest(`/rest/v1/memories?${query.toString()}`, {
1831
+ profile: DB_PROFILE
1832
+ });
1833
+ return Array.isArray(rows) && rows.length > 0 ? rows[0] : null;
1834
+ }
1835
+ async function fetchChunkById(chunkId) {
1836
+ const query = new URLSearchParams();
1837
+ query.set(
1838
+ 'select',
1839
+ 'id,content,source_file,section,body,visibility,tags,type,date,origin,source_memory_id,source_session_id,source_tool,source_user_note,agent_body,environment,created_at,updated_at'
1840
+ );
1841
+ query.set('id', `eq.${chunkId}`);
1842
+ query.set('limit', '1');
1843
+ const rows = await supabaseRequest(`/rest/v1/marsvault_chunks?${query.toString()}`, {
1844
+ profile: DB_PROFILE
1845
+ });
1846
+ return Array.isArray(rows) && rows.length > 0 ? rows[0] : null;
1847
+ }
1848
+ async function markMemoryAsPromoted(memoryId) {
1849
+ const query = new URLSearchParams();
1850
+ query.set('id', `eq.${memoryId}`);
1851
+ query.set('select', 'id,promoted,promoted_at');
1852
+ const promotedAt = new Date().toISOString();
1853
+ const rows = await supabaseRequest(`/rest/v1/memories?${query.toString()}`, {
1854
+ method: 'PATCH',
1855
+ profile: DB_PROFILE,
1856
+ prefer: 'return=representation',
1857
+ body: {
1858
+ promoted: true,
1859
+ promoted_at: promotedAt
1860
+ }
1861
+ });
1862
+ return Array.isArray(rows) && rows.length > 0 ? rows[0] : null;
1863
+ }
1864
+
1865
+ async function upsertMemoryBySession({
1866
+ session_id,
1867
+ source,
1868
+ body,
1869
+ tags,
1870
+ expires_at,
1871
+ existing,
1872
+ agent_body,
1873
+ environment
1874
+ }) {
1875
+ const resolvedAgentBody =
1876
+ normalizeOptionalAgentBody(agent_body) || resolveWriteAgentBody({ source });
1877
+ const resolvedEnvironment =
1878
+ normalizeOptionalEnvironment(environment) || resolveWriteEnvironment({ environment });
1879
+ const payload = {
1880
+ body,
1881
+ source,
1882
+ session_id,
1883
+ tags: normalizeMemoryTagsWithContext(tags, resolvedAgentBody, resolvedEnvironment),
1884
+ agent_body: resolvedAgentBody,
1885
+ environment: resolvedEnvironment
1886
+ };
1887
+ if (expires_at) {
1888
+ payload.expires_at = expires_at;
1889
+ }
1890
+
1891
+ if (existing?.id) {
1892
+ const query = new URLSearchParams();
1893
+ query.set('id', `eq.${existing.id}`);
1894
+ query.set('select', MEMORY_SELECT_COLUMNS);
1895
+ const patched = await supabaseRequest(`/rest/v1/memories?${query.toString()}`, {
1896
+ method: 'PATCH',
1897
+ profile: DB_PROFILE,
1898
+ prefer: 'return=representation',
1899
+ body: payload
1900
+ });
1901
+ return Array.isArray(patched) && patched.length > 0 ? patched[0] : existing;
1902
+ }
1903
+
1904
+ const createdId = crypto.randomUUID();
1905
+ const inserted = await supabaseRequest(`/rest/v1/memories?select=${MEMORY_SELECT_COLUMNS}`, {
1906
+ method: 'POST',
1907
+ profile: DB_PROFILE,
1908
+ prefer: 'return=representation',
1909
+ body: [{ id: createdId, ...payload }]
1910
+ });
1911
+ if (Array.isArray(inserted) && inserted.length > 0) {
1912
+ return inserted[0];
1913
+ }
1914
+ const fetched = await fetchMemoryBySessionId(session_id, source);
1915
+ return fetched || null;
1916
+ }
1917
+
1918
+ async function ensureDailyCloseRecovery(source, nowTs) {
1919
+ const yesterdayDate = new Date(toUtcStartOfDay(nowTs).getTime() - 86400000);
1920
+ const yesterdayDateKey = formatDateOnly(yesterdayDate);
1921
+ const yesterdayBootSessionId = buildLifecycleSessionId(
1922
+ 'session_boot',
1923
+ source,
1924
+ yesterdayDateKey
1925
+ );
1926
+ let yesterdayBoot = await fetchMemoryBySessionId(yesterdayBootSessionId, source);
1927
+ if (!yesterdayBoot) {
1928
+ const legacyYesterdayBootSessionId = buildLifecycleSessionId(
1929
+ 'daily_boot',
1930
+ source,
1931
+ yesterdayDateKey
1932
+ );
1933
+ yesterdayBoot = await fetchMemoryBySessionId(legacyYesterdayBootSessionId, source);
1934
+ }
1935
+ if (!yesterdayBoot) {
1936
+ return {
1937
+ checked_date: yesterdayDateKey,
1938
+ needed: false,
1939
+ inserted: false,
1940
+ reason: 'no_yesterday_boot'
1941
+ };
1942
+ }
1943
+ const closeSessionId = buildLifecycleSessionId('session_close', source, yesterdayDateKey);
1944
+ let yesterdayClose = await fetchMemoryBySessionId(closeSessionId, source);
1945
+ if (!yesterdayClose) {
1946
+ const legacyCloseSessionId = buildLifecycleSessionId('daily_close', source, yesterdayDateKey);
1947
+ yesterdayClose = await fetchMemoryBySessionId(legacyCloseSessionId, source);
1948
+ }
1949
+ if (yesterdayClose) {
1950
+ return {
1951
+ checked_date: yesterdayDateKey,
1952
+ needed: false,
1953
+ inserted: false
1954
+ };
1955
+ }
1956
+
1957
+ const recoverySessionId = `session_close:auto:${source}:${yesterdayDateKey}`;
1958
+ let existingRecovery = await fetchMemoryBySessionId(recoverySessionId, source);
1959
+ if (!existingRecovery) {
1960
+ const legacyRecoverySessionId = `daily_close:auto:${source}:${yesterdayDateKey}`;
1961
+ existingRecovery = await fetchMemoryBySessionId(legacyRecoverySessionId, source);
1962
+ }
1963
+ if (existingRecovery) {
1964
+ return {
1965
+ checked_date: yesterdayDateKey,
1966
+ needed: true,
1967
+ inserted: false,
1968
+ recovery_id: existingRecovery.id
1969
+ };
1970
+ }
1971
+
1972
+ const summaryText = `[auto] 昨日 ${yesterdayDateKey} 冇 session_close,今日 boot 時補記`;
1973
+ const recoveryPayload = {
1974
+ type: 'session_close_auto_recovery',
1975
+ profile: DB_PROFILE,
1976
+ source,
1977
+ date: yesterdayDateKey,
1978
+ summary: summaryText,
1979
+ created_at: nowTs.toISOString()
1980
+ };
1981
+ const tags = buildLifecycleMemoryTags(
1982
+ ['session_close', 'auto-補救'],
1983
+ source,
1984
+ yesterdayDateKey
1985
+ );
1986
+ const expiresAt = new Date(nowTs.getTime() + 7 * 24 * 3600000).toISOString();
1987
+ const inserted = await upsertMemoryBySession({
1988
+ session_id: recoverySessionId,
1989
+ source,
1990
+ body: JSON.stringify(recoveryPayload),
1991
+ tags,
1992
+ expires_at: expiresAt,
1993
+ existing: null
1994
+ });
1995
+
1996
+ return {
1997
+ checked_date: yesterdayDateKey,
1998
+ needed: true,
1999
+ inserted: true,
2000
+ recovery_id: inserted?.id || null,
2001
+ summary: summaryText
2002
+ };
2003
+ }
2004
+
2005
+ async function runDailyBoot(args = {}) {
2006
+ const source = normalizeDailySource(args.source);
2007
+ const topic = normalizeOptionalText(args.topic, 200);
2008
+ const mood = normalizeOptionalText(args.mood, 80);
2009
+ const queries = buildDailyBootQueries(args);
2010
+ const inferredSourceAgentBody = inferAgentBodyFromSource(source);
2011
+ const recallScopeArgs = {
2012
+ scope: 'this_body',
2013
+ ...(inferredSourceAgentBody ? { agent_body: inferredSourceAgentBody } : {})
2014
+ };
2015
+ const nowTs = new Date();
2016
+ const dateKey = dailyDateKey(nowTs);
2017
+ const heartbeatSessionId = buildLifecycleSessionId('session_boot', source, dateKey);
2018
+ let existingHeartbeat = await fetchMemoryBySessionId(heartbeatSessionId, source);
2019
+ if (!existingHeartbeat) {
2020
+ const legacyHeartbeatSessionId = buildLifecycleSessionId('daily_boot', source, dateKey);
2021
+ existingHeartbeat = await fetchMemoryBySessionId(legacyHeartbeatSessionId, source);
2022
+ }
2023
+ const heartbeatTags = buildLifecycleMemoryTags(['heartbeat', 'session_boot'], source, dateKey);
2024
+ const heartbeatExpiry = new Date(nowTs.getTime() + 24 * 3600000).toISOString();
2025
+
2026
+ const soulRecall = await callTool('recall', {
2027
+ query: queries.identity_query,
2028
+ limit: queries.recall_limit,
2029
+ ...recallScopeArgs
2030
+ });
2031
+ const workflowRecall = await callTool('recall', {
2032
+ query: queries.workflow_query,
2033
+ limit: queries.recall_limit,
2034
+ ...recallScopeArgs
2035
+ });
2036
+ const statusRecall = await callTool('recall', {
2037
+ query: queries.status_query,
2038
+ limit: queries.recall_limit,
2039
+ ...recallScopeArgs
2040
+ });
2041
+ const statusSummary = summarizeRecallStep(statusRecall);
2042
+ let fallbackUsed = false;
2043
+ let statusFallback = null;
2044
+ let contextLayer = 'marsvault_recall';
2045
+ if (statusSummary.count <= 0) {
2046
+ statusFallback = await fetchLatestSessionCloseSnapshot(source);
2047
+ if (statusFallback) {
2048
+ fallbackUsed = true;
2049
+ contextLayer = 'session_close_fallback';
2050
+ } else {
2051
+ contextLayer = 'empty_boot';
2052
+ }
2053
+ }
2054
+ const contextMessage =
2055
+ contextLayer === 'marsvault_recall'
2056
+ ? 'session_boot 使用 MarsVault recall 作為當前上下文(Level 1/2)'
2057
+ : contextLayer === 'session_close_fallback'
2058
+ ? 'session_boot 未命中 MarsVault,已降級使用最近 session_close(Level 3)'
2059
+ : 'session_boot 未命中 MarsVault,且無可用 session_close,使用空白啟動';
2060
+ const healthArgs = {};
2061
+ if (args.alert_window_hours !== undefined) {
2062
+ healthArgs.alert_window_hours = args.alert_window_hours;
2063
+ }
2064
+ const health = await runHealthExpiryCheck(healthArgs);
2065
+ const previousPayload = existingHeartbeat
2066
+ ? parseJsonObject(existingHeartbeat.body) || {}
2067
+ : {};
2068
+ const previousTopic = normalizeOptionalText(previousPayload.topic, 200);
2069
+ const mergedTopic = topic || previousTopic || '';
2070
+ const mergedMood = mood || normalizeOptionalText(previousPayload.mood, 80) || '';
2071
+ const heartbeatPayload = {
2072
+ type: 'session_boot_heartbeat',
2073
+ profile: DB_PROFILE,
2074
+ source,
2075
+ date: dateKey,
2076
+ agent_name: queries.agent_name,
2077
+ user_name: queries.user_name || null,
2078
+ topic: mergedTopic || null,
2079
+ mood: mergedMood || null,
2080
+ identity_query: queries.identity_query,
2081
+ workflow_query: queries.workflow_query,
2082
+ status_query: queries.status_query,
2083
+ updated_at: nowTs.toISOString()
2084
+ };
2085
+ const savedHeartbeat = await upsertMemoryBySession({
2086
+ session_id: heartbeatSessionId,
2087
+ source,
2088
+ body: JSON.stringify(heartbeatPayload),
2089
+ tags: heartbeatTags,
2090
+ expires_at: heartbeatExpiry,
2091
+ existing: existingHeartbeat
2092
+ });
2093
+ const autoRecoveredClose = existingHeartbeat
2094
+ ? {
2095
+ checked_date: dateKey,
2096
+ needed: false,
2097
+ inserted: false,
2098
+ reason: 'already_session_booted_today'
2099
+ }
2100
+ : await ensureDailyCloseRecovery(source, nowTs);
2101
+
2102
+ return {
2103
+ ok: true,
2104
+ profile: DB_PROFILE,
2105
+ mode: existingHeartbeat ? 'welcome_back' : 'new_day',
2106
+ source,
2107
+ date: dateKey,
2108
+ heartbeat_id: savedHeartbeat?.id || null,
2109
+ agent_name: queries.agent_name,
2110
+ last_topic: previousTopic || null,
2111
+ current_topic: mergedTopic || null,
2112
+ mood: mergedMood || null,
2113
+ queries: {
2114
+ identity_query: queries.identity_query,
2115
+ workflow_query: queries.workflow_query,
2116
+ status_query: queries.status_query
2117
+ },
2118
+ fallback_used: fallbackUsed,
2119
+ context_layer: contextLayer,
2120
+ context_message: contextMessage,
2121
+ soul: summarizeRecallStep(soulRecall),
2122
+ workflow: summarizeRecallStep(workflowRecall),
2123
+ status: {
2124
+ ...statusSummary,
2125
+ fallback_used: fallbackUsed,
2126
+ context_layer: contextLayer,
2127
+ fallback_session_close: statusFallback
2128
+ },
2129
+ health: summarizeSessionBootHealthStep(health),
2130
+ auto_recovered_close: autoRecoveredClose
2131
+ };
2132
+ }
2133
+
2134
+ async function runDailyClose(args = {}) {
2135
+ const source = normalizeDailySource(args.source);
2136
+ const summary = normalizeOptionalText(args.summary, 6000);
2137
+ if (!summary) {
2138
+ throw new Error('summary is required');
2139
+ }
2140
+ const topics = normalizeDailyTopics(args.topics);
2141
+ const mood = normalizeOptionalText(args.mood, 80);
2142
+ const nowTs = new Date();
2143
+ const dateKey = dailyDateKey(nowTs);
2144
+ const toolUsageDailySummary = await fetchToolUsageDailySummary(dateKey);
2145
+ const closeSessionId = buildLifecycleSessionId('session_close', source, dateKey);
2146
+ let existingClose = await fetchMemoryBySessionId(closeSessionId, source);
2147
+ if (!existingClose) {
2148
+ const legacyCloseSessionId = buildLifecycleSessionId('daily_close', source, dateKey);
2149
+ existingClose = await fetchMemoryBySessionId(legacyCloseSessionId, source);
2150
+ }
2151
+ const topicTags = topics.map((topic) => `topic:${normalizeTopicTag(topic).slice(0, 40)}`);
2152
+ const closeTags = buildLifecycleMemoryTags(['session_close'], source, dateKey, topicTags);
2153
+ const closePayload = {
2154
+ type: 'session_close',
2155
+ profile: DB_PROFILE,
2156
+ source,
2157
+ date: dateKey,
2158
+ summary,
2159
+ topics,
2160
+ mood: mood || null,
2161
+ tool_usage_daily_summary: toolUsageDailySummary,
2162
+ updated_at: nowTs.toISOString()
2163
+ };
2164
+ const expiresAt = new Date(nowTs.getTime() + 7 * 24 * 3600000).toISOString();
2165
+ const savedClose = await upsertMemoryBySession({
2166
+ session_id: closeSessionId,
2167
+ source,
2168
+ body: JSON.stringify(closePayload),
2169
+ tags: closeTags,
2170
+ expires_at: expiresAt,
2171
+ existing: existingClose
2172
+ });
2173
+ const expirySnapshot = await runHealthExpiryCheck({
2174
+ alert_window_hours: 48
2175
+ });
2176
+ const soonExpiringCount = Math.max(
2177
+ 0,
2178
+ Number(expirySnapshot?.expiry_alert?.soon_expiring_count) || 0
2179
+ );
2180
+ const recommendPromoteCount = Math.max(
2181
+ 0,
2182
+ Number(expirySnapshot?.expiry_alert?.recommended_for_promotion_count) || 0
2183
+ );
2184
+ const expiryAction =
2185
+ recommendPromoteCount > 0
2186
+ ? '建議叫 Hermes 或三哥 promote'
2187
+ : soonExpiringCount > 0
2188
+ ? '有短記憶即將到期,建議先做 health_check 檢視'
2189
+ : null;
2190
+
2191
+ return {
2192
+ ok: true,
2193
+ profile: DB_PROFILE,
2194
+ mode: existingClose ? 'updated' : 'created',
2195
+ close_id: savedClose?.id || null,
2196
+ source,
2197
+ date: dateKey,
2198
+ topics,
2199
+ mood: mood || null,
2200
+ tool_usage_daily_summary: toolUsageDailySummary,
2201
+ expiry_alert: {
2202
+ soon_expiring_count: soonExpiringCount,
2203
+ recommend_promote_count: recommendPromoteCount,
2204
+ action: expiryAction
2205
+ }
2206
+ };
2207
+ }
2208
+
2209
+ async function fetchPaginatedRows(table, selectColumns, options = {}) {
2210
+ const pageSize = clampInteger(options.page_size, 100, 2000, 1000);
2211
+ const maxRows = clampInteger(options.max_rows, 1000, 50000, 20000);
2212
+ const order = String(options.order || '').trim();
2213
+ const filters = Array.isArray(options.filters) ? options.filters : [];
2214
+
2215
+ const rows = [];
2216
+ let offset = 0;
2217
+ let truncated = false;
2218
+
2219
+ while (rows.length < maxRows) {
2220
+ const query = new URLSearchParams();
2221
+ query.set('select', selectColumns);
2222
+ query.set('limit', String(pageSize));
2223
+ query.set('offset', String(offset));
2224
+ if (order) {
2225
+ query.set('order', order);
2226
+ }
2227
+ for (const filter of filters) {
2228
+ if (!filter || !filter.key) continue;
2229
+ if (filter.value === undefined || filter.value === null) continue;
2230
+ query.set(filter.key, String(filter.value));
2231
+ }
2232
+
2233
+ const batch = await supabaseRequest(`/rest/v1/${table}?${query.toString()}`, {
2234
+ profile: DB_PROFILE
2235
+ });
2236
+ if (!Array.isArray(batch) || batch.length === 0) {
2237
+ break;
2238
+ }
2239
+
2240
+ const remaining = maxRows - rows.length;
2241
+ if (batch.length > remaining) {
2242
+ rows.push(...batch.slice(0, remaining));
2243
+ truncated = true;
2244
+ break;
2245
+ }
2246
+
2247
+ rows.push(...batch);
2248
+ if (batch.length < pageSize) {
2249
+ break;
2250
+ }
2251
+ offset += batch.length;
2252
+ }
2253
+
2254
+ return { rows, truncated, page_size: pageSize, max_rows: maxRows };
2255
+ }
2256
+
2257
+ function evaluatePromotionCandidate(memory, nowTs, longTermTagSet) {
2258
+ const body = String(memory?.body || '').trim();
2259
+ const tags = normalizeTags(memory?.tags).map(normalizeTopicTag).filter(Boolean);
2260
+ const expiresAt = parseTimestamp(memory?.expires_at);
2261
+ const alreadyPromoted = Boolean(memory?.promoted) || Boolean(memory?.promoted_at);
2262
+ if (alreadyPromoted) {
2263
+ return {
2264
+ recommend: 'N',
2265
+ score: 0,
2266
+ reason: '已 promoted'
2267
+ };
2268
+ }
2269
+ const reasons = [];
2270
+ let score = 0;
2271
+
2272
+ if (tags.length > 0) {
2273
+ score += 1;
2274
+ reasons.push('有 tags');
2275
+ }
2276
+ if (body.length >= 120) {
2277
+ score += 1;
2278
+ reasons.push('內容較完整');
2279
+ }
2280
+ score += 1;
2281
+ reasons.push('未曾 promoted');
2282
+ if (tags.some((tag) => longTermTagSet.has(tag))) {
2283
+ score += 1;
2284
+ reasons.push('同現有長記憶主題有連結');
2285
+ }
2286
+ if (expiresAt) {
2287
+ const hoursLeft = Math.floor((expiresAt.getTime() - nowTs.getTime()) / 3600000);
2288
+ if (hoursLeft <= 12) {
2289
+ score += 1;
2290
+ reasons.push('12小時內到期');
2291
+ }
2292
+ }
2293
+
2294
+ return {
2295
+ recommend: score >= 2 ? 'Y' : 'N',
2296
+ score,
2297
+ reason: reasons.join('、') || '訊息不足,保守不升級'
2298
+ };
2299
+ }
2300
+ function isSessionBootSessionId(sessionId) {
2301
+ const normalized = String(sessionId || '').trim().toLowerCase();
2302
+ return (
2303
+ normalized.startsWith('session_boot:') ||
2304
+ normalized.startsWith('daily_boot:')
2305
+ );
2306
+ }
2307
+ function normalizeSemanticTags(tags) {
2308
+ return normalizeTags(tags)
2309
+ .map((tag) => normalizeTopicTag(tag))
2310
+ .filter(
2311
+ (tag) =>
2312
+ tag &&
2313
+ !tag.startsWith('agent_body:') &&
2314
+ !tag.startsWith('environment:')
2315
+ );
2316
+ }
2317
+ function isLifecycleMemoryRow(memory) {
2318
+ const sessionId = String(memory?.session_id || '').trim();
2319
+ if (isSessionBootSessionId(sessionId) || isSessionCloseSessionId(sessionId)) {
2320
+ return true;
2321
+ }
2322
+ const tagSet = new Set(normalizeSemanticTags(memory?.tags));
2323
+ return (
2324
+ tagSet.has('session_boot') ||
2325
+ tagSet.has('session_close') ||
2326
+ tagSet.has('heartbeat')
2327
+ );
2328
+ }
2329
+ function buildForgetCandidates(memoryRows, nowTs, thresholdDays, limit) {
2330
+ const nowDate = toUtcStartOfDay(nowTs);
2331
+ const candidates = [];
2332
+ for (const memory of memoryRows) {
2333
+ if (Boolean(memory?.promoted) || Boolean(memory?.promoted_at)) continue;
2334
+ if (isLifecycleMemoryRow(memory)) continue;
2335
+ const createdAt = parseTimestamp(memory?.created_at);
2336
+ if (!createdAt) continue;
2337
+ const ageDays = Math.max(0, diffDaysUtc(toUtcStartOfDay(createdAt), nowDate));
2338
+ if (ageDays < thresholdDays) continue;
2339
+
2340
+ const semanticTags = normalizeSemanticTags(memory?.tags);
2341
+ const body = String(memory?.body || '').trim();
2342
+ const expiresAt = parseTimestamp(memory?.expires_at);
2343
+ const reasons = [];
2344
+ if (semanticTags.length === 0) reasons.push('無主題 tags');
2345
+ if (body.length < 120) reasons.push('內容較短');
2346
+ if (expiresAt && expiresAt.getTime() - nowTs.getTime() > 7 * 24 * 3600000) {
2347
+ reasons.push('距離自然到期仍遠');
2348
+ }
2349
+ if (reasons.length < 2) continue;
2350
+
2351
+ candidates.push({
2352
+ id: memory?.id || null,
2353
+ source: memory?.source || null,
2354
+ session_id: memory?.session_id || null,
2355
+ created_at: memory?.created_at || null,
2356
+ expires_at: memory?.expires_at || null,
2357
+ age_days: ageDays,
2358
+ tags: normalizeTags(memory?.tags),
2359
+ excerpt: body.slice(0, 140),
2360
+ reason: reasons.join('、')
2361
+ });
2362
+ }
2363
+ candidates.sort(
2364
+ (left, right) =>
2365
+ (right.age_days || 0) - (left.age_days || 0) ||
2366
+ String(left.id || '').localeCompare(String(right.id || ''))
2367
+ );
2368
+ return {
2369
+ stale_days_threshold: thresholdDays,
2370
+ total_candidates: candidates.length,
2371
+ limit,
2372
+ candidates: candidates.slice(0, limit)
2373
+ };
2374
+ }
2375
+
2376
+ function buildHealthNarrative(payload) {
2377
+ const richTopic =
2378
+ payload.coverage_map.rich_topics.length > 0
2379
+ ? `${payload.coverage_map.rich_topics[0].topic}(${payload.coverage_map.rich_topics[0].count})`
2380
+ : '未形成明顯主題';
2381
+ const sparseTopics =
2382
+ payload.coverage_map.sparse_topics.length > 0
2383
+ ? payload.coverage_map.sparse_topics.map((item) => item.topic).join('、')
2384
+ : '未見明顯稀疏主題';
2385
+ const timelineGapText =
2386
+ payload.count_chunks.gaps_over_threshold.length > 0
2387
+ ? `有 ${payload.count_chunks.gaps_over_threshold.length} 段超過門檻的時間空白`
2388
+ : '未見超過門檻的時間空白';
2389
+ const conflictStatus = String(payload?.detect_conflicts?.status || '');
2390
+ const conflictSummary =
2391
+ conflictStatus === 'ok'
2392
+ ? `高相似記憶對有 ${payload.detect_conflicts.total_pairs} 組,其中 SUPERSEDED ${payload.detect_conflicts.superseded_count} 組、CONFLICT ${payload.detect_conflicts.conflict_count} 組`
2393
+ : conflictStatus === 'unavailable'
2394
+ ? `衝突檢測暫不可用(${normalizeOptionalText(payload?.detect_conflicts?.message || 'unknown', 120)})`
2395
+ : '衝突檢測未執行';
2396
+ const forgetSummary =
2397
+ Number(payload?.forget_candidates?.total_candidates || 0) > 0
2398
+ ? `可考慮 soft_forget 的候選有 ${payload.forget_candidates.total_candidates} 條(已列出前 ${payload.forget_candidates.candidates.length} 條)`
2399
+ : '目前未見需要優先 soft_forget 的候選';
2400
+
2401
+ return `而家我有 ${payload.count_chunks.total_chunks} 個長記憶 chunk,最豐富主題係 ${richTopic}。` +
2402
+ `稀疏區域包括:${sparseTopics}。` +
2403
+ `只活喺短記憶的主題有 ${payload.coverage_map.volatile_topics_total} 個。` +
2404
+ `${payload.expiry_alert.alert_window_hours} 小時內到期短記憶有 ${payload.expiry_alert.soon_expiring_count} 條,` +
2405
+ `其中建議升長記憶 ${payload.expiry_alert.recommended_for_promotion_count} 條。` +
2406
+ `${timelineGapText}。` +
2407
+ `${conflictSummary}。` +
2408
+ `${forgetSummary}。`;
2409
+ }
2410
+ function buildExpiryNarrative({ alertWindowHours, soonExpiringCount, recommendedCount }) {
2411
+ return `${alertWindowHours} 小時內到期短記憶有 ${soonExpiringCount} 條,其中建議升長記憶 ${recommendedCount} 條。`;
2412
+ }
2413
+ function normalizeSimilarityThreshold(value, fallback = 0.85) {
2414
+ const numeric = Number(value);
2415
+ if (!Number.isFinite(numeric)) {
2416
+ return fallback;
2417
+ }
2418
+ return Math.max(0, Math.min(1, numeric));
2419
+ }
2420
+ function resolveChunkMoment(dateValue, createdAtValue) {
2421
+ const dateRaw = String(dateValue || '').trim();
2422
+ if (/^\d{4}-\d{2}-\d{2}$/.test(dateRaw)) {
2423
+ return new Date(`${dateRaw}T00:00:00.000Z`);
2424
+ }
2425
+ return parseTimestamp(createdAtValue);
2426
+ }
2427
+ function buildConflictPair(row, conflictWindowDays) {
2428
+ const leftMoment = resolveChunkMoment(row?.left_date, row?.left_created_at);
2429
+ const rightMoment = resolveChunkMoment(row?.right_date, row?.right_created_at);
2430
+ let newer = {
2431
+ id: row?.left_id || null,
2432
+ date: row?.left_date || null,
2433
+ source_file: row?.left_source_file || null,
2434
+ section: row?.left_section || null,
2435
+ tags: normalizeTags(row?.left_tags),
2436
+ excerpt: normalizeOptionalText(row?.left_content || '', 160)
2437
+ };
2438
+ let older = {
2439
+ id: row?.right_id || null,
2440
+ date: row?.right_date || null,
2441
+ source_file: row?.right_source_file || null,
2442
+ section: row?.right_section || null,
2443
+ tags: normalizeTags(row?.right_tags),
2444
+ excerpt: normalizeOptionalText(row?.right_content || '', 160)
2445
+ };
2446
+ if (
2447
+ leftMoment &&
2448
+ rightMoment &&
2449
+ rightMoment.getTime() > leftMoment.getTime()
2450
+ ) {
2451
+ newer = {
2452
+ id: row?.right_id || null,
2453
+ date: row?.right_date || null,
2454
+ source_file: row?.right_source_file || null,
2455
+ section: row?.right_section || null,
2456
+ tags: normalizeTags(row?.right_tags),
2457
+ excerpt: normalizeOptionalText(row?.right_content || '', 160)
2458
+ };
2459
+ older = {
2460
+ id: row?.left_id || null,
2461
+ date: row?.left_date || null,
2462
+ source_file: row?.left_source_file || null,
2463
+ section: row?.left_section || null,
2464
+ tags: normalizeTags(row?.left_tags),
2465
+ excerpt: normalizeOptionalText(row?.left_content || '', 160)
2466
+ };
2467
+ }
2468
+ const deltaDays =
2469
+ leftMoment && rightMoment
2470
+ ? Math.abs(
2471
+ diffDaysUtc(
2472
+ toUtcStartOfDay(leftMoment),
2473
+ toUtcStartOfDay(rightMoment)
2474
+ )
2475
+ )
2476
+ : null;
2477
+ const relation =
2478
+ deltaDays !== null && deltaDays <= conflictWindowDays
2479
+ ? 'CONFLICT'
2480
+ : 'SUPERSEDED';
2481
+ const reason =
2482
+ relation === 'CONFLICT'
2483
+ ? `時間距離 ${deltaDays ?? '未知'} 日,屬同期高相似內容`
2484
+ : deltaDays === null
2485
+ ? '缺少時間資訊,先歸類為 SUPERSEDED'
2486
+ : `時間距離 ${deltaDays} 日,較像新記憶取代舊記憶`;
2487
+
2488
+ return {
2489
+ relation,
2490
+ similarity: Number.isFinite(Number(row?.similarity))
2491
+ ? Number(row.similarity)
2492
+ : 0,
2493
+ delta_days: deltaDays,
2494
+ reason,
2495
+ older,
2496
+ newer
2497
+ };
2498
+ }
2499
+ function hasHermesDigestSourcePrefix(value) {
2500
+ const sourceFile = String(value || '').trim().toLowerCase();
2501
+ return (
2502
+ sourceFile.startsWith('hermes/digest/') ||
2503
+ sourceFile.startsWith('hermes/totodigest/')
2504
+ );
2505
+ }
2506
+ function isHermesDigestConflictChunk(chunk) {
2507
+ const section = String(chunk?.section || '').trim().toLowerCase();
2508
+ const tagSet = new Set(
2509
+ normalizeTags(chunk?.tags).map((tag) => normalizeTopicTag(tag)).filter(Boolean)
2510
+ );
2511
+ const hasDigestTag = tagSet.has('digest');
2512
+ const hasCronTag = tagSet.has('cron');
2513
+ const hasHermesIdentityTag =
2514
+ tagSet.has('hermes') || tagSet.has('coco') || tagSet.has('toto');
2515
+ return (
2516
+ hasHermesDigestSourcePrefix(chunk?.source_file) ||
2517
+ section.startsWith('digest-coco-') ||
2518
+ section.startsWith('digest-toto-') ||
2519
+ (hasDigestTag && hasCronTag && hasHermesIdentityTag)
2520
+ );
2521
+ }
2522
+ function shouldExcludeConflictRow(row) {
2523
+ const leftChunk = {
2524
+ source_file: row?.left_source_file,
2525
+ section: row?.left_section,
2526
+ tags: row?.left_tags
2527
+ };
2528
+ const rightChunk = {
2529
+ source_file: row?.right_source_file,
2530
+ section: row?.right_section,
2531
+ tags: row?.right_tags
2532
+ };
2533
+ return (
2534
+ isHermesDigestConflictChunk(leftChunk) ||
2535
+ isHermesDigestConflictChunk(rightChunk)
2536
+ );
2537
+ }
2538
+ async function runConflictDetection(args = {}) {
2539
+ const similarityThreshold = normalizeSimilarityThreshold(
2540
+ args.similarity_threshold,
2541
+ 0.85
2542
+ );
2543
+ const conflictWindowDays = clampInteger(
2544
+ args.conflict_window_days,
2545
+ 1,
2546
+ 120,
2547
+ 14
2548
+ );
2549
+ const matchCount = clampInteger(args.match_count, 1, 200, 20);
2550
+ const scanLimit = clampInteger(args.scan_limit, 50, 5000, 400);
2551
+ const neighborLimit = clampInteger(args.neighbor_limit, 1, 20, 6);
2552
+
2553
+ try {
2554
+ const result = await supabaseRequest('/rest/v1/rpc/detect_marsvault_conflicts', {
2555
+ method: 'POST',
2556
+ profile: DB_PROFILE,
2557
+ body: {
2558
+ p_similarity_threshold: similarityThreshold,
2559
+ p_match_count: matchCount,
2560
+ p_scan_limit: scanLimit,
2561
+ p_neighbor_limit: neighborLimit
2562
+ }
2563
+ });
2564
+ const rows = Array.isArray(result) ? result : [];
2565
+ const filteredRows = rows.filter((row) => !shouldExcludeConflictRow(row));
2566
+ const excludedHermesDigestPairs = Math.max(0, rows.length - filteredRows.length);
2567
+ const pairs = filteredRows.map((row) => buildConflictPair(row, conflictWindowDays));
2568
+ const supersededCount = pairs.filter(
2569
+ (item) => item.relation === 'SUPERSEDED'
2570
+ ).length;
2571
+ const conflictCount = pairs.filter(
2572
+ (item) => item.relation === 'CONFLICT'
2573
+ ).length;
2574
+
2575
+ return {
2576
+ status: 'ok',
2577
+ similarity_threshold: similarityThreshold,
2578
+ conflict_window_days: conflictWindowDays,
2579
+ scan_limit: scanLimit,
2580
+ neighbor_limit: neighborLimit,
2581
+ match_count: matchCount,
2582
+ total_pairs: pairs.length,
2583
+ excluded_hermes_digest_pairs: excludedHermesDigestPairs,
2584
+ superseded_count: supersededCount,
2585
+ conflict_count: conflictCount,
2586
+ pairs
2587
+ };
2588
+ } catch (error) {
2589
+ return {
2590
+ status: 'unavailable',
2591
+ similarity_threshold: similarityThreshold,
2592
+ conflict_window_days: conflictWindowDays,
2593
+ scan_limit: scanLimit,
2594
+ neighbor_limit: neighborLimit,
2595
+ match_count: matchCount,
2596
+ total_pairs: 0,
2597
+ excluded_hermes_digest_pairs: 0,
2598
+ superseded_count: 0,
2599
+ conflict_count: 0,
2600
+ pairs: [],
2601
+ message: normalizeOptionalText(error?.message || String(error), 280)
2602
+ };
2603
+ }
2604
+ }
2605
+ async function runHealthExpiryCheck(args = {}) {
2606
+ const alertWindowHours = clampInteger(args.alert_window_hours, 1, 720, 48);
2607
+ const pageSize = clampInteger(args.page_size, 100, 2000, 1000);
2608
+ const maxRows = clampInteger(args.max_rows, 1000, 50000, 20000);
2609
+ const nowTs = new Date();
2610
+ const alertDeadlineTs = new Date(nowTs.getTime() + alertWindowHours * 3600000);
2611
+ const memoryLoad = await fetchPaginatedRows(
2612
+ 'memories',
2613
+ 'id,body,source,session_id,tags,promoted,promoted_at,created_at,expires_at',
2614
+ {
2615
+ page_size: pageSize,
2616
+ max_rows: maxRows,
2617
+ order: 'expires_at.asc'
2618
+ }
2619
+ );
2620
+ const memoryRows = memoryLoad.rows;
2621
+ const chunkLoad = await fetchPaginatedRows('marsvault_chunks', 'id,tags', {
2622
+ page_size: pageSize,
2623
+ max_rows: maxRows,
2624
+ order: 'created_at.desc'
2625
+ });
2626
+ const longTermTagSet = new Set(
2627
+ sortTagEntries(collectTagCounts(chunkLoad.rows)).map(([tag]) => tag)
2628
+ );
2629
+ const soonExpiringRows = memoryRows
2630
+ .map((memory) => ({
2631
+ memory,
2632
+ expires_at_ts: parseTimestamp(memory?.expires_at)
2633
+ }))
2634
+ .filter(
2635
+ (item) =>
2636
+ !Boolean(item.memory?.promoted) &&
2637
+ !Boolean(item.memory?.promoted_at) &&
2638
+ item.expires_at_ts &&
2639
+ item.expires_at_ts.getTime() > nowTs.getTime() &&
2640
+ item.expires_at_ts.getTime() <= alertDeadlineTs.getTime()
2641
+ )
2642
+ .sort((left, right) => left.expires_at_ts.getTime() - right.expires_at_ts.getTime());
2643
+ const soonExpiring = soonExpiringRows.map((entry) => {
2644
+ const recommendation = evaluatePromotionCandidate(entry.memory, nowTs, longTermTagSet);
2645
+ return {
2646
+ id: entry.memory.id,
2647
+ expires_at: entry.memory.expires_at,
2648
+ tags: normalizeTags(entry.memory.tags),
2649
+ excerpt: String(entry.memory.body || '').slice(0, 140),
2650
+ promoted: Boolean(entry.memory.promoted),
2651
+ promoted_at: entry.memory.promoted_at || null,
2652
+ recommend_promote: recommendation.recommend,
2653
+ recommendation_reason: recommendation.reason
2654
+ };
2655
+ });
2656
+ const recommendedForPromotionCount = soonExpiring.filter(
2657
+ (item) => item.recommend_promote === 'Y'
2658
+ ).length;
2659
+ const payload = {
2660
+ ok: true,
2661
+ profile: DB_PROFILE,
2662
+ mode: 'expiry_only',
2663
+ generated_at: nowTs.toISOString(),
2664
+ expiry_alert: {
2665
+ alert_window_hours: alertWindowHours,
2666
+ total_short_memories: memoryRows.length,
2667
+ soon_expiring_count: soonExpiring.length,
2668
+ recommended_for_promotion_count: recommendedForPromotionCount,
2669
+ soon_expiring: soonExpiring
2670
+ },
2671
+ diagnostics: {
2672
+ memory_rows_truncated: memoryLoad.truncated,
2673
+ chunk_rows_truncated: chunkLoad.truncated,
2674
+ page_size: pageSize,
2675
+ max_rows: maxRows
2676
+ }
2677
+ };
2678
+ payload.narrative = buildExpiryNarrative({
2679
+ alertWindowHours,
2680
+ soonExpiringCount: soonExpiring.length,
2681
+ recommendedCount: recommendedForPromotionCount
2682
+ });
2683
+ return payload;
2684
+ }
2685
+
2686
+ async function runHealthCheck(args = {}) {
2687
+ const alertWindowHours = clampInteger(args.alert_window_hours, 1, 720, 48);
2688
+ const gapDays = clampInteger(args.gap_days, 7, 365, 30);
2689
+ const topicLimit = clampInteger(args.topic_limit, 1, 20, 5);
2690
+ const pageSize = clampInteger(args.page_size, 100, 2000, 1000);
2691
+ const maxRows = clampInteger(args.max_rows, 1000, 50000, 20000);
2692
+ const conflictSimilarityThreshold = normalizeSimilarityThreshold(
2693
+ args.conflict_similarity_threshold,
2694
+ 0.85
2695
+ );
2696
+ const conflictWindowDays = clampInteger(args.conflict_window_days, 1, 120, 14);
2697
+ const conflictMatchCount = clampInteger(args.conflict_match_count, 1, 200, 20);
2698
+ const conflictScanLimit = clampInteger(args.conflict_scan_limit, 50, 5000, 400);
2699
+ const conflictNeighborLimit = clampInteger(
2700
+ args.conflict_neighbor_limit,
2701
+ 1,
2702
+ 20,
2703
+ 6
2704
+ );
2705
+ const forgetCandidateDays = clampInteger(args.forget_candidate_days, 3, 180, 14);
2706
+ const forgetCandidateLimit = clampInteger(args.forget_candidate_limit, 1, 50, 10);
2707
+
2708
+ const nowTs = new Date();
2709
+ const nowDate = toUtcStartOfDay(nowTs);
2710
+ const alertDeadlineTs = new Date(nowTs.getTime() + alertWindowHours * 3600000);
2711
+
2712
+ const chunkLoad = await fetchPaginatedRows('marsvault_chunks', 'id,date,created_at,tags', {
2713
+ page_size: pageSize,
2714
+ max_rows: maxRows,
2715
+ order: 'date.asc'
2716
+ });
2717
+ const memoryLoad = await fetchPaginatedRows(
2718
+ 'memories',
2719
+ 'id,body,source,session_id,tags,promoted,promoted_at,created_at,expires_at',
2720
+ {
2721
+ page_size: pageSize,
2722
+ max_rows: maxRows,
2723
+ order: 'expires_at.asc'
2724
+ }
2725
+ );
2726
+
2727
+ const chunkRows = chunkLoad.rows;
2728
+ const memoryRows = memoryLoad.rows;
2729
+
2730
+ const chunkDates = chunkRows
2731
+ .map((row) => parseDateOnly(row?.date || row?.created_at))
2732
+ .filter(Boolean)
2733
+ .sort((left, right) => left.getTime() - right.getTime());
2734
+
2735
+ const monthlyCounts = new Map();
2736
+ for (const chunkDate of chunkDates) {
2737
+ const monthKey = formatDateOnly(chunkDate).slice(0, 7);
2738
+ monthlyCounts.set(monthKey, (monthlyCounts.get(monthKey) || 0) + 1);
2739
+ }
2740
+ const monthlyDistribution = Array.from(monthlyCounts.entries()).map(([month, count]) => ({
2741
+ month,
2742
+ count
2743
+ }));
2744
+
2745
+ const oldestChunkDate = chunkDates.length > 0 ? formatDateOnly(chunkDates[0]) : null;
2746
+ const latestChunkDate =
2747
+ chunkDates.length > 0 ? formatDateOnly(chunkDates[chunkDates.length - 1]) : null;
2748
+
2749
+ const gapsOverThreshold = [];
2750
+ for (let idx = 1; idx < chunkDates.length; idx += 1) {
2751
+ const previous = chunkDates[idx - 1];
2752
+ const current = chunkDates[idx];
2753
+ const quietDays = Math.max(0, diffDaysUtc(previous, current) - 1);
2754
+ if (quietDays > gapDays) {
2755
+ gapsOverThreshold.push({
2756
+ from: formatDateOnly(previous),
2757
+ to: formatDateOnly(current),
2758
+ quiet_days: quietDays
2759
+ });
2760
+ }
2761
+ }
2762
+ if (chunkDates.length > 0) {
2763
+ const latest = chunkDates[chunkDates.length - 1];
2764
+ const quietSinceLatest = diffDaysUtc(latest, nowDate);
2765
+ if (quietSinceLatest > gapDays) {
2766
+ gapsOverThreshold.push({
2767
+ from: formatDateOnly(latest),
2768
+ to: formatDateOnly(nowDate),
2769
+ quiet_days: quietSinceLatest,
2770
+ ongoing: true
2771
+ });
2772
+ }
2773
+ }
2774
+
2775
+ const chunkTagCounts = collectTagCounts(chunkRows);
2776
+ const memoryTagCounts = collectTagCounts(memoryRows);
2777
+ const sortedChunkTags = sortTagEntries(chunkTagCounts);
2778
+ const sortedMemoryTags = sortTagEntries(memoryTagCounts);
2779
+ const longTermTagSet = new Set(sortedChunkTags.map(([tag]) => tag));
2780
+
2781
+ const richTopics = sortedChunkTags.slice(0, topicLimit).map(([topic, count]) => ({
2782
+ topic,
2783
+ count
2784
+ }));
2785
+ const sparseTopics = sortedChunkTags
2786
+ .filter(([, count]) => count <= 1)
2787
+ .slice(0, topicLimit)
2788
+ .map(([topic, count]) => ({ topic, count }));
2789
+ const volatileTopics = sortedMemoryTags
2790
+ .filter(([topic]) => !longTermTagSet.has(topic))
2791
+ .slice(0, topicLimit)
2792
+ .map(([topic, count]) => ({ topic, count }));
2793
+
2794
+ const soonExpiringRows = memoryRows
2795
+ .map((memory) => ({
2796
+ memory,
2797
+ expires_at_ts: parseTimestamp(memory?.expires_at)
2798
+ }))
2799
+ .filter(
2800
+ (item) =>
2801
+ !Boolean(item.memory?.promoted) &&
2802
+ !Boolean(item.memory?.promoted_at) &&
2803
+ item.expires_at_ts &&
2804
+ item.expires_at_ts.getTime() > nowTs.getTime() &&
2805
+ item.expires_at_ts.getTime() <= alertDeadlineTs.getTime()
2806
+ )
2807
+ .sort((left, right) => left.expires_at_ts.getTime() - right.expires_at_ts.getTime());
2808
+
2809
+ const soonExpiring = soonExpiringRows.map((entry) => {
2810
+ const recommendation = evaluatePromotionCandidate(entry.memory, nowTs, longTermTagSet);
2811
+ return {
2812
+ id: entry.memory.id,
2813
+ expires_at: entry.memory.expires_at,
2814
+ tags: normalizeTags(entry.memory.tags),
2815
+ excerpt: String(entry.memory.body || '').slice(0, 140),
2816
+ promoted: Boolean(entry.memory.promoted),
2817
+ promoted_at: entry.memory.promoted_at || null,
2818
+ recommend_promote: recommendation.recommend,
2819
+ recommendation_reason: recommendation.reason
2820
+ };
2821
+ });
2822
+ const recommendedForPromotionCount = soonExpiring.filter(
2823
+ (item) => item.recommend_promote === 'Y'
2824
+ ).length;
2825
+ const conflictDetection = await runConflictDetection({
2826
+ similarity_threshold: conflictSimilarityThreshold,
2827
+ conflict_window_days: conflictWindowDays,
2828
+ match_count: conflictMatchCount,
2829
+ scan_limit: conflictScanLimit,
2830
+ neighbor_limit: conflictNeighborLimit
2831
+ });
2832
+ const forgetCandidates = buildForgetCandidates(
2833
+ memoryRows,
2834
+ nowTs,
2835
+ forgetCandidateDays,
2836
+ forgetCandidateLimit
2837
+ );
2838
+
2839
+ const payload = {
2840
+ ok: true,
2841
+ profile: DB_PROFILE,
2842
+ generated_at: nowTs.toISOString(),
2843
+ count_chunks: {
2844
+ total_chunks: chunkRows.length,
2845
+ monthly_distribution: monthlyDistribution,
2846
+ oldest_chunk_date: oldestChunkDate,
2847
+ latest_chunk_date: latestChunkDate,
2848
+ gaps_over_threshold: gapsOverThreshold,
2849
+ gap_days_threshold: gapDays
2850
+ },
2851
+ expiry_alert: {
2852
+ alert_window_hours: alertWindowHours,
2853
+ total_short_memories: memoryRows.length,
2854
+ soon_expiring_count: soonExpiring.length,
2855
+ recommended_for_promotion_count: recommendedForPromotionCount,
2856
+ soon_expiring: soonExpiring
2857
+ },
2858
+ coverage_map: {
2859
+ long_term_tag_topics_total: sortedChunkTags.length,
2860
+ rich_topics: richTopics,
2861
+ sparse_topics: sparseTopics,
2862
+ volatile_topics_total: sortedMemoryTags.filter(([topic]) => !longTermTagSet.has(topic)).length,
2863
+ volatile_topics: volatileTopics,
2864
+ blank_topics_estimate: volatileTopics.map((item) => item.topic)
2865
+ },
2866
+ detect_conflicts: conflictDetection,
2867
+ forget_candidates: {
2868
+ ...forgetCandidates,
2869
+ action:
2870
+ forgetCandidates.total_candidates > 0
2871
+ ? 'suggest_only_use_soft_forget_to_apply'
2872
+ : null
2873
+ },
2874
+ diagnostics: {
2875
+ chunk_rows_truncated: chunkLoad.truncated,
2876
+ memory_rows_truncated: memoryLoad.truncated,
2877
+ page_size: pageSize,
2878
+ max_rows: maxRows,
2879
+ forget_candidate_days: forgetCandidateDays,
2880
+ forget_candidate_limit: forgetCandidateLimit
2881
+ }
2882
+ };
2883
+ payload.narrative = buildHealthNarrative(payload);
2884
+
2885
+ return payload;
2886
+ }
2887
+
2888
+ function parseFiniteNumber(value) {
2889
+ const numeric = Number(value);
2890
+ return Number.isFinite(numeric) ? numeric : NaN;
2891
+ }
2892
+
2893
+ function normalizeEmbeddingVector(rawVector) {
2894
+ if (!Array.isArray(rawVector) || rawVector.length === 0) {
2895
+ throw new Error('embedding vector missing');
2896
+ }
2897
+ const values = rawVector.map((value) => parseFiniteNumber(value));
2898
+ if (values.some((value) => Number.isNaN(value))) {
2899
+ throw new Error('embedding vector contains non-numeric values');
2900
+ }
2901
+ return values;
2902
+ }
2903
+
2904
+ function embeddingVectorToText(rawVector) {
2905
+ const vector = normalizeEmbeddingVector(rawVector);
2906
+ if (vector.length !== JINA_EMBEDDING_DIMENSIONS_SAFE) {
2907
+ throw new Error(
2908
+ `embedding dimensions mismatch: expected ${JINA_EMBEDDING_DIMENSIONS_SAFE}, got ${vector.length}`
2909
+ );
2910
+ }
2911
+ return `[${vector.join(',')}]`;
2912
+ }
2913
+
2914
+ async function createJinaEmbedding(inputText, task) {
2915
+ if (!JINA_API_KEY) {
2916
+ throw new Error('JINA_API_KEY is not configured');
2917
+ }
2918
+ const text = String(inputText || '').trim();
2919
+ if (!text) {
2920
+ throw new Error('embedding input text is required');
2921
+ }
2922
+ const payload = {
2923
+ model: JINA_EMBEDDING_MODEL,
2924
+ input: [text],
2925
+ embedding_type: 'float',
2926
+ dimensions: JINA_EMBEDDING_DIMENSIONS_SAFE
2927
+ };
2928
+ if (task) {
2929
+ payload.task = task;
2930
+ }
2931
+
2932
+ const response = await fetch(JINA_EMBEDDING_API_URL, {
2933
+ method: 'POST',
2934
+ headers: {
2935
+ Authorization: `Bearer ${JINA_API_KEY}`,
2936
+ 'content-type': 'application/json'
2937
+ },
2938
+ body: JSON.stringify(payload)
2939
+ });
2940
+
2941
+ const raw = await response.text();
2942
+ let parsed;
2943
+ try {
2944
+ parsed = raw ? JSON.parse(raw) : null;
2945
+ } catch {
2946
+ parsed = raw;
2947
+ }
2948
+
2949
+ if (!response.ok) {
2950
+ const detail = typeof parsed === 'string' ? parsed : JSON.stringify(parsed ?? {});
2951
+ throw new Error(`Jina embeddings ${response.status}: ${detail}`);
2952
+ }
2953
+
2954
+ const embedding = parsed?.data?.[0]?.embedding;
2955
+ return normalizeEmbeddingVector(embedding);
2956
+ }
2957
+
2958
+ async function setMemoryEmbedding(memoryId, embeddingVector) {
2959
+ const embeddingText = embeddingVectorToText(embeddingVector);
2960
+ await supabaseRequest('/rest/v1/rpc/set_memory_embedding', {
2961
+ method: 'POST',
2962
+ profile: DB_PROFILE,
2963
+ body: {
2964
+ p_memory_id: memoryId,
2965
+ p_embedding_text: embeddingText
2966
+ }
2967
+ });
2968
+ }
2969
+ function normalizeMemoryIngestOrigin(value) {
2970
+ if (DB_PROFILE === 'coco') {
2971
+ const normalized = String(value || PROFILE.memoryIngestDefaultOrigin)
2972
+ .trim()
2973
+ .toLowerCase();
2974
+ if (!COCO_MEMORY_INGEST_ORIGIN_WHITELIST.has(normalized)) {
2975
+ throw new Error(
2976
+ 'origin must be one of perplexity-coco/cursor-coco/warp-coco/leo-manual/hermes-coco-digest'
2977
+ );
2978
+ }
2979
+ return normalized;
2980
+ }
2981
+
2982
+ const normalized = String(value || '').trim();
2983
+ if (!normalized) {
2984
+ throw new Error('origin is required');
2985
+ }
2986
+ return normalized;
2987
+ }
2988
+
2989
+ async function ingestMarsvaultChunks(args = {}, options = {}) {
2990
+ const {
2991
+ defaultType = 'digest',
2992
+ defaultSourceFile = 'Hermes/Digest/auto.md',
2993
+ defaultSectionPrefix = 'digest',
2994
+ defaultOrigin = PROFILE.digestDefaultOrigin,
2995
+ body = DB_PROFILE,
2996
+ fixedTags = [],
2997
+ normalizeOrigin = null
2998
+ } = options;
2999
+
3000
+ const content = String(args.content || '').trim();
3001
+ if (!content) {
3002
+ throw new Error('content is required');
3003
+ }
3004
+ if (content.length > 250000) {
3005
+ throw new Error('content too long (max 250000 chars)');
3006
+ }
3007
+ if (!JINA_API_KEY) {
3008
+ throw new Error('JINA_API_KEY is not configured');
3009
+ }
3010
+
3011
+ const normalizedBody = normalizeChunkBody(body);
3012
+ if (normalizedBody !== DB_PROFILE) {
3013
+ throw new Error(`${DB_PROFILE} gateway only accepts body=${DB_PROFILE} for ingest`);
3014
+ }
3015
+ const visibility = normalizeChunkVisibility(args.visibility);
3016
+ const type = String(args.type || defaultType).trim() || defaultType;
3017
+ const date = normalizeChunkDate(args.date);
3018
+ const sourceFile = String(args.source_file || defaultSourceFile).trim() || defaultSourceFile;
3019
+ const sectionPrefix = String(args.section || defaultSectionPrefix).trim() || defaultSectionPrefix;
3020
+ const originRaw = String(args.origin || defaultOrigin).trim() || defaultOrigin;
3021
+ const origin = typeof normalizeOrigin === 'function' ? normalizeOrigin(originRaw) : originRaw;
3022
+ const sourceMemoryId = normalizeOptionalUuid(args.source_memory_id, 'source_memory_id') || null;
3023
+ const sourceSessionId = normalizeOptionalText(args.source_session_id, 200) || null;
3024
+ const sourceTool = normalizeOptionalSourceTool(args.source_tool) || null;
3025
+ const sourceUserNote = normalizeOptionalText(args.source_user_note, 500) || null;
3026
+ const agentBody = resolveWriteAgentBody({
3027
+ ...args,
3028
+ source_tool: sourceTool || args.source_tool,
3029
+ origin
3030
+ });
3031
+ const environment = resolveWriteEnvironment(args);
3032
+ const chunkTexts = splitDigestContent(content, args.max_chunk_chars);
3033
+ if (chunkTexts.length === 0) {
3034
+ throw new Error('content produced no chunks');
3035
+ }
3036
+
3037
+ const baseTags = normalizeTags(args.tags);
3038
+ const mergedTagSet = new Set([...fixedTags, ...baseTags]);
3039
+ const tags = normalizeMemoryTagsWithContext(Array.from(mergedTagSet), agentBody, environment);
3040
+ const rows = [];
3041
+
3042
+ for (let index = 0; index < chunkTexts.length; index += 1) {
3043
+ const chunkText = chunkTexts[index];
3044
+ const embedding = await createJinaEmbedding(chunkText, 'retrieval.passage');
3045
+ const embeddingText = embeddingVectorToText(embedding);
3046
+ const contentHash = crypto.createHash('sha256').update(chunkText).digest('hex');
3047
+ rows.push({
3048
+ id: crypto.randomUUID(),
3049
+ content: chunkText,
3050
+ embedding: embeddingText,
3051
+ source_file: sourceFile,
3052
+ section: `${sectionPrefix}#${index + 1}`,
3053
+ body: normalizedBody,
3054
+ visibility,
3055
+ tags,
3056
+ type,
3057
+ date,
3058
+ content_hash: contentHash,
3059
+ origin,
3060
+ source_memory_id: sourceMemoryId,
3061
+ source_session_id: sourceSessionId,
3062
+ source_tool: sourceTool,
3063
+ source_user_note: sourceUserNote,
3064
+ agent_body: agentBody,
3065
+ environment
3066
+ });
3067
+ }
3068
+
3069
+ const insertedRows = await supabaseRequest(
3070
+ '/rest/v1/marsvault_chunks?on_conflict=source_file,section,content_hash,body&select=id,source_file,section,body,visibility,type,date,origin,source_memory_id,source_session_id,source_tool,source_user_note,agent_body,environment,created_at,updated_at',
3071
+ {
3072
+ method: 'POST',
3073
+ profile: DB_PROFILE,
3074
+ prefer: 'resolution=merge-duplicates,return=representation',
3075
+ body: rows
3076
+ }
3077
+ );
3078
+
3079
+ return {
3080
+ ok: true,
3081
+ chunk_count: chunkTexts.length,
3082
+ inserted_count: Array.isArray(insertedRows) ? insertedRows.length : 0,
3083
+ source_file: sourceFile,
3084
+ section_prefix: sectionPrefix,
3085
+ body: normalizedBody,
3086
+ visibility,
3087
+ origin,
3088
+ source_memory_id: sourceMemoryId,
3089
+ source_session_id: sourceSessionId,
3090
+ source_tool: sourceTool,
3091
+ source_user_note: sourceUserNote,
3092
+ agent_body: agentBody,
3093
+ environment,
3094
+ date,
3095
+ items: Array.isArray(insertedRows) ? insertedRows : []
3096
+ };
3097
+ }
3098
+ function buildPromoteTimeline(chunk, sourceMemory) {
3099
+ const sourceCreatedAt = parseTimestamp(sourceMemory?.created_at);
3100
+ const sourcePromotedAt = parseTimestamp(sourceMemory?.promoted_at);
3101
+ const chunkCreatedAt = parseTimestamp(chunk?.created_at);
3102
+ const sourceToChunkSeconds =
3103
+ sourceCreatedAt && chunkCreatedAt
3104
+ ? Math.max(0, Math.floor((chunkCreatedAt.getTime() - sourceCreatedAt.getTime()) / 1000))
3105
+ : null;
3106
+ const sourceToPromotedSeconds =
3107
+ sourceCreatedAt && sourcePromotedAt
3108
+ ? Math.max(0, Math.floor((sourcePromotedAt.getTime() - sourceCreatedAt.getTime()) / 1000))
3109
+ : null;
3110
+ return {
3111
+ source_memory_created_at: sourceMemory?.created_at || null,
3112
+ source_memory_promoted_at: sourceMemory?.promoted_at || null,
3113
+ chunk_created_at: chunk?.created_at || null,
3114
+ source_to_chunk_seconds: sourceToChunkSeconds,
3115
+ source_to_promoted_seconds: sourceToPromotedSeconds
3116
+ };
3117
+ }
3118
+ async function runExplainMemory(args = {}) {
3119
+ const chunkId = normalizeOptionalUuid(args.id, 'id');
3120
+ if (!chunkId) {
3121
+ throw new Error('id is required');
3122
+ }
3123
+
3124
+ const chunk = await fetchChunkById(chunkId);
3125
+ if (!chunk) {
3126
+ throw new Error(`memory chunk not found: ${chunkId}`);
3127
+ }
3128
+
3129
+ const sourceMemoryId = normalizeOptionalUuid(chunk.source_memory_id, 'source_memory_id') || '';
3130
+ const sourceSessionId = normalizeOptionalText(chunk.source_session_id, 200) || '';
3131
+ const sourceTool = normalizeOptionalSourceTool(chunk.source_tool) || '';
3132
+ const sourceUserNote = normalizeOptionalText(chunk.source_user_note, 500) || '';
3133
+
3134
+ let sourceMemory = null;
3135
+ if (sourceMemoryId) {
3136
+ sourceMemory = await fetchMemoryById(sourceMemoryId);
3137
+ }
3138
+ if (!sourceMemory && sourceSessionId) {
3139
+ sourceMemory = await fetchLatestMemoryBySession(sourceSessionId, sourceTool);
3140
+ }
3141
+
3142
+ return {
3143
+ ok: true,
3144
+ id: chunk.id,
3145
+ chunk: {
3146
+ id: chunk.id,
3147
+ source_file: chunk.source_file || null,
3148
+ section: chunk.section || null,
3149
+ type: chunk.type || null,
3150
+ date: chunk.date || null,
3151
+ origin: chunk.origin || null,
3152
+ excerpt: normalizeOptionalText(chunk.content, 220) || null,
3153
+ created_at: chunk.created_at || null
3154
+ },
3155
+ source_short_memory: sourceMemory
3156
+ ? {
3157
+ id: sourceMemory.id || null,
3158
+ source: sourceMemory.source || null,
3159
+ session_id: sourceMemory.session_id || null,
3160
+ excerpt: normalizeOptionalText(sourceMemory.body, 280) || null,
3161
+ created_at: sourceMemory.created_at || null,
3162
+ promoted: Boolean(sourceMemory.promoted),
3163
+ promoted_at: sourceMemory.promoted_at || null
3164
+ }
3165
+ : null,
3166
+ source_session_meta: {
3167
+ source_memory_id: sourceMemoryId || sourceMemory?.id || null,
3168
+ source_session_id: sourceSessionId || sourceMemory?.session_id || null,
3169
+ source_tool: sourceTool || sourceMemory?.source || null,
3170
+ source_user_note: sourceUserNote || null
3171
+ },
3172
+ promote_timeline: buildPromoteTimeline(chunk, sourceMemory)
3173
+ };
3174
+ }
3175
+
3176
+ function resolveToolName(name) {
3177
+ const normalized = String(name || '').trim();
3178
+ if (!normalized) return '';
3179
+ if (normalized === 'daily_boot') return 'session_boot';
3180
+ if (normalized === 'daily_close') return 'session_close';
3181
+ if (normalized === 'ingest_marsvault_digest') return 'dream_ingest';
3182
+ if (PROFILE.memoryIngestLegacyToolNames.includes(normalized)) {
3183
+ return PROFILE.memoryIngestToolName;
3184
+ }
3185
+ return normalized;
3186
+ }
3187
+
3188
+ async function callTool(name, args = {}) {
3189
+ const toolName = resolveToolName(name);
3190
+ const telemetryStartedAtMs = Date.now();
3191
+ const telemetryStartedAtIso = new Date(telemetryStartedAtMs).toISOString();
3192
+ const telemetryTokensEstimate = estimateToolUsageTokens(toolName, args);
3193
+ const telemetryAgentBody = resolveToolUsageAgentBody(toolName, args);
3194
+ try {
3195
+ if (toolName === 'insert_memory') {
3196
+ const body = String(args.body || '').trim();
3197
+ const source = String(args.source || '').trim();
3198
+ const sessionId = String(args.session_id || '').trim();
3199
+ const agentBody = resolveWriteAgentBody({ ...args, source });
3200
+ const environment = resolveWriteEnvironment(args);
3201
+ const tags = normalizeMemoryTagsWithContext(args.tags, agentBody, environment);
3202
+
3203
+ if (!body) {
3204
+ throw new Error('body is required');
3205
+ }
3206
+ if (body.length > 12000) {
3207
+ throw new Error('body too long (max 12000 chars)');
3208
+ }
3209
+ validateMemorySource(source);
3210
+ if (!sessionId) {
3211
+ throw new Error('session_id is required');
3212
+ }
3213
+
3214
+ const memoryId = crypto.randomUUID();
3215
+ const payload = {
3216
+ id: memoryId,
3217
+ body,
3218
+ source,
3219
+ session_id: sessionId,
3220
+ tags,
3221
+ agent_body: agentBody,
3222
+ environment
3223
+ };
3224
+ if (typeof args.expires_at === 'string' && args.expires_at.trim()) {
3225
+ payload.expires_at = args.expires_at.trim();
3226
+ }
3227
+
3228
+ const result = await supabaseRequest(
3229
+ '/rest/v1/memories?select=id,source,session_id,tags,agent_body,environment,created_at,expires_at',
3230
+ {
3231
+ method: 'POST',
3232
+ profile: DB_PROFILE,
3233
+ prefer: 'return=representation',
3234
+ body: [payload]
3235
+ }
3236
+ );
3237
+ let inserted = result?.[0] ?? null;
3238
+ if (!inserted) {
3239
+ const fetched = await supabaseRequest(
3240
+ `/rest/v1/memories?id=eq.${memoryId}&select=id,source,session_id,tags,agent_body,environment,created_at,expires_at`,
3241
+ { profile: DB_PROFILE }
3242
+ );
3243
+ inserted = fetched?.[0] ?? null;
3244
+ }
3245
+ let embedding_status = 'skipped_no_api_key';
3246
+ let embedding_error = null;
3247
+ if (JINA_API_KEY) {
3248
+ try {
3249
+ const embedding = await createJinaEmbedding(body, 'retrieval.passage');
3250
+ await setMemoryEmbedding(memoryId, embedding);
3251
+ embedding_status = 'stored';
3252
+ } catch (error) {
3253
+ embedding_status = 'failed';
3254
+ embedding_error = String(error?.message || error);
3255
+ }
3256
+ }
3257
+
3258
+ return {
3259
+ ok: true,
3260
+ inserted,
3261
+ embedding_status,
3262
+ ...(embedding_error ? { embedding_error } : {})
3263
+ };
3264
+ }
3265
+
3266
+ if (toolName === 'list_memories') {
3267
+ const limitRaw = Number(args.limit ?? 20);
3268
+ const limit = Number.isFinite(limitRaw)
3269
+ ? Math.max(1, Math.min(100, Math.trunc(limitRaw)))
3270
+ : 20;
3271
+ const source = args.source ? String(args.source).trim() : '';
3272
+ const unexpiredOnly = args.unexpired_only !== false;
3273
+ if (source) {
3274
+ validateMemorySource(source, { required: false });
3275
+ }
3276
+
3277
+ const query = new URLSearchParams();
3278
+ query.set(
3279
+ 'select',
3280
+ 'id,body,source,session_id,tags,agent_body,environment,promoted,promoted_at,created_at,expires_at'
3281
+ );
3282
+ query.set('order', 'created_at.desc');
3283
+ query.set('limit', String(limit));
3284
+ if (source) {
3285
+ query.set('source', `eq.${source}`);
3286
+ }
3287
+ if (unexpiredOnly) {
3288
+ query.set('expires_at', 'gt.now()');
3289
+ }
3290
+
3291
+ const result = await supabaseRequest(`/rest/v1/memories?${query.toString()}`, {
3292
+ profile: DB_PROFILE
3293
+ });
3294
+ return {
3295
+ ok: true,
3296
+ count: Array.isArray(result) ? result.length : 0,
3297
+ items: Array.isArray(result) ? result : []
3298
+ };
3299
+ }
3300
+
3301
+ if (toolName === 'search_memories') {
3302
+ const queryText = String(args.query || '').trim();
3303
+ const limitRaw = Number(args.limit ?? 20);
3304
+ const limit = Number.isFinite(limitRaw)
3305
+ ? Math.max(1, Math.min(100, Math.trunc(limitRaw)))
3306
+ : 20;
3307
+ const source = args.source ? String(args.source).trim() : '';
3308
+ const unexpiredOnly = args.unexpired_only !== false;
3309
+ const minSimilarityRaw = args.min_similarity;
3310
+ const minSimilarity =
3311
+ minSimilarityRaw === undefined || minSimilarityRaw === null
3312
+ ? null
3313
+ : Number(minSimilarityRaw);
3314
+
3315
+ if (!queryText) {
3316
+ throw new Error('query is required');
3317
+ }
3318
+ if (source) {
3319
+ validateMemorySource(source, { required: false });
3320
+ }
3321
+ if (minSimilarity !== null && !Number.isFinite(minSimilarity)) {
3322
+ throw new Error('min_similarity must be a finite number');
3323
+ }
3324
+ if (!JINA_API_KEY) {
3325
+ throw new Error('JINA_API_KEY is not configured');
3326
+ }
3327
+ const scopeFilters = resolveScopeFilters(args, inferAgentBodyFromSource(source));
3328
+
3329
+ const queryEmbedding = await createJinaEmbedding(queryText, 'retrieval.query');
3330
+ const queryEmbeddingText = embeddingVectorToText(queryEmbedding);
3331
+ const result = await supabaseRequest('/rest/v1/rpc/search_memories_semantic', {
3332
+ method: 'POST',
3333
+ profile: DB_PROFILE,
3334
+ body: {
3335
+ p_query_embedding_text: queryEmbeddingText,
3336
+ p_match_count: limit,
3337
+ p_source: source || null,
3338
+ p_unexpired_only: unexpiredOnly,
3339
+ p_scope: scopeFilters.scope,
3340
+ p_agent_body: scopeFilters.agent_body,
3341
+ p_environment: scopeFilters.environment
3342
+ }
3343
+ });
3344
+
3345
+ const items = Array.isArray(result) ? result : [];
3346
+ const filteredItems =
3347
+ minSimilarity === null
3348
+ ? items
3349
+ : items.filter((item) => Number(item?.similarity) >= minSimilarity);
3350
+
3351
+ return {
3352
+ ok: true,
3353
+ count: filteredItems.length,
3354
+ scope: scopeFilters,
3355
+ items: filteredItems
3356
+ };
3357
+ }
3358
+ if (toolName === 'reload_source_registry') {
3359
+ return await reloadSourceRegistryCache({ manual: true });
3360
+ }
3361
+
3362
+ if (toolName === 'recall') {
3363
+ const queryText = String(args.query || '').trim();
3364
+ const limitRaw = Number(args.limit ?? 5);
3365
+ const limit = Number.isFinite(limitRaw)
3366
+ ? Math.max(1, Math.min(50, Math.trunc(limitRaw)))
3367
+ : 5;
3368
+ const debugExplainRaw = args.debug_explain;
3369
+ const debugExplain =
3370
+ debugExplainRaw === undefined ? false : debugExplainRaw === true;
3371
+ const body = args.body ? String(args.body).trim() : DB_PROFILE;
3372
+ const includeGlobal = args.include_global !== false;
3373
+ const includeShared = args.include_shared !== false;
3374
+ const includePrivate = args.include_private !== false;
3375
+ const type = args.type ? String(args.type).trim() : '';
3376
+ const minSimilarityRaw = args.min_similarity;
3377
+ const minSimilarity =
3378
+ minSimilarityRaw === undefined || minSimilarityRaw === null
3379
+ ? null
3380
+ : Number(minSimilarityRaw);
3381
+
3382
+ if (!queryText) {
3383
+ throw new Error('query is required');
3384
+ }
3385
+ if (!PROFILE.recallBodyEnum.includes(body)) {
3386
+ throw new Error(RECALL_BODY_VALIDATION_MESSAGE);
3387
+ }
3388
+ if (
3389
+ debugExplainRaw !== undefined &&
3390
+ typeof debugExplainRaw !== 'boolean'
3391
+ ) {
3392
+ throw new Error('debug_explain must be a boolean');
3393
+ }
3394
+ if (minSimilarity !== null && !Number.isFinite(minSimilarity)) {
3395
+ throw new Error('min_similarity must be a finite number');
3396
+ }
3397
+ if (!JINA_API_KEY) {
3398
+ throw new Error('JINA_API_KEY is not configured');
3399
+ }
3400
+ const scopeFilters = resolveScopeFilters(
3401
+ args,
3402
+ normalizeOptionalAgentBody(args.body)
3403
+ );
3404
+
3405
+ const queryEmbedding = await createJinaEmbedding(queryText, 'retrieval.query');
3406
+ const queryEmbeddingText = embeddingVectorToText(queryEmbedding);
3407
+ const result = await supabaseRequest('/rest/v1/rpc/search_marsvault_chunks_semantic', {
3408
+ method: 'POST',
3409
+ profile: DB_PROFILE,
3410
+ body: {
3411
+ p_query_embedding_text: queryEmbeddingText,
3412
+ p_match_count: limit,
3413
+ p_body: body,
3414
+ p_include_global: includeGlobal,
3415
+ p_include_shared: includeShared,
3416
+ p_include_private: includePrivate,
3417
+ p_type: type || null,
3418
+ p_scope: scopeFilters.scope,
3419
+ p_agent_body: scopeFilters.agent_body,
3420
+ p_environment: scopeFilters.environment
3421
+ }
3422
+ });
3423
+
3424
+ const items = Array.isArray(result) ? result : [];
3425
+ const filteredItems =
3426
+ minSimilarity === null
3427
+ ? items
3428
+ : items.filter((item) => Number(item?.similarity) >= minSimilarity);
3429
+ const explained = explainRecall(queryText, filteredItems, {
3430
+ debug_explain: debugExplain
3431
+ });
3432
+
3433
+ return {
3434
+ ok: true,
3435
+ count: explained.items.length,
3436
+ scope: scopeFilters,
3437
+ ...(debugExplain
3438
+ ? {
3439
+ debug_explain: {
3440
+ query_tokens: explained.query_tokens
3441
+ }
3442
+ }
3443
+ : {}),
3444
+ items: explained.items
3445
+ };
3446
+ }
3447
+ if (toolName === 'explain_memory') {
3448
+ return await runExplainMemory(args);
3449
+ }
3450
+ if (toolName === 'demote_memory') {
3451
+ const chunkId = normalizeOptionalUuid(args.id, 'id');
3452
+ if (!chunkId) {
3453
+ throw new Error('id is required');
3454
+ }
3455
+ const deprecatedReason = normalizeOptionalText(args.deprecated_reason, 500);
3456
+ if (!deprecatedReason) {
3457
+ throw new Error('deprecated_reason is required');
3458
+ }
3459
+ const supersededBy = normalizeOptionalUuid(args.superseded_by, 'superseded_by');
3460
+ if (supersededBy && supersededBy === chunkId) {
3461
+ throw new Error('superseded_by must be different from id');
3462
+ }
3463
+ if (supersededBy) {
3464
+ const replacementChunk = await fetchChunkById(supersededBy);
3465
+ if (!replacementChunk) {
3466
+ throw new Error(`superseded_by chunk not found: ${supersededBy}`);
3467
+ }
3468
+ }
3469
+ const deprecatedAt =
3470
+ normalizeOptionalIsoTimestamp(args.deprecated_at, 'deprecated_at') ||
3471
+ new Date().toISOString();
3472
+ const patchQuery = new URLSearchParams();
3473
+ patchQuery.set('id', `eq.${chunkId}`);
3474
+ patchQuery.set(
3475
+ 'select',
3476
+ 'id,source_file,section,body,visibility,tags,type,date,origin,deprecated_at,deprecated_reason,superseded_by,updated_at'
3477
+ );
3478
+ const rows = await supabaseRequest(
3479
+ `/rest/v1/marsvault_chunks?${patchQuery.toString()}`,
3480
+ {
3481
+ method: 'PATCH',
3482
+ profile: DB_PROFILE,
3483
+ prefer: 'return=representation',
3484
+ body: {
3485
+ deprecated_at: deprecatedAt,
3486
+ deprecated_reason: deprecatedReason,
3487
+ superseded_by: supersededBy || null
3488
+ }
3489
+ }
3490
+ );
3491
+ const demoted = Array.isArray(rows) ? rows[0] : null;
3492
+ if (!demoted) {
3493
+ throw new Error(`memory chunk not found: ${chunkId}`);
3494
+ }
3495
+ return {
3496
+ ok: true,
3497
+ demoted
3498
+ };
3499
+ }
3500
+ if (toolName === 'soft_forget') {
3501
+ const requestedIds = Array.isArray(args.ids) ? args.ids : [];
3502
+ if (requestedIds.length === 0) {
3503
+ throw new Error('ids is required');
3504
+ }
3505
+ const normalizedIds = [];
3506
+ for (const rawId of requestedIds) {
3507
+ const normalizedId = normalizeOptionalUuid(rawId, 'ids');
3508
+ if (!normalizedId) continue;
3509
+ normalizedIds.push(normalizedId);
3510
+ }
3511
+ const dedupedIds = Array.from(new Set(normalizedIds));
3512
+ if (dedupedIds.length === 0) {
3513
+ throw new Error('ids must contain at least one valid UUID');
3514
+ }
3515
+ const reason = normalizeOptionalText(args.reason, 500);
3516
+ const forgottenAtIso =
3517
+ normalizeOptionalIsoTimestamp(args.forgotten_at, 'forgotten_at') ||
3518
+ new Date().toISOString();
3519
+ const forgottenAtTs = parseTimestamp(forgottenAtIso) || new Date();
3520
+
3521
+ const lookupQuery = new URLSearchParams();
3522
+ lookupQuery.set(
3523
+ 'select',
3524
+ 'id,source,session_id,promoted,promoted_at,created_at,expires_at'
3525
+ );
3526
+ lookupQuery.set('id', `in.(${dedupedIds.join(',')})`);
3527
+ const existingRows = await supabaseRequest(
3528
+ `/rest/v1/memories?${lookupQuery.toString()}`,
3529
+ {
3530
+ profile: DB_PROFILE
3531
+ }
3532
+ );
3533
+ const foundRows = Array.isArray(existingRows) ? existingRows : [];
3534
+ const foundMap = new Map(foundRows.map((row) => [String(row?.id || ''), row]));
3535
+ const notFoundIds = dedupedIds.filter((id) => !foundMap.has(id));
3536
+ const toForgetIds = [];
3537
+ const skippedItems = [];
3538
+ for (const id of dedupedIds) {
3539
+ const row = foundMap.get(id);
3540
+ if (!row) continue;
3541
+ const expiresAtTs = parseTimestamp(row.expires_at);
3542
+ if (expiresAtTs && expiresAtTs.getTime() <= forgottenAtTs.getTime()) {
3543
+ skippedItems.push({
3544
+ id,
3545
+ expires_at: row.expires_at || null,
3546
+ reason: 'already_expired'
3547
+ });
3548
+ continue;
3549
+ }
3550
+ toForgetIds.push(id);
3551
+ }
3552
+
3553
+ let forgottenRows = [];
3554
+ if (toForgetIds.length > 0) {
3555
+ const patchQuery = new URLSearchParams();
3556
+ patchQuery.set('id', `in.(${toForgetIds.join(',')})`);
3557
+ patchQuery.set(
3558
+ 'select',
3559
+ 'id,source,session_id,promoted,promoted_at,created_at,expires_at'
3560
+ );
3561
+ const patchedRows = await supabaseRequest(
3562
+ `/rest/v1/memories?${patchQuery.toString()}`,
3563
+ {
3564
+ method: 'PATCH',
3565
+ profile: DB_PROFILE,
3566
+ prefer: 'return=representation',
3567
+ body: {
3568
+ expires_at: forgottenAtIso
3569
+ }
3570
+ }
3571
+ );
3572
+ forgottenRows = Array.isArray(patchedRows) ? patchedRows : [];
3573
+ }
3574
+
3575
+ return {
3576
+ ok: true,
3577
+ forgotten_at: forgottenAtIso,
3578
+ reason: reason || null,
3579
+ requested_count: dedupedIds.length,
3580
+ forgotten_count: forgottenRows.length,
3581
+ skipped_count: skippedItems.length,
3582
+ not_found_count: notFoundIds.length,
3583
+ forgotten: forgottenRows,
3584
+ skipped: skippedItems,
3585
+ not_found_ids: notFoundIds
3586
+ };
3587
+ }
3588
+
3589
+ if (toolName === 'health_check') {
3590
+ return await runHealthCheck(args);
3591
+ }
3592
+ if (toolName === 'session_boot') {
3593
+ return await runDailyBoot(args);
3594
+ }
3595
+ if (toolName === 'session_close') {
3596
+ return await runDailyClose(args);
3597
+ }
3598
+
3599
+ if (toolName === 'dream_ingest') {
3600
+ return await ingestMarsvaultChunks(args, {
3601
+ defaultType: 'digest',
3602
+ defaultSourceFile: 'Hermes/Digest/auto.md',
3603
+ defaultSectionPrefix: 'digest',
3604
+ defaultOrigin: PROFILE.digestDefaultOrigin,
3605
+ body: DB_PROFILE,
3606
+ fixedTags: ['hermes', 'digest']
3607
+ });
3608
+ }
3609
+ if (toolName === PROFILE.memoryIngestToolName) {
3610
+ const sourceMemoryId = normalizeOptionalUuid(args.source_memory_id, 'source_memory_id');
3611
+ let linkedMemory = null;
3612
+ if (sourceMemoryId) {
3613
+ linkedMemory = await fetchMemoryById(sourceMemoryId);
3614
+ if (!linkedMemory) {
3615
+ throw new Error(`source_memory_id not found: ${sourceMemoryId}`);
3616
+ }
3617
+ }
3618
+ const sourceSessionId =
3619
+ normalizeOptionalText(args.source_session_id, 200) ||
3620
+ normalizeOptionalText(linkedMemory?.session_id, 200);
3621
+ const sourceTool =
3622
+ normalizeOptionalSourceTool(args.source_tool) ||
3623
+ normalizeOptionalSourceTool(linkedMemory?.source);
3624
+ const sourceUserNote = normalizeOptionalText(args.source_user_note, 500);
3625
+ const ingestAgentBody =
3626
+ normalizeOptionalAgentBody(args.agent_body) ||
3627
+ normalizeOptionalAgentBody(linkedMemory?.agent_body);
3628
+ const ingestEnvironment =
3629
+ normalizeOptionalEnvironment(args.environment) ||
3630
+ normalizeOptionalEnvironment(linkedMemory?.environment);
3631
+ const ingestArgs = {
3632
+ ...args,
3633
+ ...(sourceMemoryId ? { source_memory_id: sourceMemoryId } : {}),
3634
+ ...(sourceSessionId ? { source_session_id: sourceSessionId } : {}),
3635
+ ...(sourceTool ? { source_tool: sourceTool } : {}),
3636
+ ...(sourceUserNote ? { source_user_note: sourceUserNote } : {}),
3637
+ ...(ingestAgentBody ? { agent_body: ingestAgentBody } : {}),
3638
+ ...(ingestEnvironment ? { environment: ingestEnvironment } : {})
3639
+ };
3640
+ const ingestResult = await ingestMarsvaultChunks(ingestArgs, {
3641
+ defaultType: 'insight',
3642
+ defaultSourceFile: PROFILE.memoryIngestDefaultSourceFile,
3643
+ defaultSectionPrefix: 'insight',
3644
+ defaultOrigin: PROFILE.memoryIngestDefaultOrigin,
3645
+ body: DB_PROFILE,
3646
+ fixedTags: PROFILE.memoryIngestFixedTags,
3647
+ normalizeOrigin: normalizeMemoryIngestOrigin
3648
+ });
3649
+ let promotedMemory = null;
3650
+ if (sourceMemoryId) {
3651
+ promotedMemory = await markMemoryAsPromoted(sourceMemoryId);
3652
+ }
3653
+ return {
3654
+ ...ingestResult,
3655
+ source_memory_id: sourceMemoryId || null,
3656
+ source_session_id: sourceSessionId || null,
3657
+ source_tool: sourceTool || null,
3658
+ source_user_note: sourceUserNote || null,
3659
+ promoted_memory: promotedMemory
3660
+ ? {
3661
+ id: promotedMemory.id || sourceMemoryId,
3662
+ promoted: Boolean(promotedMemory.promoted),
3663
+ promoted_at: promotedMemory.promoted_at || null
3664
+ }
3665
+ : null
3666
+ };
3667
+ }
3668
+
3669
+ throw new Error(`Unknown tool: ${name}`);
3670
+ } finally {
3671
+ await writeToolUsageTelemetry({
3672
+ tool_name: toolName,
3673
+ timestamp: telemetryStartedAtIso,
3674
+ latency_ms: Math.max(0, Date.now() - telemetryStartedAtMs),
3675
+ tokens_estimate: telemetryTokensEstimate,
3676
+ agent_body: telemetryAgentBody
3677
+ });
3678
+ }
3679
+ }
3680
+
3681
+ function inferBaseUrl(req) {
3682
+ const rawProto = String(req.headers['x-forwarded-proto'] || 'http');
3683
+ const proto = rawProto.split(',')[0].trim() || 'http';
3684
+ const host = String(
3685
+ req.headers['x-forwarded-host'] ||
3686
+ req.headers.host ||
3687
+ 'localhost'
3688
+ )
3689
+ .split(',')[0]
3690
+ .trim();
3691
+ return `${proto}://${host}`;
3692
+ }
3693
+
3694
+ function extractRequestHost(req) {
3695
+ return String(req.headers['x-forwarded-host'] || req.headers.host || '')
3696
+ .split(',')[0]
3697
+ .trim()
3698
+ .toLowerCase()
3699
+ .replace(/:\d+$/, '');
3700
+ }
3701
+
3702
+ function isIpv4Address(host) {
3703
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
3704
+ }
3705
+
3706
+ function isPrivateHost(host) {
3707
+ if (!host) return false;
3708
+ if (host === 'localhost' || host.endsWith('.local')) {
3709
+ return true;
3710
+ }
3711
+ if (!isIpv4Address(host)) {
3712
+ return false;
3713
+ }
3714
+ const [a, b] = host.split('.').map((n) => Number.parseInt(n, 10));
3715
+ if (a === 10) return true;
3716
+ if (a === 127) return true;
3717
+ if (a === 192 && b === 168) return true;
3718
+ if (a === 172 && b >= 16 && b <= 31) return true;
3719
+ return false;
3720
+ }
3721
+
3722
+ function isPublicHost(host) {
3723
+ if (!host) return false;
3724
+ return PUBLIC_HOST_SUFFIXES.some(
3725
+ (suffix) => host === suffix || host.endsWith(`.${suffix}`)
3726
+ );
3727
+ }
3728
+
3729
+ function shouldRequireBearer(req) {
3730
+ if (!REQUIRE_BEARER) return false;
3731
+ const host = extractRequestHost(req);
3732
+ if (BYPASS_BEARER_FOR_PRIVATE && isPrivateHost(host)) {
3733
+ return false;
3734
+ }
3735
+ if (isPublicHost(host)) {
3736
+ return true;
3737
+ }
3738
+ return true;
3739
+ }
3740
+
3741
+ function oauthProtectedResourceMetadata(baseUrl) {
3742
+ return {
3743
+ resource: `${baseUrl}/mcp`,
3744
+ authorization_servers: [baseUrl],
3745
+ bearer_methods_supported: ['header'],
3746
+ scopes_supported: ['mcp']
3747
+ };
3748
+ }
3749
+
3750
+ function oauthAuthorizationServerMetadata(baseUrl) {
3751
+ return {
3752
+ issuer: baseUrl,
3753
+ authorization_endpoint: `${baseUrl}/oauth/authorize`,
3754
+ token_endpoint: `${baseUrl}/oauth/token`,
3755
+ registration_endpoint: `${baseUrl}/oauth/register`,
3756
+ response_types_supported: ['code'],
3757
+ grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
3758
+ code_challenge_methods_supported: ['S256'],
3759
+ token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic', 'none'],
3760
+ scopes_supported: ['mcp']
3761
+ };
3762
+ }
3763
+
3764
+ function oauthUnauthorizedHeaders(baseUrl) {
3765
+ return {
3766
+ 'www-authenticate': `Bearer realm="${SERVER_NAME}", resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`,
3767
+ 'cache-control': 'no-store'
3768
+ };
3769
+ }
3770
+
3771
+ function oauthError(res, statusCode, error, description, extraHeaders = {}) {
3772
+ json(
3773
+ res,
3774
+ statusCode,
3775
+ {
3776
+ error,
3777
+ error_description: description
3778
+ },
3779
+ extraHeaders
3780
+ );
3781
+ }
3782
+
3783
+ function cleanupOauthState() {
3784
+ const now = nowMs();
3785
+ for (const [code, value] of OAUTH_CODES.entries()) {
3786
+ if (value.expires_at_ms <= now) {
3787
+ OAUTH_CODES.delete(code);
3788
+ }
3789
+ }
3790
+ for (const [token, value] of OAUTH_TOKENS.entries()) {
3791
+ if (value.expires_at_ms <= now) {
3792
+ OAUTH_TOKENS.delete(token);
3793
+ }
3794
+ }
3795
+ for (const [refreshToken, value] of OAUTH_REFRESH_TOKENS.entries()) {
3796
+ if (value.expires_at_ms <= now) {
3797
+ OAUTH_REFRESH_TOKENS.delete(refreshToken);
3798
+ }
3799
+ }
3800
+ }
3801
+
3802
+ function decodeBasicAuth(authorizationHeader) {
3803
+ if (!authorizationHeader || !authorizationHeader.startsWith('Basic ')) {
3804
+ return { client_id: '', client_secret: '' };
3805
+ }
3806
+ const encoded = authorizationHeader.slice('Basic '.length).trim();
3807
+ try {
3808
+ const decoded = Buffer.from(encoded, 'base64').toString('utf8');
3809
+ const idx = decoded.indexOf(':');
3810
+ if (idx < 0) {
3811
+ return { client_id: decoded, client_secret: '' };
3812
+ }
3813
+ return {
3814
+ client_id: decoded.slice(0, idx),
3815
+ client_secret: decoded.slice(idx + 1)
3816
+ };
3817
+ } catch {
3818
+ return { client_id: '', client_secret: '' };
3819
+ }
3820
+ }
3821
+
3822
+ function authenticateOauthClient(req, body) {
3823
+ const basic = decodeBasicAuth(String(req.headers.authorization || ''));
3824
+ const clientId = String(basic.client_id || body.client_id || '').trim();
3825
+ const clientSecret = String(basic.client_secret || body.client_secret || '').trim();
3826
+ const grantType = String(body.grant_type || '').trim();
3827
+ const authCode = String(body.code || '').trim();
3828
+ const canSeedWithoutSecret =
3829
+ grantType === 'authorization_code' && authCode.length > 0;
3830
+
3831
+ if (!clientId) {
3832
+ return { ok: false, error: 'invalid_client', description: 'client_id is required' };
3833
+ }
3834
+ let client = OAUTH_CLIENTS.get(clientId);
3835
+ if (!client) {
3836
+ if (OAUTH_ALLOW_UNKNOWN_CLIENT_SEED && (clientSecret || canSeedWithoutSecret)) {
3837
+ client = upsertOauthClient({
3838
+ client_id: clientId,
3839
+ client_secret: clientSecret || null,
3840
+ redirect_uris: [],
3841
+ scope: 'mcp',
3842
+ token_endpoint_auth_method: clientSecret ? 'client_secret_post' : 'none',
3843
+ created_at: Math.floor(nowMs() / 1000),
3844
+ seeded: true
3845
+ });
3846
+ console.warn(`[oauth] seeded unknown client_id from token request: ${clientId}`);
3847
+ } else {
3848
+ return { ok: false, error: 'invalid_client', description: 'unknown client_id' };
3849
+ }
3850
+ }
3851
+
3852
+ const requiresSecret = client.token_endpoint_auth_method !== 'none';
3853
+ if (requiresSecret) {
3854
+ if (!clientSecret) {
3855
+ return { ok: false, error: 'invalid_client', description: 'client_secret is required' };
3856
+ }
3857
+ if (client.client_secret !== clientSecret) {
3858
+ return { ok: false, error: 'invalid_client', description: 'client_secret mismatch' };
3859
+ }
3860
+ }
3861
+
3862
+ return { ok: true, client };
3863
+ }
3864
+
3865
+ function issueRefreshToken(clientId, scope = 'mcp') {
3866
+ const refreshToken = randomId('coco_rt');
3867
+ const expiresAtMs = nowMs() + OAUTH_REFRESH_TOKEN_TTL_SECONDS * 1000;
3868
+ OAUTH_REFRESH_TOKENS.set(refreshToken, {
3869
+ refresh_token: refreshToken,
3870
+ client_id: clientId,
3871
+ scope,
3872
+ expires_at_ms: expiresAtMs
3873
+ });
3874
+ return refreshToken;
3875
+ }
3876
+
3877
+ function issueAccessToken(clientId, scope = 'mcp', { includeRefreshToken = false } = {}) {
3878
+ const token = randomId('coco_at');
3879
+ const expiresAtMs = nowMs() + OAUTH_TOKEN_TTL_SECONDS * 1000;
3880
+ OAUTH_TOKENS.set(token, {
3881
+ token,
3882
+ client_id: clientId,
3883
+ scope,
3884
+ expires_at_ms: expiresAtMs
3885
+ });
3886
+ const response = {
3887
+ access_token: token,
3888
+ token_type: 'Bearer',
3889
+ expires_in: OAUTH_TOKEN_TTL_SECONDS,
3890
+ scope
3891
+ };
3892
+ if (includeRefreshToken) {
3893
+ response.refresh_token = issueRefreshToken(clientId, scope);
3894
+ }
3895
+ return response;
3896
+ }
3897
+
3898
+ function getBearerToken(req) {
3899
+ const authorization = String(req.headers.authorization || '').trim();
3900
+ if (!authorization.startsWith('Bearer ')) {
3901
+ return '';
3902
+ }
3903
+ return authorization.slice('Bearer '.length).trim();
3904
+ }
3905
+
3906
+ function isBearerTokenValid(token) {
3907
+ if (!token) return false;
3908
+ const stored = OAUTH_TOKENS.get(token);
3909
+ if (!stored) return false;
3910
+ if (stored.expires_at_ms <= nowMs()) {
3911
+ OAUTH_TOKENS.delete(token);
3912
+ return false;
3913
+ }
3914
+ return true;
3915
+ }
3916
+
3917
+ async function handleOauthRegister(req, res) {
3918
+ let body;
3919
+ try {
3920
+ body = await readRequestBody(req);
3921
+ } catch {
3922
+ oauthError(res, 400, 'invalid_request', 'invalid JSON body');
3923
+ return;
3924
+ }
3925
+
3926
+ const redirectUris = Array.isArray(body.redirect_uris)
3927
+ ? body.redirect_uris.map((v) => String(v).trim()).filter(Boolean)
3928
+ : [];
3929
+ if (redirectUris.length === 0) {
3930
+ oauthError(res, 400, 'invalid_client_metadata', 'redirect_uris is required');
3931
+ return;
3932
+ }
3933
+
3934
+ const tokenEndpointAuthMethod = String(body.token_endpoint_auth_method || 'client_secret_post').trim();
3935
+ const clientId = randomId('coco_client');
3936
+ const needsSecret = tokenEndpointAuthMethod !== 'none';
3937
+ const clientSecret = needsSecret ? randomId('coco_secret') : null;
3938
+ const scope = String(body.scope || 'mcp').trim() || 'mcp';
3939
+
3940
+ const client = {
3941
+ client_id: clientId,
3942
+ client_secret: clientSecret,
3943
+ redirect_uris: redirectUris,
3944
+ scope,
3945
+ token_endpoint_auth_method: tokenEndpointAuthMethod,
3946
+ created_at: Math.floor(nowMs() / 1000),
3947
+ dynamic: true
3948
+ };
3949
+ upsertOauthClient(client);
3950
+
3951
+ const response = {
3952
+ client_id: client.client_id,
3953
+ client_id_issued_at: client.created_at,
3954
+ redirect_uris: client.redirect_uris,
3955
+ token_endpoint_auth_method: client.token_endpoint_auth_method,
3956
+ grant_types: ['authorization_code', 'refresh_token', 'client_credentials'],
3957
+ response_types: ['code']
3958
+ };
3959
+ if (client.client_secret) {
3960
+ response.client_secret = client.client_secret;
3961
+ response.client_secret_expires_at = 0;
3962
+ }
3963
+
3964
+ json(res, 201, response, { 'cache-control': 'no-store' });
3965
+ }
3966
+
3967
+ function resolveRedirectUriForAuthorize(client, requestedRedirectUri) {
3968
+ const redirectUri = String(requestedRedirectUri || '').trim();
3969
+ if (redirectUri) {
3970
+ if (client.redirect_uris.length > 0 && !client.redirect_uris.includes(redirectUri)) {
3971
+ return { ok: false, error: 'invalid_request', description: 'redirect_uri is not registered' };
3972
+ }
3973
+ return { ok: true, redirect_uri: redirectUri };
3974
+ }
3975
+
3976
+ if (client.redirect_uris.length === 1) {
3977
+ return { ok: true, redirect_uri: client.redirect_uris[0] };
3978
+ }
3979
+ return { ok: false, error: 'invalid_request', description: 'redirect_uri is required' };
3980
+ }
3981
+
3982
+ function handleOauthAuthorize(req, res, url) {
3983
+ const responseType = String(url.searchParams.get('response_type') || '').trim();
3984
+ const clientId = String(url.searchParams.get('client_id') || '').trim();
3985
+ const state = String(url.searchParams.get('state') || '');
3986
+ const codeChallenge = String(url.searchParams.get('code_challenge') || '').trim();
3987
+ const codeChallengeMethod = String(url.searchParams.get('code_challenge_method') || 'S256').trim();
3988
+ const scope = String(url.searchParams.get('scope') || 'mcp').trim() || 'mcp';
3989
+
3990
+ if (responseType !== 'code') {
3991
+ oauthError(res, 400, 'unsupported_response_type', 'response_type must be code');
3992
+ return;
3993
+ }
3994
+ if (!clientId) {
3995
+ oauthError(res, 400, 'invalid_request', 'client_id is required');
3996
+ return;
3997
+ }
3998
+ const client = OAUTH_CLIENTS.get(clientId);
3999
+ if (!client) {
4000
+ oauthError(res, 400, 'invalid_client', 'unknown client_id');
4001
+ return;
4002
+ }
4003
+
4004
+ const redirectResolution = resolveRedirectUriForAuthorize(
4005
+ client,
4006
+ url.searchParams.get('redirect_uri')
4007
+ );
4008
+ if (!redirectResolution.ok) {
4009
+ oauthError(res, 400, redirectResolution.error, redirectResolution.description);
4010
+ return;
4011
+ }
4012
+
4013
+ const redirectUri = redirectResolution.redirect_uri;
4014
+ const code = randomId('coco_code');
4015
+ OAUTH_CODES.set(code, {
4016
+ code,
4017
+ client_id: client.client_id,
4018
+ redirect_uri: redirectUri,
4019
+ code_challenge: codeChallenge || null,
4020
+ code_challenge_method: codeChallenge ? codeChallengeMethod : null,
4021
+ scope,
4022
+ expires_at_ms: nowMs() + OAUTH_CODE_TTL_SECONDS * 1000
4023
+ });
4024
+
4025
+ let location;
4026
+ try {
4027
+ const redirectUrl = new URL(redirectUri);
4028
+ redirectUrl.searchParams.set('code', code);
4029
+ if (state) {
4030
+ redirectUrl.searchParams.set('state', state);
4031
+ }
4032
+ location = redirectUrl.toString();
4033
+ } catch {
4034
+ oauthError(res, 400, 'invalid_request', 'redirect_uri must be a valid URL');
4035
+ return;
4036
+ }
4037
+
4038
+ res.writeHead(302, {
4039
+ location,
4040
+ 'cache-control': 'no-store'
4041
+ });
4042
+ res.end();
4043
+ }
4044
+
4045
+ function verifyCodeChallenge(codeRecord, codeVerifier) {
4046
+ if (!codeRecord.code_challenge) {
4047
+ return true;
4048
+ }
4049
+ if (!codeVerifier) {
4050
+ return false;
4051
+ }
4052
+ const method = String(codeRecord.code_challenge_method || 'S256').toUpperCase();
4053
+ if (method === 'S256') {
4054
+ return sha256Base64Url(codeVerifier) === codeRecord.code_challenge;
4055
+ }
4056
+ if (method === 'PLAIN') {
4057
+ return codeVerifier === codeRecord.code_challenge;
4058
+ }
4059
+ return false;
4060
+ }
4061
+
4062
+ async function handleOauthToken(req, res) {
4063
+ let body;
4064
+ try {
4065
+ body = await readRequestBody(req);
4066
+ } catch {
4067
+ oauthError(res, 400, 'invalid_request', 'invalid token request payload');
4068
+ return;
4069
+ }
4070
+
4071
+ const grantType = String(body.grant_type || '').trim();
4072
+ if (!grantType) {
4073
+ oauthError(res, 400, 'invalid_request', 'grant_type is required');
4074
+ return;
4075
+ }
4076
+
4077
+ const auth = authenticateOauthClient(req, body);
4078
+ if (!auth.ok) {
4079
+ oauthError(res, 401, auth.error, auth.description, { 'cache-control': 'no-store' });
4080
+ return;
4081
+ }
4082
+
4083
+ if (grantType === 'client_credentials') {
4084
+ const scope = String(body.scope || auth.client.scope || 'mcp').trim() || 'mcp';
4085
+ const tokenResponse = issueAccessToken(auth.client.client_id, scope);
4086
+ json(res, 200, tokenResponse, { 'cache-control': 'no-store' });
4087
+ return;
4088
+ }
4089
+
4090
+ if (grantType === 'authorization_code') {
4091
+ const code = String(body.code || '').trim();
4092
+ if (!code) {
4093
+ oauthError(res, 400, 'invalid_request', 'code is required');
4094
+ return;
4095
+ }
4096
+ const codeRecord = OAUTH_CODES.get(code);
4097
+ if (!codeRecord) {
4098
+ oauthError(res, 400, 'invalid_grant', 'unknown code');
4099
+ return;
4100
+ }
4101
+ if (codeRecord.expires_at_ms <= nowMs()) {
4102
+ OAUTH_CODES.delete(code);
4103
+ oauthError(res, 400, 'invalid_grant', 'code expired');
4104
+ return;
4105
+ }
4106
+ if (codeRecord.client_id !== auth.client.client_id) {
4107
+ oauthError(res, 400, 'invalid_grant', 'code client mismatch');
4108
+ return;
4109
+ }
4110
+
4111
+ const redirectUri = String(body.redirect_uri || '').trim();
4112
+ if (redirectUri && redirectUri !== codeRecord.redirect_uri) {
4113
+ oauthError(res, 400, 'invalid_grant', 'redirect_uri mismatch');
4114
+ return;
4115
+ }
4116
+
4117
+ const codeVerifier = String(body.code_verifier || '').trim();
4118
+ if (!verifyCodeChallenge(codeRecord, codeVerifier)) {
4119
+ oauthError(res, 400, 'invalid_grant', 'invalid code_verifier');
4120
+ return;
4121
+ }
4122
+
4123
+ OAUTH_CODES.delete(code);
4124
+ const tokenResponse = issueAccessToken(auth.client.client_id, codeRecord.scope || 'mcp', {
4125
+ includeRefreshToken: true
4126
+ });
4127
+ json(res, 200, tokenResponse, { 'cache-control': 'no-store' });
4128
+ return;
4129
+ }
4130
+
4131
+ if (grantType === 'refresh_token') {
4132
+ const refreshToken = String(body.refresh_token || '').trim();
4133
+ if (!refreshToken) {
4134
+ oauthError(res, 400, 'invalid_request', 'refresh_token is required');
4135
+ return;
4136
+ }
4137
+ const refreshRecord = OAUTH_REFRESH_TOKENS.get(refreshToken);
4138
+ if (!refreshRecord) {
4139
+ oauthError(res, 400, 'invalid_grant', 'unknown refresh_token');
4140
+ return;
4141
+ }
4142
+ if (refreshRecord.expires_at_ms <= nowMs()) {
4143
+ OAUTH_REFRESH_TOKENS.delete(refreshToken);
4144
+ oauthError(res, 400, 'invalid_grant', 'refresh_token expired');
4145
+ return;
4146
+ }
4147
+ if (refreshRecord.client_id !== auth.client.client_id) {
4148
+ oauthError(res, 400, 'invalid_grant', 'refresh_token client mismatch');
4149
+ return;
4150
+ }
4151
+
4152
+ OAUTH_REFRESH_TOKENS.delete(refreshToken);
4153
+ const scope = String(refreshRecord.scope || auth.client.scope || 'mcp').trim() || 'mcp';
4154
+ const tokenResponse = issueAccessToken(auth.client.client_id, scope, {
4155
+ includeRefreshToken: true
4156
+ });
4157
+ json(res, 200, tokenResponse, { 'cache-control': 'no-store' });
4158
+ return;
4159
+ }
4160
+
4161
+ oauthError(res, 400, 'unsupported_grant_type', `unsupported grant_type: ${grantType}`);
4162
+ }
4163
+
4164
+ function logRequest(req, url) {
4165
+ const host = extractRequestHost(req);
4166
+ const ua = String(req.headers['user-agent'] || '').slice(0, 120);
4167
+ const hasAuthorization = req.headers.authorization ? 'yes' : 'no';
4168
+ const hasCfServiceTokenId = req.headers['cf-access-client-id'] ? 'yes' : 'no';
4169
+ const requireBearer = shouldRequireBearer(req) ? 'yes' : 'no';
4170
+ console.log(
4171
+ `[http] ${req.method} ${url.pathname} host=${host || '-'} auth=${hasAuthorization} bearer_required=${requireBearer} cf_service_token=${hasCfServiceTokenId} ua="${ua}"`
4172
+ );
4173
+ }
4174
+
4175
+ const server = http.createServer(async (req, res) => {
4176
+ cleanupOauthState();
4177
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
4178
+ const baseUrl = inferBaseUrl(req);
4179
+ logRequest(req, url);
4180
+
4181
+ if (req.method === 'OPTIONS') {
4182
+ res.writeHead(204, {
4183
+ 'access-control-allow-origin': '*',
4184
+ 'access-control-allow-methods': 'POST,GET,OPTIONS',
4185
+ 'access-control-allow-headers': 'content-type,mcp-session-id,authorization'
4186
+ });
4187
+ res.end();
4188
+ return;
4189
+ }
4190
+
4191
+ if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/health')) {
4192
+ json(res, 200, {
4193
+ ok: true,
4194
+ name: SERVER_NAME,
4195
+ transport: 'streamable-http',
4196
+ endpoint: '/mcp',
4197
+ oauth_enabled: OAUTH_ENABLED,
4198
+ bearer_required: REQUIRE_BEARER,
4199
+ bypass_bearer_for_private: BYPASS_BEARER_FOR_PRIVATE,
4200
+ public_hosts: PUBLIC_HOST_SUFFIXES,
4201
+ semantic_search_enabled: true,
4202
+ embedding_provider: 'jina',
4203
+ embedding_model: JINA_EMBEDDING_MODEL,
4204
+ embedding_enabled: Boolean(JINA_API_KEY),
4205
+ source_mode: SOURCE_MODE,
4206
+ memory_sources: MEMORY_SOURCE_LIST.slice(),
4207
+ extra_sources: EXTRA_SOURCE_LIST.slice(),
4208
+ source_registry_cache: buildSourceRegistryHealthPayload()
4209
+ });
4210
+ return;
4211
+ }
4212
+
4213
+ if (OAUTH_ENABLED && req.method === 'GET' && url.pathname === '/.well-known/oauth-protected-resource') {
4214
+ json(res, 200, oauthProtectedResourceMetadata(baseUrl), { 'cache-control': 'no-store' });
4215
+ return;
4216
+ }
4217
+
4218
+ if (OAUTH_ENABLED && req.method === 'GET' && url.pathname === '/.well-known/oauth-authorization-server') {
4219
+ json(res, 200, oauthAuthorizationServerMetadata(baseUrl), { 'cache-control': 'no-store' });
4220
+ return;
4221
+ }
4222
+
4223
+ if (OAUTH_ENABLED && req.method === 'POST' && (url.pathname === '/oauth/register' || url.pathname === '/register')) {
4224
+ await handleOauthRegister(req, res);
4225
+ return;
4226
+ }
4227
+
4228
+ if (OAUTH_ENABLED && req.method === 'GET' && (url.pathname === '/oauth/authorize' || url.pathname === '/authorize')) {
4229
+ handleOauthAuthorize(req, res, url);
4230
+ return;
4231
+ }
4232
+
4233
+ if (OAUTH_ENABLED && req.method === 'POST' && (url.pathname === '/oauth/token' || url.pathname === '/token')) {
4234
+ await handleOauthToken(req, res);
4235
+ return;
4236
+ }
4237
+
4238
+ if (req.method !== 'POST' || !['/mcp', '/mcp/', '/'].includes(url.pathname)) {
4239
+ json(res, 404, { error: 'not_found' });
4240
+ return;
4241
+ }
4242
+
4243
+ if (shouldRequireBearer(req)) {
4244
+ const bearerToken = getBearerToken(req);
4245
+ if (!isBearerTokenValid(bearerToken)) {
4246
+ json(res, 401, { error: 'unauthorized' }, oauthUnauthorizedHeaders(baseUrl));
4247
+ return;
4248
+ }
4249
+ }
4250
+
4251
+ let rpc;
4252
+ try {
4253
+ rpc = await readRequestBody(req);
4254
+ } catch {
4255
+ json(res, 400, mcpError(null, -32700, 'Parse error'));
4256
+ return;
4257
+ }
4258
+
4259
+ const id = rpc?.id ?? null;
4260
+ const method = rpc?.method;
4261
+
4262
+ try {
4263
+ if (method === 'initialize') {
4264
+ json(
4265
+ res,
4266
+ 200,
4267
+ mcpResult(id, {
4268
+ protocolVersion: '2025-03-26',
4269
+ capabilities: { tools: { listChanged: false } },
4270
+ serverInfo: {
4271
+ name: SERVER_NAME,
4272
+ version: '0.3.1'
4273
+ }
4274
+ })
4275
+ );
4276
+ return;
4277
+ }
4278
+
4279
+ if (method === 'notifications/initialized') {
4280
+ res.writeHead(202);
4281
+ res.end();
4282
+ return;
4283
+ }
4284
+
4285
+ if (method === 'tools/list') {
4286
+ json(res, 200, mcpResult(id, { tools: TOOLS }));
4287
+ return;
4288
+ }
4289
+
4290
+ if (method === 'tools/call') {
4291
+ const toolName = rpc?.params?.name;
4292
+ const args = rpc?.params?.arguments ?? {};
4293
+ const result = await callTool(toolName, args);
4294
+ json(
4295
+ res,
4296
+ 200,
4297
+ mcpResult(id, {
4298
+ content: [{ type: 'text', text: JSON.stringify(result) }]
4299
+ })
4300
+ );
4301
+ return;
4302
+ }
4303
+
4304
+ if (method === 'ping') {
4305
+ json(res, 200, mcpResult(id, {}));
4306
+ return;
4307
+ }
4308
+
4309
+ json(res, 404, mcpError(id, -32601, 'Method not found'));
4310
+ } catch (error) {
4311
+ json(
4312
+ res,
4313
+ 200,
4314
+ mcpResult(id, {
4315
+ content: [{ type: 'text', text: JSON.stringify({ ok: false, error: String(error.message || error) }) }],
4316
+ isError: true
4317
+ })
4318
+ );
4319
+ }
4320
+ });
4321
+
4322
+ server.listen(PORT, '0.0.0.0', () => {
4323
+ console.log(`${SERVER_NAME} listening on 0.0.0.0:${PORT}`);
4324
+ console.log(`[config] source_mode=${SOURCE_MODE}`);
4325
+ if (SOURCE_MODE === 'registry') {
4326
+ void reloadSourceRegistryCache()
4327
+ .then((result) => {
4328
+ if (result?.ok) {
4329
+ console.log(
4330
+ `[source_registry] loaded ${result.enabled_sources.length} enabled sources`
4331
+ );
4332
+ } else {
4333
+ console.warn(
4334
+ `[source_registry] initial load failed: ${String(result?.error || 'unknown error')}`
4335
+ );
4336
+ }
4337
+ })
4338
+ .catch((error) => {
4339
+ console.warn(
4340
+ `[source_registry] initial load failed: ${String(error?.message || error)}`
4341
+ );
4342
+ });
4343
+ }
4344
+ if (OAUTH_ENABLED) {
4345
+ console.log('oauth endpoints enabled: /.well-known/*, /oauth/register, /oauth/authorize, /oauth/token');
4346
+ }
4347
+ });