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.
Files changed (223) hide show
  1. package/.env.example +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +209 -0
  4. package/SECURITY.md +119 -0
  5. package/bin/nemoris +46 -0
  6. package/config/agents/agent.toml.example +28 -0
  7. package/config/agents/default.toml +22 -0
  8. package/config/agents/orchestrator.toml +18 -0
  9. package/config/delivery.toml +73 -0
  10. package/config/embeddings.toml +5 -0
  11. package/config/identity/default-purpose.md +1 -0
  12. package/config/identity/default-soul.md +3 -0
  13. package/config/identity/orchestrator-purpose.md +1 -0
  14. package/config/identity/orchestrator-soul.md +1 -0
  15. package/config/improvement-targets.toml +15 -0
  16. package/config/jobs/heartbeat-check.toml +30 -0
  17. package/config/jobs/memory-rollup.toml +46 -0
  18. package/config/jobs/workspace-health.toml +63 -0
  19. package/config/mcp.toml +16 -0
  20. package/config/output-contracts.toml +17 -0
  21. package/config/peers.toml +32 -0
  22. package/config/peers.toml.example +32 -0
  23. package/config/policies/memory-default.toml +10 -0
  24. package/config/policies/memory-heartbeat.toml +5 -0
  25. package/config/policies/memory-ops.toml +10 -0
  26. package/config/policies/tools-heartbeat-minimal.toml +8 -0
  27. package/config/policies/tools-interactive-safe.toml +8 -0
  28. package/config/policies/tools-ops-bounded.toml +8 -0
  29. package/config/policies/tools-orchestrator.toml +7 -0
  30. package/config/providers/anthropic.toml +15 -0
  31. package/config/providers/ollama.toml +5 -0
  32. package/config/providers/openai-codex.toml +9 -0
  33. package/config/providers/openrouter.toml +5 -0
  34. package/config/router.toml +22 -0
  35. package/config/runtime.toml +114 -0
  36. package/config/skills/self-improvement.toml +15 -0
  37. package/config/skills/telegram-onboarding-spec.md +240 -0
  38. package/config/skills/workspace-monitor.toml +15 -0
  39. package/config/task-router.toml +42 -0
  40. package/install.sh +50 -0
  41. package/package.json +90 -0
  42. package/src/auth/auth-profiles.js +169 -0
  43. package/src/auth/openai-codex-oauth.js +285 -0
  44. package/src/battle.js +449 -0
  45. package/src/cli/help.js +265 -0
  46. package/src/cli/output-filter.js +49 -0
  47. package/src/cli/runtime-control.js +704 -0
  48. package/src/cli-main.js +2763 -0
  49. package/src/cli.js +78 -0
  50. package/src/config/loader.js +332 -0
  51. package/src/config/schema-validator.js +214 -0
  52. package/src/config/toml-lite.js +8 -0
  53. package/src/daemon/action-handlers.js +71 -0
  54. package/src/daemon/healing-tick.js +87 -0
  55. package/src/daemon/health-probes.js +90 -0
  56. package/src/daemon/notifier.js +57 -0
  57. package/src/daemon/nurse.js +218 -0
  58. package/src/daemon/repair-log.js +106 -0
  59. package/src/daemon/rule-staging.js +90 -0
  60. package/src/daemon/rules.js +29 -0
  61. package/src/daemon/telegram-commands.js +54 -0
  62. package/src/daemon/updater.js +85 -0
  63. package/src/jobs/job-runner.js +78 -0
  64. package/src/mcp/consumer.js +129 -0
  65. package/src/memory/active-recall.js +171 -0
  66. package/src/memory/backend-manager.js +97 -0
  67. package/src/memory/backends/file-backend.js +38 -0
  68. package/src/memory/backends/qmd-backend.js +219 -0
  69. package/src/memory/embedding-guards.js +24 -0
  70. package/src/memory/embedding-index.js +118 -0
  71. package/src/memory/embedding-service.js +179 -0
  72. package/src/memory/file-index.js +177 -0
  73. package/src/memory/memory-signature.js +5 -0
  74. package/src/memory/memory-store.js +648 -0
  75. package/src/memory/retrieval-planner.js +66 -0
  76. package/src/memory/scoring.js +145 -0
  77. package/src/memory/simhash.js +78 -0
  78. package/src/memory/sqlite-active-store.js +824 -0
  79. package/src/memory/write-policy.js +36 -0
  80. package/src/onboarding/aliases.js +33 -0
  81. package/src/onboarding/auth/api-key.js +224 -0
  82. package/src/onboarding/auth/ollama-detect.js +42 -0
  83. package/src/onboarding/clack-prompter.js +77 -0
  84. package/src/onboarding/doctor.js +530 -0
  85. package/src/onboarding/lock.js +42 -0
  86. package/src/onboarding/model-catalog.js +344 -0
  87. package/src/onboarding/phases/auth.js +589 -0
  88. package/src/onboarding/phases/build.js +130 -0
  89. package/src/onboarding/phases/choose.js +82 -0
  90. package/src/onboarding/phases/detect.js +98 -0
  91. package/src/onboarding/phases/hatch.js +216 -0
  92. package/src/onboarding/phases/identity.js +79 -0
  93. package/src/onboarding/phases/ollama.js +345 -0
  94. package/src/onboarding/phases/scaffold.js +99 -0
  95. package/src/onboarding/phases/telegram.js +377 -0
  96. package/src/onboarding/phases/validate.js +204 -0
  97. package/src/onboarding/phases/verify.js +206 -0
  98. package/src/onboarding/platform.js +482 -0
  99. package/src/onboarding/status-bar.js +95 -0
  100. package/src/onboarding/templates.js +794 -0
  101. package/src/onboarding/toml-writer.js +38 -0
  102. package/src/onboarding/tui.js +250 -0
  103. package/src/onboarding/uninstall.js +153 -0
  104. package/src/onboarding/wizard.js +499 -0
  105. package/src/providers/anthropic.js +168 -0
  106. package/src/providers/base.js +247 -0
  107. package/src/providers/circuit-breaker.js +136 -0
  108. package/src/providers/ollama.js +163 -0
  109. package/src/providers/openai-codex.js +149 -0
  110. package/src/providers/openrouter.js +136 -0
  111. package/src/providers/registry.js +36 -0
  112. package/src/providers/router.js +16 -0
  113. package/src/runtime/bootstrap-cache.js +47 -0
  114. package/src/runtime/capabilities-prompt.js +25 -0
  115. package/src/runtime/completion-ping.js +99 -0
  116. package/src/runtime/config-validator.js +121 -0
  117. package/src/runtime/context-ledger.js +360 -0
  118. package/src/runtime/cutover-readiness.js +42 -0
  119. package/src/runtime/daemon.js +729 -0
  120. package/src/runtime/delivery-ack.js +195 -0
  121. package/src/runtime/delivery-adapters/local-file.js +41 -0
  122. package/src/runtime/delivery-adapters/openclaw-cli.js +94 -0
  123. package/src/runtime/delivery-adapters/openclaw-peer.js +98 -0
  124. package/src/runtime/delivery-adapters/shadow.js +13 -0
  125. package/src/runtime/delivery-adapters/standalone-http.js +98 -0
  126. package/src/runtime/delivery-adapters/telegram.js +104 -0
  127. package/src/runtime/delivery-adapters/tui.js +128 -0
  128. package/src/runtime/delivery-manager.js +807 -0
  129. package/src/runtime/delivery-store.js +168 -0
  130. package/src/runtime/dependency-health.js +118 -0
  131. package/src/runtime/envelope.js +114 -0
  132. package/src/runtime/evaluation.js +1089 -0
  133. package/src/runtime/exec-approvals.js +216 -0
  134. package/src/runtime/executor.js +500 -0
  135. package/src/runtime/failure-ping.js +67 -0
  136. package/src/runtime/flows.js +83 -0
  137. package/src/runtime/guards.js +45 -0
  138. package/src/runtime/handoff.js +51 -0
  139. package/src/runtime/identity-cache.js +28 -0
  140. package/src/runtime/improvement-engine.js +109 -0
  141. package/src/runtime/improvement-harness.js +581 -0
  142. package/src/runtime/input-sanitiser.js +72 -0
  143. package/src/runtime/interaction-contract.js +347 -0
  144. package/src/runtime/lane-readiness.js +226 -0
  145. package/src/runtime/migration.js +323 -0
  146. package/src/runtime/model-resolution.js +78 -0
  147. package/src/runtime/network.js +64 -0
  148. package/src/runtime/notification-store.js +97 -0
  149. package/src/runtime/notifier.js +256 -0
  150. package/src/runtime/orchestrator.js +53 -0
  151. package/src/runtime/orphan-reaper.js +41 -0
  152. package/src/runtime/output-contract-schema.js +139 -0
  153. package/src/runtime/output-contract-validator.js +439 -0
  154. package/src/runtime/peer-readiness.js +69 -0
  155. package/src/runtime/peer-registry.js +133 -0
  156. package/src/runtime/pilot-status.js +108 -0
  157. package/src/runtime/prompt-builder.js +261 -0
  158. package/src/runtime/provider-attempt.js +582 -0
  159. package/src/runtime/report-fallback.js +71 -0
  160. package/src/runtime/result-normalizer.js +183 -0
  161. package/src/runtime/retention.js +74 -0
  162. package/src/runtime/review.js +244 -0
  163. package/src/runtime/route-job.js +15 -0
  164. package/src/runtime/run-store.js +38 -0
  165. package/src/runtime/schedule.js +88 -0
  166. package/src/runtime/scheduler-state.js +434 -0
  167. package/src/runtime/scheduler.js +656 -0
  168. package/src/runtime/session-compactor.js +182 -0
  169. package/src/runtime/session-search.js +155 -0
  170. package/src/runtime/slack-inbound.js +249 -0
  171. package/src/runtime/ssrf.js +102 -0
  172. package/src/runtime/status-aggregator.js +330 -0
  173. package/src/runtime/task-contract.js +140 -0
  174. package/src/runtime/task-packet.js +107 -0
  175. package/src/runtime/task-router.js +140 -0
  176. package/src/runtime/telegram-inbound.js +1565 -0
  177. package/src/runtime/token-counter.js +134 -0
  178. package/src/runtime/token-estimator.js +59 -0
  179. package/src/runtime/tool-loop.js +200 -0
  180. package/src/runtime/transport-server.js +311 -0
  181. package/src/runtime/tui-server.js +411 -0
  182. package/src/runtime/ulid.js +44 -0
  183. package/src/security/ssrf-check.js +197 -0
  184. package/src/setup.js +369 -0
  185. package/src/shadow/bridge.js +303 -0
  186. package/src/skills/loader.js +84 -0
  187. package/src/tools/catalog.json +49 -0
  188. package/src/tools/cli-delegate.js +44 -0
  189. package/src/tools/mcp-client.js +106 -0
  190. package/src/tools/micro/cancel-task.js +6 -0
  191. package/src/tools/micro/complete-task.js +6 -0
  192. package/src/tools/micro/fail-task.js +6 -0
  193. package/src/tools/micro/http-fetch.js +74 -0
  194. package/src/tools/micro/index.js +36 -0
  195. package/src/tools/micro/lcm-recall.js +60 -0
  196. package/src/tools/micro/list-dir.js +17 -0
  197. package/src/tools/micro/list-skills.js +46 -0
  198. package/src/tools/micro/load-skill.js +38 -0
  199. package/src/tools/micro/memory-search.js +45 -0
  200. package/src/tools/micro/read-file.js +11 -0
  201. package/src/tools/micro/session-search.js +54 -0
  202. package/src/tools/micro/shell-exec.js +43 -0
  203. package/src/tools/micro/trigger-job.js +79 -0
  204. package/src/tools/micro/web-search.js +58 -0
  205. package/src/tools/micro/workspace-paths.js +39 -0
  206. package/src/tools/micro/write-file.js +14 -0
  207. package/src/tools/micro/write-memory.js +41 -0
  208. package/src/tools/registry.js +348 -0
  209. package/src/tools/tool-result-contract.js +36 -0
  210. package/src/tui/chat.js +835 -0
  211. package/src/tui/renderer.js +175 -0
  212. package/src/tui/socket-client.js +217 -0
  213. package/src/utils/canonical-json.js +29 -0
  214. package/src/utils/compaction.js +30 -0
  215. package/src/utils/env-loader.js +5 -0
  216. package/src/utils/errors.js +80 -0
  217. package/src/utils/fs.js +101 -0
  218. package/src/utils/ids.js +5 -0
  219. package/src/utils/model-context-limits.js +30 -0
  220. package/src/utils/token-budget.js +74 -0
  221. package/src/utils/usage-cost.js +25 -0
  222. package/src/utils/usage-metrics.js +14 -0
  223. 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
+ }