@marsnme/mcp-gateway 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/deploy/phase2/build_release_artifact.sh +5 -5
- package/deploy/phase2/pre_deploy_schema_gate.sh +1 -1
- package/deploy/phase3/smoke_gate.sh +11 -5
- package/deploy/systemd/memory-mcp-gateway@.service +6 -6
- package/package.json +1 -1
- package/scripts/tests/test_dream_runner_modes.py +2 -2
- package/server.mjs +295 -11
|
@@ -31,15 +31,15 @@ ARTIFACT_PATH="${OUTPUT_DIR}/${ARTIFACT_BASENAME}.tar.gz"
|
|
|
31
31
|
MANIFEST_PATH="${OUTPUT_DIR}/${ARTIFACT_BASENAME}.manifest.json"
|
|
32
32
|
|
|
33
33
|
tar -C "${REPO_ROOT}" -czf "${ARTIFACT_PATH}" \
|
|
34
|
-
--exclude='
|
|
35
|
-
--exclude='
|
|
34
|
+
--exclude='marsnme-supabase/deploy/artifacts' \
|
|
35
|
+
--exclude='marsnme-supabase/deploy/phase0/ct101-baseline-*' \
|
|
36
36
|
README.md \
|
|
37
|
-
|
|
37
|
+
marsnme-supabase \
|
|
38
38
|
supabase/config.toml \
|
|
39
39
|
supabase/migrations
|
|
40
40
|
|
|
41
41
|
ARTIFACT_SHA256="$(shasum -a 256 "${ARTIFACT_PATH}" | awk '{print $1}')"
|
|
42
|
-
SERVER_SHA256="$(shasum -a 256 "${REPO_ROOT}/
|
|
42
|
+
SERVER_SHA256="$(shasum -a 256 "${REPO_ROOT}/marsnme-supabase/server.mjs" | awk '{print $1}')"
|
|
43
43
|
|
|
44
44
|
cat > "${MANIFEST_PATH}" <<EOF
|
|
45
45
|
{
|
|
@@ -47,7 +47,7 @@ cat > "${MANIFEST_PATH}" <<EOF
|
|
|
47
47
|
"commit_hash": "${COMMIT_HASH}",
|
|
48
48
|
"artifact_path": "${ARTIFACT_PATH}",
|
|
49
49
|
"artifact_sha256": "${ARTIFACT_SHA256}",
|
|
50
|
-
"server_path": "
|
|
50
|
+
"server_path": "marsnme-supabase/server.mjs",
|
|
51
51
|
"server_sha256": "${SERVER_SHA256}"
|
|
52
52
|
}
|
|
53
53
|
EOF
|
|
@@ -151,7 +151,7 @@ for profile in "${PROFILE_LIST[@]}"; do
|
|
|
151
151
|
profile_ident="$(quote_ident "${profile}")"
|
|
152
152
|
|
|
153
153
|
check_sql "${profile}.memories required columns" \
|
|
154
|
-
"SELECT id,body,source,session_id,tags,agent_body,environment,promoted,promoted_at,created_at,expires_at FROM ${profile_ident}.memories LIMIT 0;"
|
|
154
|
+
"SELECT id,body,source,session_id,tags,agent_body,environment,promoted,promoted_at,created_at,expires_at,recipient_body,note,read_at FROM ${profile_ident}.memories LIMIT 0;"
|
|
155
155
|
|
|
156
156
|
check_sql "${profile}.marsvault_chunks required columns" \
|
|
157
157
|
"SELECT 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 FROM ${profile_ident}.marsvault_chunks LIMIT 0;"
|
|
@@ -112,9 +112,9 @@ mcp_jsonrpc() {
|
|
|
112
112
|
if [[ "${SPAWN_LOCAL}" == "true" ]]; then
|
|
113
113
|
COCO_LOG="/tmp/mars-memory-coco-smoke.log"
|
|
114
114
|
TOTO_LOG="/tmp/mars-memory-toto-smoke.log"
|
|
115
|
-
MCP_PROFILE=coco PORT=18790 SUPABASE_BASE_URL="${SUPABASE_BASE_URL_FOR_LOCAL}" MCP_REQUIRE_BEARER=false node "${REPO_ROOT}/
|
|
115
|
+
MCP_PROFILE=coco PORT=18790 SUPABASE_BASE_URL="${SUPABASE_BASE_URL_FOR_LOCAL}" MCP_REQUIRE_BEARER=false node "${REPO_ROOT}/marsnme-supabase/server.mjs" >"${COCO_LOG}" 2>&1 &
|
|
116
116
|
COCO_PID="$!"
|
|
117
|
-
MCP_PROFILE=toto PORT=18791 SUPABASE_BASE_URL="${SUPABASE_BASE_URL_FOR_LOCAL}" MCP_REQUIRE_BEARER=false node "${REPO_ROOT}/
|
|
117
|
+
MCP_PROFILE=toto PORT=18791 SUPABASE_BASE_URL="${SUPABASE_BASE_URL_FOR_LOCAL}" MCP_REQUIRE_BEARER=false node "${REPO_ROOT}/marsnme-supabase/server.mjs" >"${TOTO_LOG}" 2>&1 &
|
|
118
118
|
TOTO_PID="$!"
|
|
119
119
|
sleep 2
|
|
120
120
|
fi
|
|
@@ -131,7 +131,8 @@ COCO_TOOLS_JSON="$(mcp_jsonrpc "${COCO_URL}" '{"jsonrpc":"2.0","id":"coco-tools-
|
|
|
131
131
|
TOTO_TOOLS_JSON="$(mcp_jsonrpc "${TOTO_URL}" '{"jsonrpc":"2.0","id":"toto-tools-list","method":"tools/list","params":{}}')"
|
|
132
132
|
|
|
133
133
|
COCO_TOOLS_CHECK="$(TOOLS_PAYLOAD="${COCO_TOOLS_JSON}" node -e '
|
|
134
|
-
const expected = ["insert_memory", "list_memories", "search_memories", "recall", "health_check", "session_boot", "session_close", "dream_ingest", "memory_ingest"];
|
|
134
|
+
const expected = ["insert_memory", "list_memories", "search_memories", "reload_source_registry", "recall", "get_summary", "get_full", "health_check", "session_boot", "session_close", "dream_ingest", "memory_ingest", "demote_memory", "soft_forget", "explain_memory", "batch_promote"];
|
|
135
|
+
const prdTools = ["save_prd", "get_prd", "list_prds", "score_prd", "spawn_to_linear"];
|
|
135
136
|
const payload = JSON.parse(process.env.TOOLS_PAYLOAD || "{}");
|
|
136
137
|
if (payload.error) throw new Error("tools/list error: " + JSON.stringify(payload.error));
|
|
137
138
|
const tools = payload.result?.tools;
|
|
@@ -139,11 +140,14 @@ if (!Array.isArray(tools)) throw new Error("tools/list missing result.tools");
|
|
|
139
140
|
const names = tools.map((item) => item?.name).filter(Boolean);
|
|
140
141
|
const missing = expected.filter((name) => !names.includes(name));
|
|
141
142
|
if (missing.length > 0) throw new Error("missing tools: " + missing.join(","));
|
|
143
|
+
const leaked = names.filter((name) => prdTools.includes(name));
|
|
144
|
+
if (leaked.length > 0) throw new Error("PRD tools must not be exposed: " + leaked.join(","));
|
|
142
145
|
process.stdout.write(JSON.stringify({ tool_count: names.length, tools: names }));
|
|
143
146
|
')"
|
|
144
147
|
|
|
145
148
|
TOTO_TOOLS_CHECK="$(TOOLS_PAYLOAD="${TOTO_TOOLS_JSON}" node -e '
|
|
146
|
-
const expected = ["insert_memory", "list_memories", "search_memories", "recall", "health_check", "session_boot", "session_close", "dream_ingest", "memory_ingest"];
|
|
149
|
+
const expected = ["insert_memory", "list_memories", "search_memories", "reload_source_registry", "recall", "get_summary", "get_full", "health_check", "session_boot", "session_close", "dream_ingest", "memory_ingest", "demote_memory", "soft_forget", "explain_memory", "batch_promote"];
|
|
150
|
+
const prdTools = ["save_prd", "get_prd", "list_prds", "score_prd", "spawn_to_linear"];
|
|
147
151
|
const payload = JSON.parse(process.env.TOOLS_PAYLOAD || "{}");
|
|
148
152
|
if (payload.error) throw new Error("tools/list error: " + JSON.stringify(payload.error));
|
|
149
153
|
const tools = payload.result?.tools;
|
|
@@ -151,6 +155,8 @@ if (!Array.isArray(tools)) throw new Error("tools/list missing result.tools");
|
|
|
151
155
|
const names = tools.map((item) => item?.name).filter(Boolean);
|
|
152
156
|
const missing = expected.filter((name) => !names.includes(name));
|
|
153
157
|
if (missing.length > 0) throw new Error("missing tools: " + missing.join(","));
|
|
158
|
+
const leaked = names.filter((name) => prdTools.includes(name));
|
|
159
|
+
if (leaked.length > 0) throw new Error("PRD tools must not be exposed: " + leaked.join(","));
|
|
154
160
|
process.stdout.write(JSON.stringify({ tool_count: names.length, tools: names }));
|
|
155
161
|
')"
|
|
156
162
|
|
|
@@ -261,7 +267,7 @@ if [[ -z "${OUTPUT_PATH}" ]]; then
|
|
|
261
267
|
fi
|
|
262
268
|
|
|
263
269
|
COMMIT_HASH="$(git -C "${REPO_ROOT}" rev-parse HEAD)"
|
|
264
|
-
LOCAL_SERVER_SHA256="$(shasum -a 256 "${REPO_ROOT}/
|
|
270
|
+
LOCAL_SERVER_SHA256="$(shasum -a 256 "${REPO_ROOT}/marsnme-supabase/server.mjs" | awk '{print $1}')"
|
|
265
271
|
|
|
266
272
|
if [[ -n "${REMOTE_SERVER_SHA256}" ]]; then
|
|
267
273
|
REMOTE_SERVER_SHA256_VALUE="\"${REMOTE_SERVER_SHA256}\""
|
|
@@ -6,19 +6,19 @@ Requires=docker.service
|
|
|
6
6
|
|
|
7
7
|
[Service]
|
|
8
8
|
Type=simple
|
|
9
|
-
WorkingDirectory=/opt/mars-memory-mcp/current-%i/
|
|
9
|
+
WorkingDirectory=/opt/mars-memory-mcp/current-%i/marsnme-supabase
|
|
10
10
|
EnvironmentFile=-/opt/mars-memory-mcp/shared/.env
|
|
11
11
|
EnvironmentFile=-/opt/mars-memory-mcp/shared/.env.%i
|
|
12
|
-
EnvironmentFile=-/opt/mars-memory-mcp/current-%i/
|
|
13
|
-
EnvironmentFile=-/opt/mars-memory-mcp/current-%i/
|
|
14
|
-
EnvironmentFile=-/opt/mars-memory-mcp/current/
|
|
15
|
-
EnvironmentFile=-/opt/mars-memory-mcp/current/
|
|
12
|
+
EnvironmentFile=-/opt/mars-memory-mcp/current-%i/marsnme-supabase/.env
|
|
13
|
+
EnvironmentFile=-/opt/mars-memory-mcp/current-%i/marsnme-supabase/.env.%i
|
|
14
|
+
EnvironmentFile=-/opt/mars-memory-mcp/current/marsnme-supabase/.env
|
|
15
|
+
EnvironmentFile=-/opt/mars-memory-mcp/current/marsnme-supabase/.env.%i
|
|
16
16
|
Environment=MCP_PROFILE=%i
|
|
17
17
|
Environment=MCP_REQUIRE_BEARER=true
|
|
18
18
|
Environment=JINA_EMBEDDING_API_URL=https://api.jina.ai/v1/embeddings
|
|
19
19
|
Environment=JINA_EMBEDDING_MODEL=jina-embeddings-v3
|
|
20
20
|
Environment=JINA_EMBEDDING_DIMENSIONS=1024
|
|
21
|
-
ExecStart=/usr/bin/node /opt/mars-memory-mcp/current-%i/
|
|
21
|
+
ExecStart=/usr/bin/node /opt/mars-memory-mcp/current-%i/marsnme-supabase/server.mjs
|
|
22
22
|
Restart=always
|
|
23
23
|
RestartSec=3
|
|
24
24
|
|
package/package.json
CHANGED
|
@@ -8,8 +8,8 @@ from typing import Optional
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
ROOT = Path(__file__).resolve().parents[3]
|
|
11
|
-
DREAM_RUNNER = ROOT / "
|
|
12
|
-
HERMES_WRAPPER = ROOT / "
|
|
11
|
+
DREAM_RUNNER = ROOT / "marsnme-supabase" / "scripts" / "dream_runner.py"
|
|
12
|
+
HERMES_WRAPPER = ROOT / "marsnme-supabase" / "scripts" / "hermes_digest_runner.py"
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class DreamRunnerModeTests(unittest.TestCase):
|
package/server.mjs
CHANGED
|
@@ -13,7 +13,7 @@ const PROFILE_CONFIGS = {
|
|
|
13
13
|
defaultPort: 18790,
|
|
14
14
|
gatewayDir: 'coco-mcp-gateway',
|
|
15
15
|
publicHostSuffix: 'coco-mcp.marsgroup.asia',
|
|
16
|
-
sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw', 'hermes'],
|
|
16
|
+
sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw', 'hermes', 'draft', 'grok'],
|
|
17
17
|
recallBodyEnum: ['coco', 'toto', 'system'],
|
|
18
18
|
digestDefaultOrigin: 'hermes-coco-digest',
|
|
19
19
|
memoryIngestToolName: 'memory_ingest',
|
|
@@ -35,7 +35,7 @@ const PROFILE_CONFIGS = {
|
|
|
35
35
|
defaultPort: 18791,
|
|
36
36
|
gatewayDir: 'toto-mcp-gateway',
|
|
37
37
|
publicHostSuffix: 'toto-mcp.marsgroup.asia',
|
|
38
|
-
sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw'],
|
|
38
|
+
sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw', 'grok'],
|
|
39
39
|
recallBodyEnum: ['toto', 'system'],
|
|
40
40
|
digestDefaultOrigin: 'hermes-toto-digest',
|
|
41
41
|
memoryIngestToolName: 'memory_ingest',
|
|
@@ -190,9 +190,13 @@ const ANON_ALLOWED_TOOL_NAMES = new Set([
|
|
|
190
190
|
'search_memories',
|
|
191
191
|
'reload_source_registry',
|
|
192
192
|
'recall',
|
|
193
|
+
'get_summary',
|
|
194
|
+
'get_full',
|
|
193
195
|
'health_check',
|
|
194
196
|
'explain_memory'
|
|
195
197
|
]);
|
|
198
|
+
const RECALL_PREVIEW_MAX_CHARS = 80;
|
|
199
|
+
const CHUNK_SUMMARY_MAX_CHARS = 300;
|
|
196
200
|
const SUPABASE_REQUEST_CONTEXT = new AsyncLocalStorage();
|
|
197
201
|
const OAUTH_ENABLED = process.env.MCP_OAUTH_ENABLED !== 'false';
|
|
198
202
|
const REQUIRE_BEARER = process.env.MCP_REQUIRE_BEARER === 'true';
|
|
@@ -256,7 +260,8 @@ const SOURCE_TO_AGENT_BODY_MAP = new Map([
|
|
|
256
260
|
['cursor', 'cursor'],
|
|
257
261
|
['warp', 'warp'],
|
|
258
262
|
['openclaw', 'desktop'],
|
|
259
|
-
['hermes', 'desktop']
|
|
263
|
+
['hermes', 'desktop'],
|
|
264
|
+
['draft', 'desktop']
|
|
260
265
|
]);
|
|
261
266
|
const DEFAULT_AGENT_BODY = String(process.env.MCP_AGENT_BODY || '')
|
|
262
267
|
.trim()
|
|
@@ -386,7 +391,7 @@ function buildTools() {
|
|
|
386
391
|
},
|
|
387
392
|
{
|
|
388
393
|
name: 'recall',
|
|
389
|
-
description: `Semantic recall from long-term memory (${DB_PROFILE}.marsvault_chunks) using Jina embeddings.
|
|
394
|
+
description: `Semantic recall from long-term memory (${DB_PROFILE}.marsvault_chunks) using Jina embeddings. Returns preview snippets (~${RECALL_PREVIEW_MAX_CHARS} chars) per match by default — use get_summary for a longer excerpt or get_full for complete chunk text. Searches promoted insights, digests, and archived knowledge using vector similarity.`,
|
|
390
395
|
inputSchema: {
|
|
391
396
|
type: 'object',
|
|
392
397
|
properties: {
|
|
@@ -417,6 +422,30 @@ function buildTools() {
|
|
|
417
422
|
additionalProperties: false
|
|
418
423
|
}
|
|
419
424
|
},
|
|
425
|
+
{
|
|
426
|
+
name: 'get_summary',
|
|
427
|
+
description: `Fetch a medium-length excerpt (~${CHUNK_SUMMARY_MAX_CHARS} chars) of a long-term memory chunk by ID. Use after recall when a preview match looks relevant. For complete text, call get_full.`,
|
|
428
|
+
inputSchema: {
|
|
429
|
+
type: 'object',
|
|
430
|
+
properties: {
|
|
431
|
+
id: { type: 'string', description: 'UUID of the marsvault_chunks row' }
|
|
432
|
+
},
|
|
433
|
+
required: ['id'],
|
|
434
|
+
additionalProperties: false
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
name: 'get_full',
|
|
439
|
+
description: `Fetch the complete text of a long-term memory chunk by ID. Use sparingly after recall/get_summary when you need the full institutional memory entry.`,
|
|
440
|
+
inputSchema: {
|
|
441
|
+
type: 'object',
|
|
442
|
+
properties: {
|
|
443
|
+
id: { type: 'string', description: 'UUID of the marsvault_chunks row' }
|
|
444
|
+
},
|
|
445
|
+
required: ['id'],
|
|
446
|
+
additionalProperties: false
|
|
447
|
+
}
|
|
448
|
+
},
|
|
420
449
|
{
|
|
421
450
|
name: 'health_check',
|
|
422
451
|
description: `Run comprehensive ${PROFILE.displayName} memory system diagnostics. Returns chunk counts by type and visibility, identifies expiring short-term memories that need promotion, detects timeline coverage gaps, and finds conflicting or redundant long-term chunks. Use this regularly to maintain memory hygiene and before batch promotion.`,
|
|
@@ -554,6 +583,10 @@ function buildTools() {
|
|
|
554
583
|
default: 5,
|
|
555
584
|
description: 'Maximum recall results per category (identity/workflow/status, default 5)'
|
|
556
585
|
},
|
|
586
|
+
body: {
|
|
587
|
+
type: 'string',
|
|
588
|
+
description: 'Recipient body name — check for 便條 (handoff notes) addressed to this body and surface them at the top of boot output. Defaults to current profile (e.g. "coco") so any CoCo body receives CoCo-addressed notes without passing an explicit body.'
|
|
589
|
+
},
|
|
557
590
|
alert_window_hours: {
|
|
558
591
|
type: 'number',
|
|
559
592
|
minimum: 1,
|
|
@@ -580,7 +613,7 @@ function buildTools() {
|
|
|
580
613
|
{
|
|
581
614
|
name: 'session_close',
|
|
582
615
|
description:
|
|
583
|
-
`Save a ${PROFILE.displayName} session close summary before ending a conversation. The summary is stored as a short-term memory with 7-day retention, providing context for the next session boot. Include what was accomplished, what is still pending, and any important context for continuity.`,
|
|
616
|
+
`Save a ${PROFILE.displayName} session close summary before ending a conversation. The summary is stored as a short-term memory with 7-day retention, providing context for the next session boot. After saving, automatically promotes up to 5 soon-expiring short-term memories to long-term storage (alert window 48h); promote failures never fail the close. Include what was accomplished, what is still pending, and any important context for continuity.`,
|
|
584
617
|
inputSchema: {
|
|
585
618
|
type: 'object',
|
|
586
619
|
properties: {
|
|
@@ -597,6 +630,14 @@ function buildTools() {
|
|
|
597
630
|
mood: {
|
|
598
631
|
type: 'string',
|
|
599
632
|
description: 'Optional mood marker'
|
|
633
|
+
},
|
|
634
|
+
to: {
|
|
635
|
+
type: 'string',
|
|
636
|
+
description: 'Recipient body name — leave a 便條 (handoff note) for another body, surfaced on that body next session_boot'
|
|
637
|
+
},
|
|
638
|
+
note: {
|
|
639
|
+
type: 'string',
|
|
640
|
+
description: 'Short context handoff for the recipient body (used with `to`)'
|
|
600
641
|
}
|
|
601
642
|
},
|
|
602
643
|
required: ['source', 'summary'],
|
|
@@ -1055,6 +1096,8 @@ function listAvailableToolsForAuthMode(authMode) {
|
|
|
1055
1096
|
function buildSupabaseCapabilityPayload() {
|
|
1056
1097
|
return {
|
|
1057
1098
|
mode: SUPABASE_AUTH_MODE,
|
|
1099
|
+
tool_surface: 'coco',
|
|
1100
|
+
prd_tools_enabled: false,
|
|
1058
1101
|
anon_allowed_tools: listAvailableToolNamesForAuthMode('anon'),
|
|
1059
1102
|
effective_tools: listAvailableToolNamesForAuthMode(SUPABASE_AUTH_MODE)
|
|
1060
1103
|
};
|
|
@@ -1382,6 +1425,46 @@ function normalizeOptionalText(value, maxLength = 200) {
|
|
|
1382
1425
|
if (!text) return '';
|
|
1383
1426
|
return text.slice(0, Math.max(1, maxLength));
|
|
1384
1427
|
}
|
|
1428
|
+
|
|
1429
|
+
function truncateChunkContent(text, maxChars) {
|
|
1430
|
+
const raw = String(text ?? '');
|
|
1431
|
+
if (raw.length <= maxChars) {
|
|
1432
|
+
return { content: raw, preview_truncated: false, content_chars: raw.length };
|
|
1433
|
+
}
|
|
1434
|
+
return {
|
|
1435
|
+
content: raw.slice(0, maxChars),
|
|
1436
|
+
preview_truncated: true,
|
|
1437
|
+
content_chars: raw.length
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function applyRecallPreviewToItem(item) {
|
|
1442
|
+
const fullContent = String(item?.content ?? '');
|
|
1443
|
+
const preview = truncateChunkContent(fullContent, RECALL_PREVIEW_MAX_CHARS);
|
|
1444
|
+
return {
|
|
1445
|
+
...item,
|
|
1446
|
+
content: preview.content,
|
|
1447
|
+
preview_truncated: preview.preview_truncated,
|
|
1448
|
+
content_chars: preview.content_chars
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
function buildChunkToolFields(chunk) {
|
|
1453
|
+
return {
|
|
1454
|
+
id: chunk.id,
|
|
1455
|
+
source_file: chunk.source_file || null,
|
|
1456
|
+
section: chunk.section || null,
|
|
1457
|
+
body: chunk.body || null,
|
|
1458
|
+
visibility: chunk.visibility || null,
|
|
1459
|
+
tags: chunk.tags ?? [],
|
|
1460
|
+
type: chunk.type || null,
|
|
1461
|
+
date: chunk.date || null,
|
|
1462
|
+
origin: chunk.origin || null,
|
|
1463
|
+
agent_body: chunk.agent_body || null,
|
|
1464
|
+
environment: chunk.environment || null,
|
|
1465
|
+
created_at: chunk.created_at || null
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1385
1468
|
function normalizeOptionalSourceTool(value) {
|
|
1386
1469
|
const normalized = String(value || '').trim();
|
|
1387
1470
|
if (!normalized) return '';
|
|
@@ -1518,6 +1601,10 @@ function estimateToolUsageTokens(toolName, args = {}) {
|
|
|
1518
1601
|
appendTokenCharCount(safeArgs.query, state);
|
|
1519
1602
|
appendTokenCharCount(safeArgs.type, state);
|
|
1520
1603
|
break;
|
|
1604
|
+
case 'get_summary':
|
|
1605
|
+
case 'get_full':
|
|
1606
|
+
appendTokenCharCount(safeArgs.id, state);
|
|
1607
|
+
break;
|
|
1521
1608
|
case 'session_close':
|
|
1522
1609
|
appendTokenCharCount(safeArgs.summary, state);
|
|
1523
1610
|
appendTokenCharCount(safeArgs.topics, state);
|
|
@@ -2089,6 +2176,64 @@ async function upsertMemoryBySession({
|
|
|
2089
2176
|
return fetched || null;
|
|
2090
2177
|
}
|
|
2091
2178
|
|
|
2179
|
+
// ─── 便條 (Body-to-Body handoff notes) ────────────────────────────────────────
|
|
2180
|
+
// Stored as memories rows with recipient_body/note/read_at columns.
|
|
2181
|
+
// session_close(to, note) writes; session_boot(body) reads + marks read.
|
|
2182
|
+
// If the note columns are not yet deployed, these degrade gracefully (caller wraps in try/catch).
|
|
2183
|
+
|
|
2184
|
+
const NOTE_SELECT_COLUMNS =
|
|
2185
|
+
'id,body,source,session_id,tags,agent_body,environment,created_at,expires_at,recipient_body,note,read_at';
|
|
2186
|
+
|
|
2187
|
+
async function insertNoteMemory({ source, to, note, body, tags, expires_at }) {
|
|
2188
|
+
const resolvedAgentBody = resolveWriteAgentBody({ source });
|
|
2189
|
+
const resolvedEnvironment = resolveWriteEnvironment({});
|
|
2190
|
+
const createdId = crypto.randomUUID();
|
|
2191
|
+
const payload = {
|
|
2192
|
+
id: createdId,
|
|
2193
|
+
body,
|
|
2194
|
+
source,
|
|
2195
|
+
session_id: `note:to:${to}:${createdId.slice(0, 8)}`,
|
|
2196
|
+
tags: normalizeMemoryTagsWithContext(tags, resolvedAgentBody, resolvedEnvironment),
|
|
2197
|
+
agent_body: resolvedAgentBody,
|
|
2198
|
+
environment: resolvedEnvironment,
|
|
2199
|
+
recipient_body: to,
|
|
2200
|
+
note,
|
|
2201
|
+
read_at: null
|
|
2202
|
+
};
|
|
2203
|
+
if (expires_at) payload.expires_at = expires_at;
|
|
2204
|
+
const inserted = await supabaseRequest(`/rest/v1/memories?select=${NOTE_SELECT_COLUMNS}`, {
|
|
2205
|
+
method: 'POST',
|
|
2206
|
+
profile: DB_PROFILE,
|
|
2207
|
+
prefer: 'return=representation',
|
|
2208
|
+
body: [payload]
|
|
2209
|
+
});
|
|
2210
|
+
return Array.isArray(inserted) && inserted.length > 0 ? inserted[0] : null;
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
async function listUnreadNotes(bodyName, limit = 3) {
|
|
2214
|
+
const query = new URLSearchParams();
|
|
2215
|
+
query.set('select', NOTE_SELECT_COLUMNS);
|
|
2216
|
+
query.set('recipient_body', `eq.${bodyName}`);
|
|
2217
|
+
query.set('read_at', 'is.null');
|
|
2218
|
+
query.set('order', 'created_at.desc');
|
|
2219
|
+
query.set('limit', String(Math.min(limit, 10)));
|
|
2220
|
+
const rows = await supabaseRequest(`/rest/v1/memories?${query.toString()}`, {
|
|
2221
|
+
profile: DB_PROFILE
|
|
2222
|
+
});
|
|
2223
|
+
return Array.isArray(rows) ? rows : [];
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
async function markNoteRead(id) {
|
|
2227
|
+
const query = new URLSearchParams();
|
|
2228
|
+
query.set('id', `eq.${id}`);
|
|
2229
|
+
await supabaseRequest(`/rest/v1/memories?${query.toString()}`, {
|
|
2230
|
+
method: 'PATCH',
|
|
2231
|
+
profile: DB_PROFILE,
|
|
2232
|
+
prefer: 'return=minimal',
|
|
2233
|
+
body: { read_at: new Date().toISOString() }
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2092
2237
|
async function ensureDailyCloseRecovery(source, nowTs) {
|
|
2093
2238
|
const yesterdayDate = new Date(toUtcStartOfDay(nowTs).getTime() - 86400000);
|
|
2094
2239
|
const yesterdayDateKey = formatDateOnly(yesterdayDate);
|
|
@@ -2180,6 +2325,7 @@ async function runDailyBoot(args = {}) {
|
|
|
2180
2325
|
const source = normalizeDailySource(args.source);
|
|
2181
2326
|
const topic = normalizeOptionalText(args.topic, 200);
|
|
2182
2327
|
const mood = normalizeOptionalText(args.mood, 80);
|
|
2328
|
+
const bodyName = normalizeOptionalText(args.body, 60) || DB_PROFILE;
|
|
2183
2329
|
const queries = buildDailyBootQueries(args);
|
|
2184
2330
|
const inferredSourceAgentBody = inferAgentBodyFromSource(source);
|
|
2185
2331
|
const recallScopeArgs = {
|
|
@@ -2273,12 +2419,34 @@ async function runDailyBoot(args = {}) {
|
|
|
2273
2419
|
}
|
|
2274
2420
|
: await ensureDailyCloseRecovery(source, nowTs);
|
|
2275
2421
|
|
|
2422
|
+
// 便條: surface unread handoff notes addressed to this body, then mark read
|
|
2423
|
+
let notes = [];
|
|
2424
|
+
if (bodyName) {
|
|
2425
|
+
try {
|
|
2426
|
+
const unread = await listUnreadNotes(bodyName);
|
|
2427
|
+
for (const n of unread) {
|
|
2428
|
+
notes.push({
|
|
2429
|
+
id: n.id,
|
|
2430
|
+
from: normalizeOptionalText(n.source, 60) || 'unknown',
|
|
2431
|
+
to: bodyName,
|
|
2432
|
+
note: normalizeOptionalText(n.note, 1000) || '',
|
|
2433
|
+
created_at: n.created_at
|
|
2434
|
+
});
|
|
2435
|
+
await markNoteRead(n.id);
|
|
2436
|
+
}
|
|
2437
|
+
} catch (error) {
|
|
2438
|
+
// note columns may not be deployed yet — degrade gracefully, boot itself still succeeds
|
|
2439
|
+
notes = [];
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2276
2443
|
return {
|
|
2277
2444
|
ok: true,
|
|
2278
2445
|
profile: DB_PROFILE,
|
|
2279
2446
|
mode: existingHeartbeat ? 'welcome_back' : 'new_day',
|
|
2280
2447
|
source,
|
|
2281
2448
|
date: dateKey,
|
|
2449
|
+
notes,
|
|
2282
2450
|
heartbeat_id: savedHeartbeat?.id || null,
|
|
2283
2451
|
agent_name: queries.agent_name,
|
|
2284
2452
|
last_topic: previousTopic || null,
|
|
@@ -2313,6 +2481,8 @@ async function runDailyClose(args = {}) {
|
|
|
2313
2481
|
}
|
|
2314
2482
|
const topics = normalizeDailyTopics(args.topics);
|
|
2315
2483
|
const mood = normalizeOptionalText(args.mood, 80);
|
|
2484
|
+
const to = normalizeOptionalText(args.to, 60);
|
|
2485
|
+
const note = to ? normalizeOptionalText(args.note, 1000) : null;
|
|
2316
2486
|
const nowTs = new Date();
|
|
2317
2487
|
const dateKey = dailyDateKey(nowTs);
|
|
2318
2488
|
const toolUsageDailySummary = await fetchToolUsageDailySummary(dateKey);
|
|
@@ -2344,6 +2514,23 @@ async function runDailyClose(args = {}) {
|
|
|
2344
2514
|
expires_at: expiresAt,
|
|
2345
2515
|
existing: existingClose
|
|
2346
2516
|
});
|
|
2517
|
+
|
|
2518
|
+
// Auto-promote soon-expiring short-term memories (replaces Hermes dream schedule).
|
|
2519
|
+
// Failure must never break session_close — summary is already stored.
|
|
2520
|
+
let promotedCount = 0;
|
|
2521
|
+
try {
|
|
2522
|
+
const promoteResult = await runBatchPromote({
|
|
2523
|
+
alert_window_hours: 48,
|
|
2524
|
+
max_promote: 5
|
|
2525
|
+
});
|
|
2526
|
+
promotedCount = Math.max(0, Number(promoteResult?.promoted_count) || 0);
|
|
2527
|
+
} catch (error) {
|
|
2528
|
+
console.warn(
|
|
2529
|
+
`[session_close] auto batch_promote failed: ${String(error?.message || error)}`
|
|
2530
|
+
);
|
|
2531
|
+
promotedCount = 0;
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2347
2534
|
const expirySnapshot = await runHealthExpiryCheck({
|
|
2348
2535
|
alert_window_hours: 48
|
|
2349
2536
|
});
|
|
@@ -2356,11 +2543,42 @@ async function runDailyClose(args = {}) {
|
|
|
2356
2543
|
Number(expirySnapshot?.expiry_alert?.recommended_for_promotion_count) || 0
|
|
2357
2544
|
);
|
|
2358
2545
|
const expiryAction =
|
|
2359
|
-
|
|
2360
|
-
?
|
|
2361
|
-
:
|
|
2362
|
-
? '
|
|
2363
|
-
:
|
|
2546
|
+
promotedCount > 0
|
|
2547
|
+
? `auto-promoted ${promotedCount} expiring memor${promotedCount === 1 ? 'y' : 'ies'}`
|
|
2548
|
+
: recommendPromoteCount > 0
|
|
2549
|
+
? 'expiring memories remain — call batch_promote or wait for next session_close'
|
|
2550
|
+
: soonExpiringCount > 0
|
|
2551
|
+
? 'short-term memories approaching expiry — review with health_check if needed'
|
|
2552
|
+
: null;
|
|
2553
|
+
|
|
2554
|
+
// 便條: optional handoff note addressed to another body
|
|
2555
|
+
let noteResult = null;
|
|
2556
|
+
if (to) {
|
|
2557
|
+
try {
|
|
2558
|
+
const noteTags = buildLifecycleMemoryTags(['note', `to:${to}`], source, dateKey);
|
|
2559
|
+
const notePayload = {
|
|
2560
|
+
type: 'note',
|
|
2561
|
+
profile: DB_PROFILE,
|
|
2562
|
+
source,
|
|
2563
|
+
to,
|
|
2564
|
+
note,
|
|
2565
|
+
date: dateKey,
|
|
2566
|
+
created_at: nowTs.toISOString()
|
|
2567
|
+
};
|
|
2568
|
+
const savedNote = await insertNoteMemory({
|
|
2569
|
+
source,
|
|
2570
|
+
to,
|
|
2571
|
+
note: note || '',
|
|
2572
|
+
body: JSON.stringify(notePayload),
|
|
2573
|
+
tags: noteTags,
|
|
2574
|
+
expires_at: expiresAt
|
|
2575
|
+
});
|
|
2576
|
+
noteResult = { to, note_id: savedNote?.id || null, delivered: Boolean(savedNote) };
|
|
2577
|
+
} catch (error) {
|
|
2578
|
+
// note columns may not be deployed yet — degrade gracefully, close itself still succeeds
|
|
2579
|
+
noteResult = { to, delivered: false, error: String(error?.message || error) };
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2364
2582
|
|
|
2365
2583
|
return {
|
|
2366
2584
|
ok: true,
|
|
@@ -2372,6 +2590,8 @@ async function runDailyClose(args = {}) {
|
|
|
2372
2590
|
topics,
|
|
2373
2591
|
mood: mood || null,
|
|
2374
2592
|
tool_usage_daily_summary: toolUsageDailySummary,
|
|
2593
|
+
note: noteResult,
|
|
2594
|
+
promoted_count: promotedCount,
|
|
2375
2595
|
expiry_alert: {
|
|
2376
2596
|
soon_expiring_count: soonExpiringCount,
|
|
2377
2597
|
recommend_promote_count: recommendPromoteCount,
|
|
@@ -3439,6 +3659,45 @@ function buildPromoteTimeline(chunk, sourceMemory) {
|
|
|
3439
3659
|
source_to_promoted_seconds: sourceToPromotedSeconds
|
|
3440
3660
|
};
|
|
3441
3661
|
}
|
|
3662
|
+
|
|
3663
|
+
async function runGetSummary(args = {}) {
|
|
3664
|
+
const chunkId = normalizeOptionalUuid(args.id, 'id');
|
|
3665
|
+
if (!chunkId) {
|
|
3666
|
+
throw new Error('id is required');
|
|
3667
|
+
}
|
|
3668
|
+
const chunk = await fetchChunkById(chunkId);
|
|
3669
|
+
if (!chunk) {
|
|
3670
|
+
throw new Error(`memory chunk not found: ${chunkId}`);
|
|
3671
|
+
}
|
|
3672
|
+
const preview = truncateChunkContent(chunk.content, CHUNK_SUMMARY_MAX_CHARS);
|
|
3673
|
+
return {
|
|
3674
|
+
ok: true,
|
|
3675
|
+
...buildChunkToolFields(chunk),
|
|
3676
|
+
content: preview.content,
|
|
3677
|
+
preview_truncated: preview.preview_truncated,
|
|
3678
|
+
content_chars: preview.content_chars,
|
|
3679
|
+
drill_down: 'Use get_full for complete chunk text.'
|
|
3680
|
+
};
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
async function runGetFull(args = {}) {
|
|
3684
|
+
const chunkId = normalizeOptionalUuid(args.id, 'id');
|
|
3685
|
+
if (!chunkId) {
|
|
3686
|
+
throw new Error('id is required');
|
|
3687
|
+
}
|
|
3688
|
+
const chunk = await fetchChunkById(chunkId);
|
|
3689
|
+
if (!chunk) {
|
|
3690
|
+
throw new Error(`memory chunk not found: ${chunkId}`);
|
|
3691
|
+
}
|
|
3692
|
+
const fullContent = String(chunk.content ?? '');
|
|
3693
|
+
return {
|
|
3694
|
+
ok: true,
|
|
3695
|
+
...buildChunkToolFields(chunk),
|
|
3696
|
+
content: fullContent,
|
|
3697
|
+
content_chars: fullContent.length
|
|
3698
|
+
};
|
|
3699
|
+
}
|
|
3700
|
+
|
|
3442
3701
|
async function runExplainMemory(args = {}) {
|
|
3443
3702
|
const chunkId = normalizeOptionalUuid(args.id, 'id');
|
|
3444
3703
|
if (!chunkId) {
|
|
@@ -3765,9 +4024,15 @@ async function callTool(name, args = {}) {
|
|
|
3765
4024
|
}
|
|
3766
4025
|
}
|
|
3767
4026
|
: {}),
|
|
3768
|
-
items: explained.items
|
|
4027
|
+
items: explained.items.map(applyRecallPreviewToItem)
|
|
3769
4028
|
};
|
|
3770
4029
|
}
|
|
4030
|
+
if (toolName === 'get_summary') {
|
|
4031
|
+
return await runGetSummary(args);
|
|
4032
|
+
}
|
|
4033
|
+
if (toolName === 'get_full') {
|
|
4034
|
+
return await runGetFull(args);
|
|
4035
|
+
}
|
|
3771
4036
|
if (toolName === 'explain_memory') {
|
|
3772
4037
|
return await runExplainMemory(args);
|
|
3773
4038
|
}
|
|
@@ -4565,6 +4830,25 @@ const server = http.createServer(async (req, res) => {
|
|
|
4565
4830
|
return;
|
|
4566
4831
|
}
|
|
4567
4832
|
|
|
4833
|
+
// Streamable HTTP: this server is POST-only (no SSE streaming of
|
|
4834
|
+
// server-initiated notifications). Per MCP spec, respond to GET /mcp with
|
|
4835
|
+
// 405 Method Not Allowed + an `allow` header so compliant clients (Cursor,
|
|
4836
|
+
// Claude Desktop, etc.) fall back to POST-only request/response instead
|
|
4837
|
+
// of treating the missing stream as a fatal transport error (404 would
|
|
4838
|
+
// cause Cursor's V2 FSM to tombstone the transport after 5 retries).
|
|
4839
|
+
if (
|
|
4840
|
+
req.method === 'GET' &&
|
|
4841
|
+
['/mcp', '/mcp/', '/'].includes(url.pathname)
|
|
4842
|
+
) {
|
|
4843
|
+
res.writeHead(405, {
|
|
4844
|
+
'access-control-allow-origin': '*',
|
|
4845
|
+
allow: 'POST',
|
|
4846
|
+
'cache-control': 'no-store'
|
|
4847
|
+
});
|
|
4848
|
+
res.end();
|
|
4849
|
+
return;
|
|
4850
|
+
}
|
|
4851
|
+
|
|
4568
4852
|
if (req.method !== 'POST' || !['/mcp', '/mcp/', '/'].includes(url.pathname)) {
|
|
4569
4853
|
json(res, 404, { error: 'not_found' });
|
|
4570
4854
|
return;
|