nemoris 0.1.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/.env.example +49 -0
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/SECURITY.md +119 -0
- package/bin/nemoris +46 -0
- package/config/agents/agent.toml.example +28 -0
- package/config/agents/default.toml +22 -0
- package/config/agents/orchestrator.toml +18 -0
- package/config/delivery.toml +73 -0
- package/config/embeddings.toml +5 -0
- package/config/identity/default-purpose.md +1 -0
- package/config/identity/default-soul.md +3 -0
- package/config/identity/orchestrator-purpose.md +1 -0
- package/config/identity/orchestrator-soul.md +1 -0
- package/config/improvement-targets.toml +15 -0
- package/config/jobs/heartbeat-check.toml +30 -0
- package/config/jobs/memory-rollup.toml +46 -0
- package/config/jobs/workspace-health.toml +63 -0
- package/config/mcp.toml +16 -0
- package/config/output-contracts.toml +17 -0
- package/config/peers.toml +32 -0
- package/config/peers.toml.example +32 -0
- package/config/policies/memory-default.toml +10 -0
- package/config/policies/memory-heartbeat.toml +5 -0
- package/config/policies/memory-ops.toml +10 -0
- package/config/policies/tools-heartbeat-minimal.toml +8 -0
- package/config/policies/tools-interactive-safe.toml +8 -0
- package/config/policies/tools-ops-bounded.toml +8 -0
- package/config/policies/tools-orchestrator.toml +7 -0
- package/config/providers/anthropic.toml +15 -0
- package/config/providers/ollama.toml +5 -0
- package/config/providers/openai-codex.toml +9 -0
- package/config/providers/openrouter.toml +5 -0
- package/config/router.toml +22 -0
- package/config/runtime.toml +114 -0
- package/config/skills/self-improvement.toml +15 -0
- package/config/skills/telegram-onboarding-spec.md +240 -0
- package/config/skills/workspace-monitor.toml +15 -0
- package/config/task-router.toml +42 -0
- package/install.sh +50 -0
- package/package.json +90 -0
- package/src/auth/auth-profiles.js +169 -0
- package/src/auth/openai-codex-oauth.js +285 -0
- package/src/battle.js +449 -0
- package/src/cli/help.js +265 -0
- package/src/cli/output-filter.js +49 -0
- package/src/cli/runtime-control.js +704 -0
- package/src/cli-main.js +2763 -0
- package/src/cli.js +78 -0
- package/src/config/loader.js +332 -0
- package/src/config/schema-validator.js +214 -0
- package/src/config/toml-lite.js +8 -0
- package/src/daemon/action-handlers.js +71 -0
- package/src/daemon/healing-tick.js +87 -0
- package/src/daemon/health-probes.js +90 -0
- package/src/daemon/notifier.js +57 -0
- package/src/daemon/nurse.js +218 -0
- package/src/daemon/repair-log.js +106 -0
- package/src/daemon/rule-staging.js +90 -0
- package/src/daemon/rules.js +29 -0
- package/src/daemon/telegram-commands.js +54 -0
- package/src/daemon/updater.js +85 -0
- package/src/jobs/job-runner.js +78 -0
- package/src/mcp/consumer.js +129 -0
- package/src/memory/active-recall.js +171 -0
- package/src/memory/backend-manager.js +97 -0
- package/src/memory/backends/file-backend.js +38 -0
- package/src/memory/backends/qmd-backend.js +219 -0
- package/src/memory/embedding-guards.js +24 -0
- package/src/memory/embedding-index.js +118 -0
- package/src/memory/embedding-service.js +179 -0
- package/src/memory/file-index.js +177 -0
- package/src/memory/memory-signature.js +5 -0
- package/src/memory/memory-store.js +648 -0
- package/src/memory/retrieval-planner.js +66 -0
- package/src/memory/scoring.js +145 -0
- package/src/memory/simhash.js +78 -0
- package/src/memory/sqlite-active-store.js +824 -0
- package/src/memory/write-policy.js +36 -0
- package/src/onboarding/aliases.js +33 -0
- package/src/onboarding/auth/api-key.js +224 -0
- package/src/onboarding/auth/ollama-detect.js +42 -0
- package/src/onboarding/clack-prompter.js +77 -0
- package/src/onboarding/doctor.js +530 -0
- package/src/onboarding/lock.js +42 -0
- package/src/onboarding/model-catalog.js +344 -0
- package/src/onboarding/phases/auth.js +589 -0
- package/src/onboarding/phases/build.js +130 -0
- package/src/onboarding/phases/choose.js +82 -0
- package/src/onboarding/phases/detect.js +98 -0
- package/src/onboarding/phases/hatch.js +216 -0
- package/src/onboarding/phases/identity.js +79 -0
- package/src/onboarding/phases/ollama.js +345 -0
- package/src/onboarding/phases/scaffold.js +99 -0
- package/src/onboarding/phases/telegram.js +377 -0
- package/src/onboarding/phases/validate.js +204 -0
- package/src/onboarding/phases/verify.js +206 -0
- package/src/onboarding/platform.js +482 -0
- package/src/onboarding/status-bar.js +95 -0
- package/src/onboarding/templates.js +794 -0
- package/src/onboarding/toml-writer.js +38 -0
- package/src/onboarding/tui.js +250 -0
- package/src/onboarding/uninstall.js +153 -0
- package/src/onboarding/wizard.js +499 -0
- package/src/providers/anthropic.js +168 -0
- package/src/providers/base.js +247 -0
- package/src/providers/circuit-breaker.js +136 -0
- package/src/providers/ollama.js +163 -0
- package/src/providers/openai-codex.js +149 -0
- package/src/providers/openrouter.js +136 -0
- package/src/providers/registry.js +36 -0
- package/src/providers/router.js +16 -0
- package/src/runtime/bootstrap-cache.js +47 -0
- package/src/runtime/capabilities-prompt.js +25 -0
- package/src/runtime/completion-ping.js +99 -0
- package/src/runtime/config-validator.js +121 -0
- package/src/runtime/context-ledger.js +360 -0
- package/src/runtime/cutover-readiness.js +42 -0
- package/src/runtime/daemon.js +729 -0
- package/src/runtime/delivery-ack.js +195 -0
- package/src/runtime/delivery-adapters/local-file.js +41 -0
- package/src/runtime/delivery-adapters/openclaw-cli.js +94 -0
- package/src/runtime/delivery-adapters/openclaw-peer.js +98 -0
- package/src/runtime/delivery-adapters/shadow.js +13 -0
- package/src/runtime/delivery-adapters/standalone-http.js +98 -0
- package/src/runtime/delivery-adapters/telegram.js +104 -0
- package/src/runtime/delivery-adapters/tui.js +128 -0
- package/src/runtime/delivery-manager.js +807 -0
- package/src/runtime/delivery-store.js +168 -0
- package/src/runtime/dependency-health.js +118 -0
- package/src/runtime/envelope.js +114 -0
- package/src/runtime/evaluation.js +1089 -0
- package/src/runtime/exec-approvals.js +216 -0
- package/src/runtime/executor.js +500 -0
- package/src/runtime/failure-ping.js +67 -0
- package/src/runtime/flows.js +83 -0
- package/src/runtime/guards.js +45 -0
- package/src/runtime/handoff.js +51 -0
- package/src/runtime/identity-cache.js +28 -0
- package/src/runtime/improvement-engine.js +109 -0
- package/src/runtime/improvement-harness.js +581 -0
- package/src/runtime/input-sanitiser.js +72 -0
- package/src/runtime/interaction-contract.js +347 -0
- package/src/runtime/lane-readiness.js +226 -0
- package/src/runtime/migration.js +323 -0
- package/src/runtime/model-resolution.js +78 -0
- package/src/runtime/network.js +64 -0
- package/src/runtime/notification-store.js +97 -0
- package/src/runtime/notifier.js +256 -0
- package/src/runtime/orchestrator.js +53 -0
- package/src/runtime/orphan-reaper.js +41 -0
- package/src/runtime/output-contract-schema.js +139 -0
- package/src/runtime/output-contract-validator.js +439 -0
- package/src/runtime/peer-readiness.js +69 -0
- package/src/runtime/peer-registry.js +133 -0
- package/src/runtime/pilot-status.js +108 -0
- package/src/runtime/prompt-builder.js +261 -0
- package/src/runtime/provider-attempt.js +582 -0
- package/src/runtime/report-fallback.js +71 -0
- package/src/runtime/result-normalizer.js +183 -0
- package/src/runtime/retention.js +74 -0
- package/src/runtime/review.js +244 -0
- package/src/runtime/route-job.js +15 -0
- package/src/runtime/run-store.js +38 -0
- package/src/runtime/schedule.js +88 -0
- package/src/runtime/scheduler-state.js +434 -0
- package/src/runtime/scheduler.js +656 -0
- package/src/runtime/session-compactor.js +182 -0
- package/src/runtime/session-search.js +155 -0
- package/src/runtime/slack-inbound.js +249 -0
- package/src/runtime/ssrf.js +102 -0
- package/src/runtime/status-aggregator.js +330 -0
- package/src/runtime/task-contract.js +140 -0
- package/src/runtime/task-packet.js +107 -0
- package/src/runtime/task-router.js +140 -0
- package/src/runtime/telegram-inbound.js +1565 -0
- package/src/runtime/token-counter.js +134 -0
- package/src/runtime/token-estimator.js +59 -0
- package/src/runtime/tool-loop.js +200 -0
- package/src/runtime/transport-server.js +311 -0
- package/src/runtime/tui-server.js +411 -0
- package/src/runtime/ulid.js +44 -0
- package/src/security/ssrf-check.js +197 -0
- package/src/setup.js +369 -0
- package/src/shadow/bridge.js +303 -0
- package/src/skills/loader.js +84 -0
- package/src/tools/catalog.json +49 -0
- package/src/tools/cli-delegate.js +44 -0
- package/src/tools/mcp-client.js +106 -0
- package/src/tools/micro/cancel-task.js +6 -0
- package/src/tools/micro/complete-task.js +6 -0
- package/src/tools/micro/fail-task.js +6 -0
- package/src/tools/micro/http-fetch.js +74 -0
- package/src/tools/micro/index.js +36 -0
- package/src/tools/micro/lcm-recall.js +60 -0
- package/src/tools/micro/list-dir.js +17 -0
- package/src/tools/micro/list-skills.js +46 -0
- package/src/tools/micro/load-skill.js +38 -0
- package/src/tools/micro/memory-search.js +45 -0
- package/src/tools/micro/read-file.js +11 -0
- package/src/tools/micro/session-search.js +54 -0
- package/src/tools/micro/shell-exec.js +43 -0
- package/src/tools/micro/trigger-job.js +79 -0
- package/src/tools/micro/web-search.js +58 -0
- package/src/tools/micro/workspace-paths.js +39 -0
- package/src/tools/micro/write-file.js +14 -0
- package/src/tools/micro/write-memory.js +41 -0
- package/src/tools/registry.js +348 -0
- package/src/tools/tool-result-contract.js +36 -0
- package/src/tui/chat.js +835 -0
- package/src/tui/renderer.js +175 -0
- package/src/tui/socket-client.js +217 -0
- package/src/utils/canonical-json.js +29 -0
- package/src/utils/compaction.js +30 -0
- package/src/utils/env-loader.js +5 -0
- package/src/utils/errors.js +80 -0
- package/src/utils/fs.js +101 -0
- package/src/utils/ids.js +5 -0
- package/src/utils/model-context-limits.js +30 -0
- package/src/utils/token-budget.js +74 -0
- package/src/utils/usage-cost.js +25 -0
- package/src/utils/usage-metrics.js +14 -0
- package/vendor/smol-toml-1.5.2.tgz +0 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { ProviderRegistry } from "../providers/registry.js";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_TURNS = 8;
|
|
4
|
+
const DEFAULT_KEEP_RECENT_TURNS = 4;
|
|
5
|
+
const DEFAULT_COMPACTION_MODEL = "ollama/qwen3:8b";
|
|
6
|
+
|
|
7
|
+
const SUMMARISE_PROMPT =
|
|
8
|
+
"Summarise the key facts, decisions, and context from this conversation history in 3-5 sentences. " +
|
|
9
|
+
"Preserve any specific values, filenames, decisions, or action items mentioned.";
|
|
10
|
+
|
|
11
|
+
const CONDENSE_PROMPT =
|
|
12
|
+
"Condense these conversation summaries into a single high-level summary. " +
|
|
13
|
+
"Focus on long-term goals and key outcomes.";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compact a turns array by summarising old turns into a single context message.
|
|
17
|
+
*
|
|
18
|
+
* @param {Array} turns - Array of {role, content, timestamp} objects
|
|
19
|
+
* @param {object} options
|
|
20
|
+
* @param {number} [options.maxTurns=8] - Trigger compaction when turns.length >= maxTurns
|
|
21
|
+
* @param {number} [options.keepRecentTurns=4] - Always keep the last N turns verbatim
|
|
22
|
+
* @param {string} [options.compactionModel] - Model to use for summarisation
|
|
23
|
+
* @param {object} [options.registry] - ProviderRegistry instance (for testing)
|
|
24
|
+
* @param {object} [options.ledger] - ContextLedger instance
|
|
25
|
+
* @param {string} [options.sessionId] - Session ID
|
|
26
|
+
* @param {number} [options.condensedFanout=4] - Number of depth-0 summaries before depth-1 condensation
|
|
27
|
+
* @returns {{ compacted: boolean, turns: Array, summary?: string }}
|
|
28
|
+
*/
|
|
29
|
+
export async function compactSessionContext(turns, options = {}) {
|
|
30
|
+
const maxTurns = options.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
31
|
+
const keepRecentTurns = options.keepRecentTurns ?? DEFAULT_KEEP_RECENT_TURNS;
|
|
32
|
+
const compactionModel = options.compactionModel ?? DEFAULT_COMPACTION_MODEL;
|
|
33
|
+
const condensedFanout = options.condensedFanout ?? 4;
|
|
34
|
+
const ledger = options.ledger;
|
|
35
|
+
const sessionId = options.sessionId;
|
|
36
|
+
|
|
37
|
+
if (!Array.isArray(turns) || turns.length < maxTurns) {
|
|
38
|
+
return { compacted: false, turns };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const oldTurns = turns.slice(0, turns.length - keepRecentTurns);
|
|
42
|
+
const recentTurns = turns.slice(-keepRecentTurns);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const registry = options.registry ?? new ProviderRegistry();
|
|
46
|
+
const adapter = getAdapter(registry, compactionModel);
|
|
47
|
+
const modelName = getModelName(compactionModel);
|
|
48
|
+
|
|
49
|
+
const historyText = JSON.stringify(oldTurns, null, 2);
|
|
50
|
+
const payload = {
|
|
51
|
+
model: modelName,
|
|
52
|
+
system: SUMMARISE_PROMPT,
|
|
53
|
+
messages: [{ role: "user", content: historyText }],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
let summary;
|
|
57
|
+
try {
|
|
58
|
+
const raw = await adapter.invoke(payload);
|
|
59
|
+
const normalized = adapter.normalizeResponse(raw);
|
|
60
|
+
summary = normalized.output || normalized.summary || "";
|
|
61
|
+
|
|
62
|
+
if (!summary || summary.length > 2000) {
|
|
63
|
+
throw new Error(summary.length > 2000 ? "Summary too long" : "Empty summary");
|
|
64
|
+
}
|
|
65
|
+
} catch (err) {
|
|
66
|
+
summary = level3Fallback(oldTurns, sessionId);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// DAG integration
|
|
70
|
+
if (ledger && sessionId) {
|
|
71
|
+
try {
|
|
72
|
+
ledger.saveContextSummary({
|
|
73
|
+
session_id: sessionId,
|
|
74
|
+
depth: 0,
|
|
75
|
+
source_event_ids: "[]", // We don't have individual event IDs here
|
|
76
|
+
summary_text: summary,
|
|
77
|
+
token_count: 0,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const depth0s = ledger.getContextSummaries({ session_id: sessionId, depth: 0 });
|
|
81
|
+
if (depth0s.length >= condensedFanout) {
|
|
82
|
+
await runDepth1Condensation(ledger, sessionId, depth0s, { adapter, modelName, condensedFanout });
|
|
83
|
+
}
|
|
84
|
+
} catch (dagErr) {
|
|
85
|
+
console.error(JSON.stringify({ service: "session_compactor", event: "dag_error", error: dagErr.message }));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
compacted: true,
|
|
91
|
+
turns: [
|
|
92
|
+
{
|
|
93
|
+
role: "assistant",
|
|
94
|
+
content: `[Context summary] ${summary}`,
|
|
95
|
+
timestamp: new Date().toISOString(),
|
|
96
|
+
compacted: true,
|
|
97
|
+
},
|
|
98
|
+
...recentTurns,
|
|
99
|
+
],
|
|
100
|
+
summary,
|
|
101
|
+
};
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error(
|
|
104
|
+
JSON.stringify({ service: "session_compactor", error: err.message })
|
|
105
|
+
);
|
|
106
|
+
return { compacted: false, turns };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getAdapter(registry, compactionModel) {
|
|
111
|
+
const [providerPrefix] = compactionModel.split("/");
|
|
112
|
+
let providerConfig;
|
|
113
|
+
if (providerPrefix === "ollama") {
|
|
114
|
+
providerConfig = {
|
|
115
|
+
id: "ollama",
|
|
116
|
+
baseUrl: process.env.OLLAMA_BASE_URL || "http://localhost:11434",
|
|
117
|
+
};
|
|
118
|
+
} else if (providerPrefix === "openrouter") {
|
|
119
|
+
providerConfig = {
|
|
120
|
+
id: "openrouter",
|
|
121
|
+
adapter: "openrouter",
|
|
122
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
123
|
+
authRef: "env:OPENROUTER_API_KEY",
|
|
124
|
+
};
|
|
125
|
+
} else {
|
|
126
|
+
providerConfig = {
|
|
127
|
+
id: providerPrefix,
|
|
128
|
+
adapter: providerPrefix,
|
|
129
|
+
baseUrl: process.env.ANTHROPIC_BASE_URL || "https://api.anthropic.com",
|
|
130
|
+
authRef: "env:ANTHROPIC_API_KEY",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return registry.create(providerConfig);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getModelName(compactionModel) {
|
|
137
|
+
const [, ...rest] = compactionModel.split("/");
|
|
138
|
+
return rest.join("/") || compactionModel;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function level3Fallback(oldTurns, sessionId) {
|
|
142
|
+
const raw = JSON.stringify(oldTurns);
|
|
143
|
+
const truncated = raw.slice(0, 1500);
|
|
144
|
+
const summary = `${truncated} [compacted — full history in context_events]`;
|
|
145
|
+
console.warn(`[session-compactor] Level 3 fallback triggered for session ${sessionId || "unknown"}`);
|
|
146
|
+
return summary;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function runDepth1Condensation(ledger, sessionId, depth0s, { adapter, modelName }) {
|
|
150
|
+
const combinedText = depth0s.map(s => s.summary_text).join("\n---\n");
|
|
151
|
+
|
|
152
|
+
let condensed;
|
|
153
|
+
try {
|
|
154
|
+
const payload = {
|
|
155
|
+
model: modelName,
|
|
156
|
+
system: CONDENSE_PROMPT,
|
|
157
|
+
messages: [{ role: "user", content: combinedText }],
|
|
158
|
+
};
|
|
159
|
+
const raw = await adapter.invoke(payload);
|
|
160
|
+
const normalized = adapter.normalizeResponse(raw);
|
|
161
|
+
condensed = normalized.output || normalized.summary || "";
|
|
162
|
+
|
|
163
|
+
if (!condensed || condensed.length > 2000) {
|
|
164
|
+
throw new Error("Depth-1 summary failed or too long");
|
|
165
|
+
}
|
|
166
|
+
} catch (err) {
|
|
167
|
+
// Depth-1 fallback: concatenate first 500 chars of each depth-0 summary
|
|
168
|
+
condensed = depth0s.map(s => s.summary_text.slice(0, 500)).join("\n---\n");
|
|
169
|
+
console.warn(`[session-compactor] Level 3 fallback triggered for depth-1 session ${sessionId}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
ledger.saveContextSummary({
|
|
173
|
+
session_id: sessionId,
|
|
174
|
+
depth: 1,
|
|
175
|
+
source_event_ids: JSON.stringify(depth0s.map(s => s.id)),
|
|
176
|
+
summary_text: condensed,
|
|
177
|
+
token_count: 0,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Delete source depth-0 summaries
|
|
181
|
+
ledger.deleteContextSummaries({ session_id: sessionId, depth: 0 });
|
|
182
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FTS5-powered session search over conversation history.
|
|
3
|
+
* Searches context_events (messages) and context_summaries (compacted history).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Normalize a user query into FTS5 MATCH syntax.
|
|
8
|
+
* "did we discuss auth last week" → "\"did\" OR \"we\" OR \"discuss\" OR \"auth\" OR \"last\" OR \"week\""
|
|
9
|
+
*/
|
|
10
|
+
function normalizeQuery(query) {
|
|
11
|
+
return String(query || "")
|
|
12
|
+
.split(/[^a-z0-9]+/i)
|
|
13
|
+
.map(t => t.trim())
|
|
14
|
+
.filter(t => t.length > 1)
|
|
15
|
+
.map(t => `"${t.replace(/"/g, "")}"`)
|
|
16
|
+
.join(" OR ");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract text content from a context_event payload_json.
|
|
21
|
+
*/
|
|
22
|
+
function extractContent(payloadJson) {
|
|
23
|
+
try {
|
|
24
|
+
const p = typeof payloadJson === "string" ? JSON.parse(payloadJson) : payloadJson;
|
|
25
|
+
// message_in/message_out typically have { content: "..." } or { text: "..." } or is a plain string
|
|
26
|
+
if (typeof p === "string") return p;
|
|
27
|
+
return p.content || p.text || p.message || p.summary || JSON.stringify(p);
|
|
28
|
+
} catch {
|
|
29
|
+
return String(payloadJson || "");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class SessionSearch {
|
|
34
|
+
constructor(contextLedger) {
|
|
35
|
+
this.ledger = contextLedger;
|
|
36
|
+
this.db = contextLedger.db;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Ensure FTS5 tables exist. Called once after ledger.open().
|
|
41
|
+
*/
|
|
42
|
+
ensureSchema() {
|
|
43
|
+
this.db.exec(`
|
|
44
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS context_events_fts USING fts5(
|
|
45
|
+
event_id UNINDEXED, session_id UNINDEXED, kind UNINDEXED, ts UNINDEXED, content
|
|
46
|
+
);
|
|
47
|
+
`);
|
|
48
|
+
this.db.exec(`
|
|
49
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS context_summaries_fts USING fts5(
|
|
50
|
+
summary_id UNINDEXED, session_id UNINDEXED, summary_text
|
|
51
|
+
);
|
|
52
|
+
`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Index a single context event into FTS5. Call this after appendEvent().
|
|
57
|
+
*/
|
|
58
|
+
indexEvent(event) {
|
|
59
|
+
// Only index message events — tool_call/tool_result/state_patch are noisy
|
|
60
|
+
if (event.kind !== "message_in" && event.kind !== "message_out") return;
|
|
61
|
+
const content = extractContent(event.payload_json);
|
|
62
|
+
if (!content || content.length < 5) return;
|
|
63
|
+
this.db.prepare(
|
|
64
|
+
"INSERT INTO context_events_fts (event_id, session_id, kind, ts, content) VALUES (?, ?, ?, ?, ?)"
|
|
65
|
+
).run(event.id, event.session_id, event.kind, String(event.ts), content);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Index a single context summary into FTS5. Call after creating a summary.
|
|
70
|
+
*/
|
|
71
|
+
indexSummary(summary) {
|
|
72
|
+
this.db.prepare(
|
|
73
|
+
"INSERT INTO context_summaries_fts (summary_id, session_id, summary_text) VALUES (?, ?, ?)"
|
|
74
|
+
).run(String(summary.id), summary.session_id, summary.summary_text);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Rebuild the FTS index from scratch (for existing data).
|
|
79
|
+
*/
|
|
80
|
+
rebuildIndex() {
|
|
81
|
+
// Clear existing
|
|
82
|
+
this.db.exec("DELETE FROM context_events_fts");
|
|
83
|
+
this.db.exec("DELETE FROM context_summaries_fts");
|
|
84
|
+
|
|
85
|
+
// Re-index all message events
|
|
86
|
+
const events = this.db.prepare(
|
|
87
|
+
"SELECT id, session_id, kind, ts, payload_json FROM context_events WHERE kind IN ('message_in', 'message_out')"
|
|
88
|
+
).all();
|
|
89
|
+
const insertEvent = this.db.prepare(
|
|
90
|
+
"INSERT INTO context_events_fts (event_id, session_id, kind, ts, content) VALUES (?, ?, ?, ?, ?)"
|
|
91
|
+
);
|
|
92
|
+
for (const e of events) {
|
|
93
|
+
const content = extractContent(e.payload_json);
|
|
94
|
+
if (content && content.length >= 5) {
|
|
95
|
+
insertEvent.run(e.id, e.session_id, e.kind, String(e.ts), content);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Re-index all summaries
|
|
100
|
+
const summaries = this.db.prepare(
|
|
101
|
+
"SELECT id, session_id, summary_text FROM context_summaries"
|
|
102
|
+
).all();
|
|
103
|
+
const insertSummary = this.db.prepare(
|
|
104
|
+
"INSERT INTO context_summaries_fts (summary_id, session_id, summary_text) VALUES (?, ?, ?)"
|
|
105
|
+
);
|
|
106
|
+
for (const s of summaries) {
|
|
107
|
+
insertSummary.run(String(s.id), s.session_id, s.summary_text);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Search across events and summaries. Returns ranked results.
|
|
113
|
+
* @param {string} query User's search query
|
|
114
|
+
* @param {{ sessionId?: string, limit?: number }} opts
|
|
115
|
+
* @returns {{ events: Array, summaries: Array }}
|
|
116
|
+
*/
|
|
117
|
+
search(query, { sessionId, limit = 10 } = {}) {
|
|
118
|
+
const match = normalizeQuery(query);
|
|
119
|
+
if (!match) return { events: [], summaries: [] };
|
|
120
|
+
|
|
121
|
+
const sessionFilter = sessionId ? "AND session_id = ?" : "";
|
|
122
|
+
const params = sessionId ? [match, sessionId, limit] : [match, limit];
|
|
123
|
+
|
|
124
|
+
let events = [];
|
|
125
|
+
try {
|
|
126
|
+
events = this.db.prepare(`
|
|
127
|
+
SELECT event_id, session_id, kind, ts, snippet(context_events_fts, 4, '»', '«', '...', 32) as snippet,
|
|
128
|
+
rank
|
|
129
|
+
FROM context_events_fts
|
|
130
|
+
WHERE context_events_fts MATCH ?
|
|
131
|
+
${sessionFilter}
|
|
132
|
+
ORDER BY rank
|
|
133
|
+
LIMIT ?
|
|
134
|
+
`).all(...params);
|
|
135
|
+
} catch { /* FTS5 match can throw on malformed queries */ }
|
|
136
|
+
|
|
137
|
+
let summaries = [];
|
|
138
|
+
try {
|
|
139
|
+
summaries = this.db.prepare(`
|
|
140
|
+
SELECT summary_id, session_id, snippet(context_summaries_fts, 2, '»', '«', '...', 32) as snippet,
|
|
141
|
+
rank
|
|
142
|
+
FROM context_summaries_fts
|
|
143
|
+
WHERE context_summaries_fts MATCH ?
|
|
144
|
+
${sessionFilter}
|
|
145
|
+
ORDER BY rank
|
|
146
|
+
LIMIT ?
|
|
147
|
+
`).all(...params);
|
|
148
|
+
} catch { /* FTS5 match can throw on malformed queries */ }
|
|
149
|
+
|
|
150
|
+
return { events, summaries };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Export extractContent and normalizeQuery for testing
|
|
155
|
+
export { extractContent, normalizeQuery };
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Slack Inbound Handler — validates signatures, deduplicates, and enqueues
|
|
5
|
+
* incoming Slack events and slash commands as interactive jobs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MAX_TURNS = 10;
|
|
9
|
+
|
|
10
|
+
export class SlackInboundHandler {
|
|
11
|
+
constructor({ stateStore, slackConfig, availablePeers = [], agentHealthCheck = null, logger = console }) {
|
|
12
|
+
this.store = stateStore;
|
|
13
|
+
this.config = slackConfig;
|
|
14
|
+
this.availablePeers = availablePeers;
|
|
15
|
+
this.agentHealthCheck = agentHealthCheck;
|
|
16
|
+
this.logger = logger;
|
|
17
|
+
|
|
18
|
+
this.botToken = this.config.botToken;
|
|
19
|
+
this.signingSecret = this.config.signingSecret;
|
|
20
|
+
|
|
21
|
+
// Rate limiter: Map<userId, { count, windowStart }>
|
|
22
|
+
this._rateLimitMax = Number(process.env.NEMORIS_RATE_LIMIT_MAX ?? 10);
|
|
23
|
+
this._rateLimitWindowMs = Number(process.env.NEMORIS_RATE_LIMIT_WINDOW_MS ?? 60_000);
|
|
24
|
+
this._rateBuckets = new Map();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_checkRateLimit(userId) {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
const bucket = this._rateBuckets.get(userId);
|
|
30
|
+
if (!bucket || now - bucket.windowStart >= this._rateLimitWindowMs) {
|
|
31
|
+
this._rateBuckets.set(userId, { count: 1, windowStart: now });
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
if (bucket.count >= this._rateLimitMax) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
bucket.count += 1;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Verify Slack request signature using HMAC-SHA256.
|
|
43
|
+
* Signature base string: v0:<timestamp>:<body>
|
|
44
|
+
*/
|
|
45
|
+
verifySignature(headers, rawBody) {
|
|
46
|
+
const timestamp = headers["x-slack-request-timestamp"];
|
|
47
|
+
const signature = headers["x-slack-signature"];
|
|
48
|
+
|
|
49
|
+
if (!timestamp || !signature || !this.signingSecret) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Protect against replay attacks (5 minute window)
|
|
54
|
+
const now = Math.floor(Date.now() / 1000);
|
|
55
|
+
if (Math.abs(now - Number(timestamp)) > 300) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const baseString = `v0:${timestamp}:${rawBody}`;
|
|
60
|
+
const hmac = crypto.createHmac("sha256", this.signingSecret);
|
|
61
|
+
const mySignature = "v0=" + hmac.update(baseString).digest("hex");
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
return crypto.timingSafeEqual(Buffer.from(mySignature), Buffer.from(signature));
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Process inbound Slack event payload.
|
|
72
|
+
*/
|
|
73
|
+
handleEvent(payload) {
|
|
74
|
+
if (payload.type === "url_verification") {
|
|
75
|
+
return { action: "challenge", challenge: payload.challenge };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const event = payload.event;
|
|
79
|
+
if (!event) {
|
|
80
|
+
return { action: "ignored", reason: "no_event" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Ignore bot messages
|
|
84
|
+
if (event.bot_id || event.subtype === "bot_message") {
|
|
85
|
+
return { action: "ignored", reason: "bot_message" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!event.text || !event.channel || !event.user) {
|
|
89
|
+
return { action: "ignored", reason: "incomplete_event" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { channel, user, text, ts, team } = event;
|
|
93
|
+
const teamId = team || event.team_id;
|
|
94
|
+
const sessionKey = `slack:${teamId}:${channel}`;
|
|
95
|
+
const jobId = `slack-${channel}-${ts}`;
|
|
96
|
+
|
|
97
|
+
// Deduplicate by ts
|
|
98
|
+
if (this.store.interactiveJobExists(jobId)) {
|
|
99
|
+
return { action: "deduplicated", jobId };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Look up or create session
|
|
103
|
+
let session = this.store.getChatSession(sessionKey);
|
|
104
|
+
|
|
105
|
+
// Auto-bind (Slack doesn't have a simple operatorChatId check in instructions,
|
|
106
|
+
// but mirroring Telegram pattern for consistency)
|
|
107
|
+
if (!session) {
|
|
108
|
+
// For Slack MVP, we might want to auto-bind if enabled or authorized.
|
|
109
|
+
// Instructions say "mirroring Telegram pattern exactly".
|
|
110
|
+
// Telegram has _isAuthorized(chatId).
|
|
111
|
+
// Slack config doesn't have authorized ids in instructions,
|
|
112
|
+
// but let's assume we allow if authorized or it's the operator.
|
|
113
|
+
if (this._isAuthorized(user)) {
|
|
114
|
+
this.store.bindChatSession({
|
|
115
|
+
chatId: sessionKey,
|
|
116
|
+
agentId: this.config.defaultAgent || "nemo",
|
|
117
|
+
boundBy: "default",
|
|
118
|
+
});
|
|
119
|
+
session = this.store.getChatSession(sessionKey);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!session) {
|
|
124
|
+
this.logger.warn(`[slack-inbound] Unauthorized user: ${user} in channel: ${channel}`);
|
|
125
|
+
return { action: "rejected", reason: "unauthorized" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const agentId = session.agent_id;
|
|
129
|
+
|
|
130
|
+
// Check agent availability
|
|
131
|
+
if (this.agentHealthCheck) {
|
|
132
|
+
const health = this.agentHealthCheck(agentId);
|
|
133
|
+
if (!health.available) {
|
|
134
|
+
return {
|
|
135
|
+
action: "agent_unavailable",
|
|
136
|
+
agentId,
|
|
137
|
+
reason: health.reason,
|
|
138
|
+
reply: `Agent ${agentId} is no longer available.`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Rate limit check
|
|
144
|
+
if (!this._checkRateLimit(user)) {
|
|
145
|
+
this.logger.warn(`[slack-inbound] Rate limit exceeded for user: ${user}`);
|
|
146
|
+
return {
|
|
147
|
+
action: "rate_limited",
|
|
148
|
+
userId: user,
|
|
149
|
+
reply: "Slow down — max 10 messages per minute.",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Enqueue job
|
|
154
|
+
this.store.enqueueInteractiveJob({
|
|
155
|
+
jobId,
|
|
156
|
+
agentId,
|
|
157
|
+
input: text,
|
|
158
|
+
source: "slack",
|
|
159
|
+
chatId: sessionKey,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return { action: "enqueued", jobId, agentId, chatId: sessionKey };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Process slash commands.
|
|
167
|
+
*/
|
|
168
|
+
handleSlashCommand(payload) {
|
|
169
|
+
const { command, text, channel_id, user_id, team_id } = payload;
|
|
170
|
+
const sessionKey = `slack:${team_id}:${channel_id}`;
|
|
171
|
+
|
|
172
|
+
// Commands require an existing session
|
|
173
|
+
const session = this.store.getChatSession(sessionKey);
|
|
174
|
+
if (!session) {
|
|
175
|
+
return "Unauthorized. Start a conversation first.";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const cmd = command.toLowerCase();
|
|
179
|
+
const parts = text ? text.trim().split(/\s+/) : [];
|
|
180
|
+
|
|
181
|
+
switch (cmd) {
|
|
182
|
+
case "/status": {
|
|
183
|
+
return `Agent: ${session.agent_id}\nBound by: ${session.bound_by}\nSlack inbound: ACTIVE`;
|
|
184
|
+
}
|
|
185
|
+
case "/agents": {
|
|
186
|
+
const list = this.availablePeers.length > 0 ? this.availablePeers.join(", ") : "No agents configured";
|
|
187
|
+
return `Available agents: ${list}`;
|
|
188
|
+
}
|
|
189
|
+
case "/who": {
|
|
190
|
+
return `Current agent: ${session.agent_id}`;
|
|
191
|
+
}
|
|
192
|
+
case "/switch": {
|
|
193
|
+
const targetAgent = parts[0];
|
|
194
|
+
if (!targetAgent) return "Usage: /switch <agent-name>";
|
|
195
|
+
if (!this.availablePeers.includes(targetAgent)) {
|
|
196
|
+
return `Unknown agent "${targetAgent}". Use /agents to see available agents.`;
|
|
197
|
+
}
|
|
198
|
+
this.store.bindChatSession({ chatId: sessionKey, agentId: targetAgent, boundBy: "user" });
|
|
199
|
+
return `Switched to ${targetAgent}.`;
|
|
200
|
+
}
|
|
201
|
+
case "/help": {
|
|
202
|
+
return [
|
|
203
|
+
"/status — Show agent and session status",
|
|
204
|
+
"/agents — List available agents",
|
|
205
|
+
"/who — Show current agent",
|
|
206
|
+
"/switch <agent> — Switch active agent",
|
|
207
|
+
"/help — Show this message",
|
|
208
|
+
].join("\n");
|
|
209
|
+
}
|
|
210
|
+
default:
|
|
211
|
+
return `Unknown command: ${command}`;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* POST to Slack chat.postMessage API.
|
|
217
|
+
*/
|
|
218
|
+
async postMessage(channel, text) {
|
|
219
|
+
if (!this.botToken) {
|
|
220
|
+
this.logger.error("[slack-inbound] Missing botToken for postMessage");
|
|
221
|
+
return { ok: false, error: "missing_token" };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const res = await fetch("https://slack.com/api/chat.postMessage", {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: {
|
|
228
|
+
"Content-Type": "application/json",
|
|
229
|
+
"Authorization": `Bearer ${this.botToken}`,
|
|
230
|
+
},
|
|
231
|
+
body: JSON.stringify({ channel, text }),
|
|
232
|
+
});
|
|
233
|
+
const body = await res.json();
|
|
234
|
+
if (!body.ok) {
|
|
235
|
+
this.logger.error(`[slack-inbound] chat.postMessage failed: ${body.error}`);
|
|
236
|
+
}
|
|
237
|
+
return body;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
this.logger.error(`[slack-inbound] postMessage error: ${error.message}`);
|
|
240
|
+
return { ok: false, error: error.message };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
_isAuthorized(_user) {
|
|
245
|
+
// Instructions don't specify authorization rules for Slack beyond "mirroring Telegram".
|
|
246
|
+
// For MVP, we'll allow all if not explicitly restricted.
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { annotateError } from "./network.js";
|
|
2
|
+
import {
|
|
3
|
+
inspectOutboundUrl as inspectSharedOutboundUrl,
|
|
4
|
+
} from "../security/ssrf-check.js";
|
|
5
|
+
export { isBlockedIpAddress } from "../security/ssrf-check.js";
|
|
6
|
+
|
|
7
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
8
|
+
|
|
9
|
+
export async function inspectOutboundUrl(rawUrl, {
|
|
10
|
+
allowedProtocols = ["http:", "https:"],
|
|
11
|
+
invalidUrlMessage = "Invalid URL.",
|
|
12
|
+
invalidProtocolMessage = "Only http:// and https:// URLs are allowed.",
|
|
13
|
+
privateAddressMessage = "Target resolves to a private/reserved IP address.",
|
|
14
|
+
loopbackOnlyMessage = "Target must resolve to a loopback address.",
|
|
15
|
+
addressPolicy,
|
|
16
|
+
allowLoopbackAddresses = false,
|
|
17
|
+
lookupImpl,
|
|
18
|
+
} = {}) {
|
|
19
|
+
return inspectSharedOutboundUrl(rawUrl, {
|
|
20
|
+
lookupImpl,
|
|
21
|
+
allowedProtocols,
|
|
22
|
+
invalidUrlMessage,
|
|
23
|
+
invalidProtocolMessage,
|
|
24
|
+
privateAddressMessage,
|
|
25
|
+
loopbackOnlyMessage,
|
|
26
|
+
addressPolicy,
|
|
27
|
+
allowLoopbackAddresses,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function assertSafeOutboundUrl(rawUrl, options = {}) {
|
|
32
|
+
const result = await inspectOutboundUrl(rawUrl, options);
|
|
33
|
+
if (!result.ok) {
|
|
34
|
+
throw annotateError(new Error(result.reason), { surface: options.surface || "runtime" });
|
|
35
|
+
}
|
|
36
|
+
return result.parsedUrl;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeRedirectOptions(options = {}) {
|
|
40
|
+
return {
|
|
41
|
+
...options,
|
|
42
|
+
redirect: "manual",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function rewriteRedirectOptions(options, status) {
|
|
47
|
+
const nextOptions = { ...options };
|
|
48
|
+
const method = String(nextOptions.method || "GET").toUpperCase();
|
|
49
|
+
if (status === 303 || ((status === 301 || status === 302) && method === "POST")) {
|
|
50
|
+
nextOptions.method = "GET";
|
|
51
|
+
delete nextOptions.body;
|
|
52
|
+
}
|
|
53
|
+
return nextOptions;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function fetchWithOutboundPolicy(rawUrl, options = {}, {
|
|
57
|
+
fetchImpl = globalThis.fetch,
|
|
58
|
+
maxRedirects = 5,
|
|
59
|
+
surface = "runtime",
|
|
60
|
+
invalidUrlMessage,
|
|
61
|
+
invalidProtocolMessage,
|
|
62
|
+
privateAddressMessage,
|
|
63
|
+
loopbackOnlyMessage,
|
|
64
|
+
addressPolicy,
|
|
65
|
+
allowLoopbackAddresses = false,
|
|
66
|
+
lookupImpl,
|
|
67
|
+
} = {}) {
|
|
68
|
+
let currentUrl = rawUrl;
|
|
69
|
+
let currentOptions = normalizeRedirectOptions(options);
|
|
70
|
+
|
|
71
|
+
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
|
|
72
|
+
const parsedUrl = await assertSafeOutboundUrl(currentUrl, {
|
|
73
|
+
lookupImpl,
|
|
74
|
+
surface,
|
|
75
|
+
invalidUrlMessage,
|
|
76
|
+
invalidProtocolMessage,
|
|
77
|
+
privateAddressMessage,
|
|
78
|
+
loopbackOnlyMessage,
|
|
79
|
+
addressPolicy,
|
|
80
|
+
allowLoopbackAddresses,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const response = await fetchImpl(parsedUrl.toString(), currentOptions);
|
|
84
|
+
if (!REDIRECT_STATUSES.has(response.status)) {
|
|
85
|
+
return response;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const location = response.headers?.get?.("location");
|
|
89
|
+
if (!location) {
|
|
90
|
+
return response;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (redirectCount >= maxRedirects) {
|
|
94
|
+
throw annotateError(new Error(`Too many redirects for ${parsedUrl.toString()}`), { surface });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
currentUrl = new URL(location, parsedUrl).toString();
|
|
98
|
+
currentOptions = normalizeRedirectOptions(rewriteRedirectOptions(currentOptions, response.status));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw annotateError(new Error("Too many redirects."), { surface });
|
|
102
|
+
}
|