@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/deploy/phase2/build_release_artifact.sh +56 -0
- package/deploy/phase3/smoke_gate.sh +223 -0
- package/deploy/systemd/memory-mcp-gateway@.service +26 -0
- package/package.json +41 -0
- package/scripts/dream_runner.py +779 -0
- package/scripts/hermes_digest_runner.py +36 -0
- package/scripts/tests/test_dream_runner_modes.py +90 -0
- package/server.mjs +4347 -0
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
|
+
});
|