hippo-memory 1.72.0 → 2.0.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/README.md +6 -4
- package/dist/agent-memories/apply.js +5 -5
- package/dist/api/assemble.js +2 -1
- package/dist/api/audit.d.ts +7 -7
- package/dist/api/audit.js +19 -17
- package/dist/api/auth.d.ts +5 -6
- package/dist/api/auth.js +28 -33
- package/dist/api/conflicts.js +1 -1
- package/dist/api/context-auto.js +4 -2
- package/dist/api/context-select.js +8 -20
- package/dist/api/context.js +12 -10
- package/dist/api/drill-down.js +2 -1
- package/dist/api/forget.d.ts +2 -2
- package/dist/api/forget.js +6 -7
- package/dist/api/graph.js +2 -1
- package/dist/api/learn.d.ts +1 -1
- package/dist/api/learn.js +45 -44
- package/dist/api/memories.d.ts +4 -2
- package/dist/api/memories.js +16 -5
- package/dist/api/objects.js +2 -1
- package/dist/api/outcome.d.ts +5 -5
- package/dist/api/outcome.js +16 -15
- package/dist/api/predictions.js +2 -1
- package/dist/api/promote.d.ts +3 -3
- package/dist/api/promote.js +25 -27
- package/dist/api/prompt-hook.d.ts +3 -1
- package/dist/api/prompt-hook.js +1 -0
- package/dist/api/quarantine.js +2 -1
- package/dist/api/recall-core.js +8 -3
- package/dist/api/recall-pipeline.js +17 -17
- package/dist/api/recall-types.d.ts +3 -1
- package/dist/api/recall.js +32 -29
- package/dist/api/remember-local.d.ts +5 -2
- package/dist/api/remember-local.js +14 -6
- package/dist/api/remember.d.ts +5 -2
- package/dist/api/remember.js +16 -14
- package/dist/api/session-end.d.ts +24 -0
- package/dist/api/session-end.js +31 -1
- package/dist/api/sleep-run.js +5 -4
- package/dist/api/sleep.js +4 -3
- package/dist/api/tokens.js +1 -1
- package/dist/api/types.d.ts +1 -7
- package/dist/capture/command.js +1 -1
- package/dist/capture/compaction-spool.d.ts +3 -5
- package/dist/capture/compaction-spool.js +41 -46
- package/dist/capture/session-end-caller.js +1 -1
- package/dist/capture/session-worker.js +19 -6
- package/dist/cli/audit.js +10 -10
- package/dist/cli/auth.js +4 -4
- package/dist/cli/client.d.ts +1 -2
- package/dist/cli/client.js +13 -13
- package/dist/cli/context.js +1 -1
- package/dist/cli/curate.d.ts +1 -1
- package/dist/cli/curate.js +9 -9
- package/dist/cli/decisions.js +4 -5
- package/dist/cli/hook-runtime.d.ts +3 -1
- package/dist/cli/hook-runtime.js +2 -2
- package/dist/cli/init.d.ts +1 -1
- package/dist/cli/init.js +9 -9
- package/dist/cli/object-verbs.d.ts +2 -2
- package/dist/cli/object-verbs.js +4 -4
- package/dist/cli/predictions.js +2 -3
- package/dist/cli/quality-repair-once.d.ts +1 -1
- package/dist/cli/quality-repair-once.js +2 -2
- package/dist/cli/refine-llm.js +2 -1
- package/dist/cli/remember.js +5 -5
- package/dist/cli/serve.js +9 -4
- package/dist/cli/session-hooks.d.ts +0 -4
- package/dist/cli/session-hooks.js +57 -66
- package/dist/cli/setup.d.ts +1 -1
- package/dist/cli/setup.js +2 -2
- package/dist/cli/shared.d.ts +1 -1
- package/dist/cli/shared.js +2 -2
- package/dist/cli/sleep.js +2 -2
- package/dist/cli/transfer.d.ts +3 -3
- package/dist/cli/transfer.js +34 -15
- package/dist/cli/usage.d.ts +1 -1
- package/dist/cli/usage.js +1 -1
- package/dist/cli/verb-row.d.ts +2 -0
- package/dist/cli/verbs/health.js +2 -0
- package/dist/cli/verbs/memory.js +43 -51
- package/dist/cli/verbs/objects.js +6 -3
- package/dist/cli/verbs/sessions.js +4 -2
- package/dist/cli.js +1 -2
- package/dist/connectors/dlq.js +2 -1
- package/dist/connectors/github/deletion.js +2 -1
- package/dist/connectors/github/ingest.js +2 -1
- package/dist/connectors/github/tenant-routing.js +2 -1
- package/dist/connectors/github/webhook.js +4 -2
- package/dist/connectors/ingest.js +2 -1
- package/dist/connectors/slack/deletion.js +2 -1
- package/dist/connectors/slack/ingest.js +2 -1
- package/dist/connectors/slack/tenant-routing.js +2 -1
- package/dist/consolidate/conflicts.js +1 -1
- package/dist/consolidate/dag.js +14 -9
- package/dist/consolidate/dedupe.js +1 -1
- package/dist/consolidate/memory-value.js +1 -1
- package/dist/consolidate/merge.js +6 -6
- package/dist/consolidate/sleep.js +1 -1
- package/dist/consolidate/traces.js +2 -2
- package/dist/core/config.js +15 -16
- package/dist/core/markdown.js +2 -2
- package/dist/core/memory.d.ts +4 -4
- package/dist/core/memory.js +10 -5
- package/dist/core/physics.d.ts +3 -1
- package/dist/core/physics.js +17 -2
- package/dist/core/project-identity.js +2 -1
- package/dist/core/search-types.d.ts +1 -1
- package/dist/dashboard/dashboard-actions.d.ts +2 -2
- package/dist/dashboard/dashboard-actions.js +5 -5
- package/dist/dashboard/dashboard-queries.js +2 -7
- package/dist/dashboard/dashboard-snapshot.js +1 -3
- package/dist/dashboard/dashboard.js +16 -5
- package/dist/db/connect.d.ts +2 -1
- package/dist/db/connect.js +2 -1
- package/dist/db/migrate.js +14 -7
- package/dist/db/migrations/v11.js +1 -0
- package/dist/db/migrations/v14.js +1 -0
- package/dist/db/physics-state.d.ts +0 -4
- package/dist/db/physics-state.js +5 -31
- package/dist/db/request-stores.d.ts +1 -0
- package/dist/db/request-stores.js +2 -1
- package/dist/db/vector-store.js +2 -1
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +7 -7
- package/dist/embeddings/local.js +2 -2
- package/dist/embeddings/provider.js +1 -10
- package/dist/entry/store-kit.d.ts +37 -0
- package/dist/entry/store-kit.js +25 -0
- package/dist/entry/testing.d.ts +6 -0
- package/dist/entry/testing.js +7 -0
- package/dist/eval/judgment.js +1 -1
- package/dist/hooks/json-hooks.js +6 -2
- package/dist/importers/core.js +3 -3
- package/dist/importers/vault.d.ts +1 -1
- package/dist/importers/vault.js +18 -18
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/learn/autolearn.d.ts +6 -2
- package/dist/learn/autolearn.js +8 -6
- package/dist/learn/extract.js +1 -1
- package/dist/learn/invalidation.d.ts +2 -1
- package/dist/learn/invalidation.js +10 -5
- package/dist/mcp/memory-tools.d.ts +1 -1
- package/dist/mcp/memory-tools.js +6 -9
- package/dist/mcp/request.js +20 -19
- package/dist/mcp/stdio.js +13 -8
- package/dist/rerankers/cross-encoder.js +10 -3
- package/dist/search/embed-memories.d.ts +5 -2
- package/dist/search/embed-memories.js +40 -28
- package/dist/search/engine.d.ts +8 -0
- package/dist/search/engine.js +10 -0
- package/dist/search/vector.js +5 -5
- package/dist/server/auth.d.ts +1 -1
- package/dist/server/auth.js +7 -8
- package/dist/server/boot.d.ts +2 -1
- package/dist/server/boot.js +28 -19
- package/dist/server/client-ip.d.ts +9 -4
- package/dist/server/client-ip.js +35 -17
- package/dist/server/route-table.js +5 -6
- package/dist/server/routes/memories.js +4 -2
- package/dist/server/sleep-offload.js +3 -3
- package/dist/server/tls.d.ts +7 -2
- package/dist/server/tls.js +17 -5
- package/dist/server/types.d.ts +7 -2
- package/dist/server.d.ts +4 -41
- package/dist/server.js +3 -30
- package/dist/sharing/global-store.d.ts +4 -5
- package/dist/sharing/global-store.js +6 -11
- package/dist/sharing/global-sync.js +1 -1
- package/dist/sharing/search-both.js +9 -2
- package/dist/sharing/share.js +2 -4
- package/dist/store/ambient.js +2 -1
- package/dist/store/candidates.js +18 -8
- package/dist/store/cards.js +5 -4
- package/dist/store/compactions-record.js +8 -7
- package/dist/store/conflicts.d.ts +7 -1
- package/dist/store/conflicts.js +6 -5
- package/dist/store/connectors/dlq.js +2 -1
- package/dist/store/connectors/event-log.d.ts +13 -0
- package/dist/store/connectors/event-log.js +24 -0
- package/dist/store/connectors/github.js +6 -8
- package/dist/store/connectors/slack.js +6 -10
- package/dist/store/dashboard-reads.js +1 -0
- package/dist/store/delete-and-batch.js +5 -4
- package/dist/store/delivery-recorder.d.ts +2 -2
- package/dist/store/delivery-recorder.js +1 -6
- package/dist/store/descendant-page.js +2 -1
- package/dist/store/dormant.js +7 -3
- package/dist/store/entry-reads.d.ts +25 -3
- package/dist/store/entry-reads.js +43 -12
- package/dist/store/entry-row.js +9 -2
- package/dist/store/entry-writes.d.ts +10 -7
- package/dist/store/entry-writes.js +23 -11
- package/dist/store/goals.js +3 -2
- package/dist/store/graph-reads.js +5 -4
- package/dist/store/graph-view-rows.js +2 -1
- package/dist/store/half-life.js +2 -2
- package/dist/store/handoffs.d.ts +9 -3
- package/dist/store/handoffs.js +4 -3
- package/dist/store/index.d.ts +2 -2
- package/dist/store/index.js +1 -1
- package/dist/store/mirrors.js +9 -4
- package/dist/store/open.d.ts +2 -1
- package/dist/store/open.js +2 -1
- package/dist/store/port.d.ts +59 -3
- package/dist/store/quality-repair.js +1 -1
- package/dist/store/recall-trace.js +2 -1
- package/dist/store/rejected-values.js +14 -9
- package/dist/store/rows.js +2 -2
- package/dist/store/rule-sql.d.ts +2 -0
- package/dist/store/rule-sql.js +12 -6
- package/dist/store/search-rows.d.ts +5 -5
- package/dist/store/search-rows.js +9 -12
- package/dist/store/set-aside.js +1 -1
- package/dist/store/sqlite/connector-writes-group.js +3 -1
- package/dist/store/sqlite/entry-writes-group.d.ts +3 -3
- package/dist/store/sqlite/entry-writes-group.js +1 -1
- package/dist/store/sqlite/held-connection.d.ts +2 -0
- package/dist/store/sqlite/held-connection.js +1 -1
- package/dist/store/sqlite/last-recall-group.d.ts +13 -0
- package/dist/store/sqlite/last-recall-group.js +57 -0
- package/dist/store/sqlite/local.d.ts +0 -17
- package/dist/store/sqlite/local.js +0 -31
- package/dist/store/sqlite/maintenance-groups.d.ts +6 -0
- package/dist/store/sqlite/maintenance-groups.js +60 -0
- package/dist/store/sqlite/objects-group.js +14 -6
- package/dist/store/sqlite/store.d.ts +1 -8
- package/dist/store/sqlite/store.js +13 -24
- package/dist/store/summaries.d.ts +7 -0
- package/dist/store/summaries.js +49 -7
- package/dist/store/token-ledger-rows.js +3 -2
- package/dist/store/vector-index.js +5 -2
- package/dist/util/anthropic-messages.d.ts +3 -1
- package/dist/util/anthropic-messages.js +4 -2
- package/dist/util/atomic-write.d.ts +5 -2
- package/dist/util/atomic-write.js +12 -8
- package/dist/util/env.d.ts +10 -1
- package/dist/util/env.js +10 -1
- package/dist/util/format.d.ts +2 -0
- package/dist/util/format.js +4 -0
- package/dist/util/request-scope.d.ts +6 -2
- package/dist/util/request-scope.js +7 -3
- package/dist/util/spawn-argv.d.ts +9 -0
- package/dist/util/spawn-argv.js +57 -0
- package/dist/util/sql.d.ts +3 -0
- package/dist/util/sql.js +6 -0
- package/dist/util/vector-codec.d.ts +4 -0
- package/dist/util/vector-codec.js +15 -0
- package/dist/util/version.d.ts +1 -1
- package/dist/util/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +3 -1
- package/dist/api/on-store.d.ts +0 -16
- package/dist/api/on-store.js +0 -38
- package/dist/cli/lenient-id.d.ts +0 -2
- package/dist/cli/lenient-id.js +0 -13
package/dist/mcp/request.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// Transport-agnostic request handling: tool dispatch table, tool execution and the JSON-RPC method switch.
|
|
2
2
|
import { errorMessage, log } from '../util/log.js';
|
|
3
|
+
import { currentSignal } from '../util/request-scope.js';
|
|
3
4
|
import { STORE_NOT_PORTED_MESSAGE } from '../util/http-util.js';
|
|
4
5
|
import { getGlobalRoot, initGlobal } from '../sharing/global-store.js';
|
|
5
6
|
import { loadConfig } from '../core/config.js';
|
|
6
7
|
import { resolveTenantId } from '../store/tenant.js';
|
|
7
8
|
import { runWithRequestStores } from '../db/index.js';
|
|
8
9
|
import { rethrowIfSqliteBlocked } from '../util/sqlite-blocked.js';
|
|
9
|
-
import { hasGroup
|
|
10
|
+
import { hasGroup } from '../store/index.js';
|
|
11
|
+
import { storeFor } from '../store/sqlite/store.js';
|
|
10
12
|
import { estimateTokens } from '../util/token-text.js';
|
|
11
13
|
import { PACKAGE_VERSION } from '../util/version.js';
|
|
12
14
|
import { validateToolArgs } from './tool-args.js';
|
|
@@ -62,20 +64,15 @@ const TOOL_HANDLERS = new Map([
|
|
|
62
64
|
['hippo_outcome', { handler: runOutcomeTool, storeReady: 'entryWrites' }],
|
|
63
65
|
['hippo_context', { handler: runContextTool, storeReady: 'contextReads' }],
|
|
64
66
|
['hippo_status', { handler: runStatusTool }],
|
|
65
|
-
['hippo_learn', { handler: runLearnTool }],
|
|
67
|
+
['hippo_learn', { handler: runLearnTool, storeReady: 'lessonWrites' }],
|
|
66
68
|
['hippo_conflicts', { handler: runConflictsTool }],
|
|
67
69
|
['hippo_resolve', { handler: runResolveTool }],
|
|
68
70
|
['hippo_share', { handler: runShareTool }],
|
|
69
71
|
['hippo_peers', { handler: runPeersTool }],
|
|
70
72
|
]);
|
|
71
|
-
/**
|
|
72
|
-
function otherStore(ctx) {
|
|
73
|
-
const store = ctx?.store;
|
|
74
|
-
return store !== undefined && store.kind !== 'sqlite' ? store : null;
|
|
75
|
-
}
|
|
73
|
+
/** Stdio serves no store and runs every tool on the folder's hippo.db. */
|
|
76
74
|
function runsOn(store, toolName) {
|
|
77
|
-
|
|
78
|
-
return group !== undefined && hasGroup(store, group);
|
|
75
|
+
return store === undefined || hasGroup(store, TOOL_HANDLERS.get(toolName)?.storeReady ?? 'localFiles');
|
|
79
76
|
}
|
|
80
77
|
async function executeTool(name, args, ctx) {
|
|
81
78
|
// With a transport context (HTTP), trust it: hippoRoot comes from the server's bound opts and tenantId from the Bearer (or loopback fallback).
|
|
@@ -100,6 +97,17 @@ function invalidArgs(id, toolName, problems) {
|
|
|
100
97
|
result: { content: [{ type: 'text', text: `Invalid arguments for ${toolName}: ${problems.join('; ')}` }], isError: true },
|
|
101
98
|
};
|
|
102
99
|
}
|
|
100
|
+
/** One handle per store for the tool and its ledger row; stdio interleaves calls, so each gets its own scope.
|
|
101
|
+
* SHORTCUT: the deadline stops a call only between these steps, not a store read or fetch already running; thread the signal into them. */
|
|
102
|
+
function runToolScoped(toolName, args, ctx) {
|
|
103
|
+
return runWithRequestStores(async () => {
|
|
104
|
+
currentSignal()?.throwIfAborted();
|
|
105
|
+
const text = await executeTool(toolName, args, ctx);
|
|
106
|
+
currentSignal()?.throwIfAborted();
|
|
107
|
+
await recordMcpTokens(toolName, text, ctx);
|
|
108
|
+
return text;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
103
111
|
async function callTool(id, params, ctx) {
|
|
104
112
|
const nameValue = params?.name;
|
|
105
113
|
const toolName = isJsonString(nameValue) ? nameValue : '';
|
|
@@ -108,8 +116,7 @@ async function callTool(id, params, ctx) {
|
|
|
108
116
|
return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool: ${toolName.slice(0, 128)}` } };
|
|
109
117
|
}
|
|
110
118
|
// The same refusal a ported tool gives when it reaches hippo.db, so a client handles one shape.
|
|
111
|
-
|
|
112
|
-
if (other && !runsOn(other, toolName)) {
|
|
119
|
+
if (!runsOn(ctx?.store, toolName)) {
|
|
113
120
|
return { jsonrpc: '2.0', id, error: { code: -32603, message: STORE_NOT_PORTED_MESSAGE } };
|
|
114
121
|
}
|
|
115
122
|
const refusal = sharedStoreRefusal(toolName, ctx);
|
|
@@ -125,12 +132,7 @@ async function callTool(id, params, ctx) {
|
|
|
125
132
|
return invalidArgs(id, toolName, problems);
|
|
126
133
|
let output;
|
|
127
134
|
try {
|
|
128
|
-
|
|
129
|
-
output = await runWithRequestStores(async () => {
|
|
130
|
-
const text = await executeTool(toolName, toolArgs, ctx);
|
|
131
|
-
await recordMcpTokens(toolName, text, ctx);
|
|
132
|
-
return text;
|
|
133
|
-
});
|
|
135
|
+
output = await runToolScoped(toolName, toolArgs, ctx);
|
|
134
136
|
}
|
|
135
137
|
catch (err) {
|
|
136
138
|
if (!(err instanceof RecallRequestError))
|
|
@@ -163,8 +165,7 @@ export async function handleMcpRequest(req, ctx) {
|
|
|
163
165
|
case 'notifications/initialized':
|
|
164
166
|
return null;
|
|
165
167
|
case 'tools/list': {
|
|
166
|
-
|
|
167
|
-
return { jsonrpc: '2.0', id, result: { tools: other ? TOOLS.filter((t) => runsOn(other, t.name)) : TOOLS } };
|
|
168
|
+
return { jsonrpc: '2.0', id, result: { tools: TOOLS.filter((t) => runsOn(ctx?.store, t.name)) } };
|
|
168
169
|
}
|
|
169
170
|
case 'tools/call':
|
|
170
171
|
return callTool(id, params, ctx);
|
package/dist/mcp/stdio.js
CHANGED
|
@@ -75,7 +75,7 @@ function callName(req) {
|
|
|
75
75
|
}
|
|
76
76
|
/** Answers `req` with one timeout error at the deadline a served request has. The returned function stops the timer and says whether that
|
|
77
77
|
* answer went out, so a later reply is dropped. */
|
|
78
|
-
function watchDeadline(req, owedReply) {
|
|
78
|
+
function watchDeadline(req, owedReply, abort) {
|
|
79
79
|
const ms = requestDeadlineMs();
|
|
80
80
|
if (ms === 0)
|
|
81
81
|
return () => false;
|
|
@@ -83,7 +83,8 @@ function watchDeadline(req, owedReply) {
|
|
|
83
83
|
const timer = setTimeout(() => {
|
|
84
84
|
expired = true;
|
|
85
85
|
const name = callName(req);
|
|
86
|
-
log.warn(`mcp: ${name} did not answer within ${ms} ms;
|
|
86
|
+
log.warn(`mcp: ${name} did not answer within ${ms} ms; its next step is cancelled and its reply will be dropped`, { method: req.method });
|
|
87
|
+
abort.abort(new DOMException(`${name} did not finish within ${ms} ms`, 'TimeoutError'));
|
|
87
88
|
if (!owedReply)
|
|
88
89
|
return;
|
|
89
90
|
const requestId = currentRequestId();
|
|
@@ -100,7 +101,7 @@ function watchDeadline(req, owedReply) {
|
|
|
100
101
|
return expired;
|
|
101
102
|
};
|
|
102
103
|
}
|
|
103
|
-
function dispatch(body) {
|
|
104
|
+
function dispatch(body, abort) {
|
|
104
105
|
let parsed;
|
|
105
106
|
try {
|
|
106
107
|
parsed = JSON.parse(body);
|
|
@@ -116,17 +117,19 @@ function dispatch(body) {
|
|
|
116
117
|
}
|
|
117
118
|
const req = parsed;
|
|
118
119
|
if (req.method.startsWith('notifications/')) {
|
|
119
|
-
const stopWatching = watchDeadline(req, false);
|
|
120
|
+
const stopWatching = watchDeadline(req, false, abort);
|
|
120
121
|
track(req.method, handleMcpRequest(req).then(() => { }).catch((err) => {
|
|
121
122
|
log.error(`mcp notification ${req.method} failed: ${errorMessage(err)}`, errorFields(err));
|
|
122
123
|
}).finally(stopWatching));
|
|
123
124
|
return;
|
|
124
125
|
}
|
|
125
126
|
// A frame with no id is a notification under any method name, and JSON-RPC forbids answering one.
|
|
126
|
-
const timedOut = watchDeadline(req, isJsonString(req.id) || isJsonNumber(req.id));
|
|
127
|
+
const timedOut = watchDeadline(req, isJsonString(req.id) || isJsonNumber(req.id), abort);
|
|
127
128
|
track(req.method, handleMcpRequest(req).then((resp) => { if (!timedOut() && resp)
|
|
128
129
|
send(resp); }).catch((err) => {
|
|
129
|
-
if (timedOut())
|
|
130
|
+
if (timedOut() && err instanceof DOMException && err.name === 'TimeoutError')
|
|
131
|
+
log.debug(`mcp: ${callName(req)} stopped at its deadline`);
|
|
132
|
+
else if (timedOut())
|
|
130
133
|
log.error(`mcp: ${callName(req)} failed after its timeout reply: ${errorMessage(err)}`, errorFields(err));
|
|
131
134
|
else
|
|
132
135
|
send(mcpErrorResponse(req.id, err, undefined, callName(req)));
|
|
@@ -158,8 +161,10 @@ export function startStdioLoop() {
|
|
|
158
161
|
continue;
|
|
159
162
|
}
|
|
160
163
|
// One id per frame, so the error reply and every line logged for the call name the same request.
|
|
161
|
-
if (result.kind === 'message')
|
|
162
|
-
|
|
164
|
+
if (result.kind === 'message') {
|
|
165
|
+
const abort = new AbortController();
|
|
166
|
+
runWithRequestId(randomUUID(), () => dispatch(result.body, abort), undefined, abort.signal);
|
|
167
|
+
}
|
|
163
168
|
}
|
|
164
169
|
pending = buffer.length > 0 ? [buffer] : [];
|
|
165
170
|
pendingBytes = buffer.length;
|
|
@@ -48,10 +48,12 @@ function identityOrder(head) {
|
|
|
48
48
|
}));
|
|
49
49
|
}
|
|
50
50
|
async function scoreHead(pipe, query, head) {
|
|
51
|
+
let succeeded = 0;
|
|
51
52
|
const scored = await Promise.all(head.map(async (r, i) => {
|
|
52
53
|
let ceScore;
|
|
53
54
|
try {
|
|
54
55
|
ceScore = await pipe(query, r.entry.content);
|
|
56
|
+
succeeded++;
|
|
55
57
|
}
|
|
56
58
|
catch (err) {
|
|
57
59
|
// One bad inference must not sink the whole pass.
|
|
@@ -69,7 +71,7 @@ async function scoreHead(pipe, query, head) {
|
|
|
69
71
|
// relevance order, never an arbitrary content order.
|
|
70
72
|
scored.sort((a, b) => compareScoresDesc(a.rerankScore, b.rerankScore));
|
|
71
73
|
scored.forEach((r, i) => (r.postRerankRank = i + 1));
|
|
72
|
-
return scored;
|
|
74
|
+
return { results: scored, succeeded };
|
|
73
75
|
}
|
|
74
76
|
/** Track 2 reranker: MS-MARCO MiniLM cross-encoder, identity fallback if the model will not load. Each instance owns its model and outage warning. */
|
|
75
77
|
export function createCrossEncoderReranker(importModule = importTransformers) {
|
|
@@ -88,8 +90,13 @@ export function createCrossEncoderReranker(importModule = importTransformers) {
|
|
|
88
90
|
outage.failed(errorMessage(err), errorFields(err));
|
|
89
91
|
return identityOrder(head);
|
|
90
92
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
const { results: scored, succeeded } = await scoreHead(pipe, query, head);
|
|
94
|
+
// A loaded model whose every call throws is as dead as one that never loaded.
|
|
95
|
+
if (head.length > 0 && succeeded === 0)
|
|
96
|
+
outage.failed('every inference failed');
|
|
97
|
+
else
|
|
98
|
+
outage.answered();
|
|
99
|
+
return scored;
|
|
93
100
|
};
|
|
94
101
|
}
|
|
95
102
|
//# sourceMappingURL=cross-encoder.js.map
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { MemoryEntry } from '../core/memory.js';
|
|
2
2
|
import { type EmbeddingProvider } from '../embeddings/provider.js';
|
|
3
3
|
import { type HippoStore } from '../store/port.js';
|
|
4
|
-
/**
|
|
5
|
-
export
|
|
4
|
+
/** `off` is a provider that is disabled or not installed, which is a choice; `failed` is a memory that stays out of the vector index. */
|
|
5
|
+
export type EmbedOutcome = 'indexed' | 'off' | 'failed';
|
|
6
|
+
/** Embed a single memory entry and cache the result in the embedding index; with `store`, through its vectorWrites, never opening hippo.db.
|
|
7
|
+
* Resolves whatever fails, so a caller that fires and forgets stays safe; the outcome says whether the memory reached the index. */
|
|
8
|
+
export declare function embedMemory(hippoRoot: string, entry: MemoryEntry, model?: string, store?: HippoStore): Promise<EmbedOutcome>;
|
|
6
9
|
/** Embeds every entry with no cached vector, prunes vectors of deleted memories, and returns how many it embedded; `provider` defaults to the
|
|
7
10
|
* configured one. With `store`, every tenant's memories with no vector for the provider's model go through its `vectorWrites`, never hippo.db. */
|
|
8
11
|
export declare function embedAll(hippoRoot: string, model?: string, provider?: EmbeddingProvider, store?: HippoStore): Promise<number>;
|
|
@@ -4,7 +4,7 @@ import * as fs from 'fs';
|
|
|
4
4
|
import * as path from 'path';
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
6
6
|
import { loadConfig } from '../core/config.js';
|
|
7
|
-
import { initializeParticle } from '../
|
|
7
|
+
import { initializeParticle } from '../core/physics.js';
|
|
8
8
|
import { resolveEmbeddingProvider } from '../embeddings/provider.js';
|
|
9
9
|
import { embeddingIndexIdentity, embeddingInputText, embeddingModelRequiresReindex, indexNeedsRebuild, saveStoredEmbeddingModel, } from '../store/embeddings/index.js';
|
|
10
10
|
import { loadAllEntryIds, loadEntriesByIds } from '../store/entry-reads.js';
|
|
@@ -191,7 +191,7 @@ async function writeVectorPage(writes, write, page, vectors, withParticles) {
|
|
|
191
191
|
if (!vector || vector.length === 0)
|
|
192
192
|
return noteSkippedEmbedding(entry.id);
|
|
193
193
|
const row = withParticles ? { memoryId: entry.id, vector, particle: initializeParticle(entry, vector) } : { memoryId: entry.id, vector };
|
|
194
|
-
byTenant.set(entry.
|
|
194
|
+
byTenant.set(entry.tenant_id, [...(byTenant.get(entry.tenant_id) ?? []), row]);
|
|
195
195
|
});
|
|
196
196
|
let written = 0;
|
|
197
197
|
for (const [tenantId, rows] of byTenant) {
|
|
@@ -237,15 +237,20 @@ function embedMemoryInStore(store, provider, entry) {
|
|
|
237
237
|
|| (await writeVectorPage(writes, { model, replaceIndex: false }, [entry], await provider.embed([embeddingInputText(entry)], 'passage'), true)).modelMismatch;
|
|
238
238
|
if (refused)
|
|
239
239
|
warnEmbedFailureOnce('index', OTHER_MODEL_INDEX);
|
|
240
|
+
return refused ? 'failed' : 'indexed';
|
|
240
241
|
}
|
|
241
242
|
catch (err) {
|
|
242
243
|
warnEmbedFailureOnce(provider.kind, errorMessage(err), err);
|
|
244
|
+
return 'failed';
|
|
243
245
|
}
|
|
244
246
|
}).catch((err) => {
|
|
245
247
|
log.warn(`skipped embedding ${entry.id} (${errorMessage(err)})`);
|
|
248
|
+
return 'failed';
|
|
246
249
|
});
|
|
247
250
|
}
|
|
248
|
-
|
|
251
|
+
const INLINE_REINDEX_MAX_ROWS = 256;
|
|
252
|
+
/** Embed a single memory entry and cache the result in the embedding index; with `store`, through its vectorWrites, never opening hippo.db.
|
|
253
|
+
* Resolves whatever fails, so a caller that fires and forgets stays safe; the outcome says whether the memory reached the index. */
|
|
249
254
|
export async function embedMemory(hippoRoot, entry, model, store) {
|
|
250
255
|
let provider;
|
|
251
256
|
try {
|
|
@@ -254,38 +259,45 @@ export async function embedMemory(hippoRoot, entry, model, store) {
|
|
|
254
259
|
catch (err) {
|
|
255
260
|
// Callers fire and forget, so this must resolve: a bad config warns once instead of rejecting.
|
|
256
261
|
warnEmbedFailureOnce('config', errorMessage(err), err);
|
|
257
|
-
return;
|
|
262
|
+
return 'failed';
|
|
258
263
|
}
|
|
259
264
|
if (!provider.isAvailable())
|
|
260
|
-
return;
|
|
265
|
+
return 'off';
|
|
261
266
|
if (store)
|
|
262
267
|
return embedMemoryInStore(store, provider, entry);
|
|
263
|
-
return withEmbedLock(hippoRoot,
|
|
264
|
-
// Best-effort: an embedding failure (API down, bad key, 5xx) must not reject the caller's write; the existing index is left as-is.
|
|
265
|
-
// Failures surface on the explicit `hippo embed` / `embedAll` path.
|
|
266
|
-
try {
|
|
267
|
-
const identity = provider.id;
|
|
268
|
-
if (embeddingModelRequiresReindex(hippoRoot, identity)) {
|
|
269
|
-
await rebuildIndexForProvider(hippoRoot, provider);
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
const text = embeddingInputText(entry);
|
|
273
|
-
const [vector] = await provider.embed([text], 'passage');
|
|
274
|
-
if (!vector || vector.length === 0)
|
|
275
|
-
return;
|
|
276
|
-
saveStoredVectors(hippoRoot, [[entry.id, vector]], embeddingIndexIdentity(identity));
|
|
277
|
-
saveStoredEmbeddingModel(hippoRoot, identity);
|
|
278
|
-
// Initialize physics state for this memory
|
|
279
|
-
initializePhysicsIfMissing(hippoRoot, entry, vector);
|
|
280
|
-
}
|
|
281
|
-
catch (err) {
|
|
282
|
-
// Provider failure (API down / bad key). Best-effort: leave the index as-is, but say so once.
|
|
283
|
-
warnEmbedFailureOnce(provider.kind, errorMessage(err), err);
|
|
284
|
-
}
|
|
285
|
-
}).catch((err) => {
|
|
268
|
+
return withEmbedLock(hippoRoot, () => embedMemoryLocked(hippoRoot, provider, entry)).catch((err) => {
|
|
286
269
|
log.warn(`skipped embedding ${entry.id} (${errorMessage(err)}); run 'hippo embed' to backfill`);
|
|
270
|
+
return 'failed';
|
|
287
271
|
});
|
|
288
272
|
}
|
|
273
|
+
/** Best-effort: an embedding failure (API down, bad key, 5xx) must not reject the caller's write; the existing index is left as-is.
|
|
274
|
+
* Failures surface on the explicit `hippo embed` / `embedAll` path. */
|
|
275
|
+
async function embedMemoryLocked(hippoRoot, provider, entry) {
|
|
276
|
+
try {
|
|
277
|
+
const identity = provider.id;
|
|
278
|
+
if (embeddingModelRequiresReindex(hippoRoot, identity)) {
|
|
279
|
+
// A fire-and-forget write heals a small index inline; a large one is left to `hippo embed`, so no write holds the lock for a whole-store embed.
|
|
280
|
+
if (loadAllEntryIds(hippoRoot).length > INLINE_REINDEX_MAX_ROWS) {
|
|
281
|
+
warnEmbedFailureOnce('index', OTHER_MODEL_INDEX);
|
|
282
|
+
return 'failed';
|
|
283
|
+
}
|
|
284
|
+
await rebuildIndexForProvider(hippoRoot, provider);
|
|
285
|
+
return 'indexed';
|
|
286
|
+
}
|
|
287
|
+
const [vector] = await provider.embed([embeddingInputText(entry)], 'passage');
|
|
288
|
+
if (!vector || vector.length === 0)
|
|
289
|
+
return 'failed';
|
|
290
|
+
saveStoredVectors(hippoRoot, [[entry.id, vector]], embeddingIndexIdentity(identity));
|
|
291
|
+
saveStoredEmbeddingModel(hippoRoot, identity);
|
|
292
|
+
initializePhysicsIfMissing(hippoRoot, entry, vector);
|
|
293
|
+
return 'indexed';
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
// Provider failure (API down / bad key). Best-effort: leave the index as-is, but say so once.
|
|
297
|
+
warnEmbedFailureOnce(provider.kind, errorMessage(err), err);
|
|
298
|
+
return 'failed';
|
|
299
|
+
}
|
|
300
|
+
}
|
|
289
301
|
// Every page is embedded before one transaction replaces the index, and the identity is saved only after that: a provider failure or a crash
|
|
290
302
|
// at any earlier point leaves the old identity, so embeddingModelRequiresReindex has the next run rebuild from the start.
|
|
291
303
|
async function rebuildIndexForProvider(hippoRoot, provider) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MemoryEntry } from '../core/memory.js';
|
|
2
|
+
import type { PhysicsConfig } from '../core/physics-config.js';
|
|
3
|
+
import type { SearchResult } from '../core/search-types.js';
|
|
4
|
+
import { type HybridSearchOptions } from './hybrid.js';
|
|
5
|
+
import type { VectorSource } from './vector.js';
|
|
6
|
+
/** Physics scoring when `physics` holds its config, hybrid otherwise, both on the store `source` names; each caller keeps its own options. */
|
|
7
|
+
export declare function searchByEngine(source: VectorSource, query: string, pool: MemoryEntry[], physics: PhysicsConfig | null, options: Omit<HybridSearchOptions, 'hippoRoot' | 'store'>): Promise<SearchResult[]>;
|
|
8
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The one search-engine choice every ranker over a candidate pool makes: recall's band, MCP's wide list, the CLI core and context.
|
|
2
|
+
import { hybridSearch } from './hybrid.js';
|
|
3
|
+
import { physicsSearchIn } from './physics-search.js';
|
|
4
|
+
/** Physics scoring when `physics` holds its config, hybrid otherwise, both on the store `source` names; each caller keeps its own options. */
|
|
5
|
+
export function searchByEngine(source, query, pool, physics, options) {
|
|
6
|
+
return physics
|
|
7
|
+
? physicsSearchIn(source, query, pool, { ...options, physicsConfig: physics })
|
|
8
|
+
: hybridSearch(query, pool, { ...options, hippoRoot: source.root, store: source.store });
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=engine.js.map
|
package/dist/search/vector.js
CHANGED
|
@@ -5,7 +5,7 @@ import { resolveEmbeddingProvider } from '../embeddings/provider.js';
|
|
|
5
5
|
import { rethrowIfSqliteBlocked } from '../util/sqlite-blocked.js';
|
|
6
6
|
import { errorMessage, log } from '../util/log.js';
|
|
7
7
|
import { envQueryEmbedTimeoutMs } from '../util/env.js';
|
|
8
|
-
import { requireGroup } from '../store/index.js';
|
|
8
|
+
import { hasGroup, requireGroup } from '../store/index.js';
|
|
9
9
|
import { redactSecretsStrict } from '../util/secret-detect.js';
|
|
10
10
|
import { currentEntries } from './as-of.js';
|
|
11
11
|
// Jev's default budget (rerankers/jev.ts): each is one off-box call that a recall waits on.
|
|
@@ -33,12 +33,12 @@ export async function embedQueryBy(deadline, provider, query) {
|
|
|
33
33
|
function warnBm25Fallback(reason, detail) {
|
|
34
34
|
log.once(`search.bm25-fallback.${reason}`, 'warn', `hybrid search fell back to BM25 only: ${redactSecretsStrict(detail)}`);
|
|
35
35
|
}
|
|
36
|
-
function reindexHint(
|
|
36
|
+
function reindexHint(store) {
|
|
37
37
|
const why = 'the embedding index was built by another model or is being rebuilt';
|
|
38
38
|
// `hippo embed` writes only hippo.db, so another store gets its vectors by a rebuild from it.
|
|
39
|
-
return
|
|
39
|
+
return hasGroup(store, 'localFiles')
|
|
40
40
|
? `${why}; run 'hippo embed'`
|
|
41
|
-
: `${why}; run 'hippo embed' on the SQLite store, then rebuild the '${
|
|
41
|
+
: `${why}; run 'hippo embed' on the SQLite store, then rebuild the '${store.kind}' database from it`;
|
|
42
42
|
}
|
|
43
43
|
/** The store's vector reads; a store without them answers 501, as any unported path does. */
|
|
44
44
|
export function requireVectorReads(store) {
|
|
@@ -76,7 +76,7 @@ async function fillVectorArm(arm, query, { root, store }, options) {
|
|
|
76
76
|
const reads = requireVectorReads(store);
|
|
77
77
|
const index = await reads.embeddingIndexState();
|
|
78
78
|
if (indexNeedsRebuild(indexedModel(index), provider.id)) {
|
|
79
|
-
warnBm25Fallback('reindex', reindexHint(store
|
|
79
|
+
warnBm25Fallback('reindex', reindexHint(store));
|
|
80
80
|
return;
|
|
81
81
|
}
|
|
82
82
|
const spec = options.vectorCandidates;
|
package/dist/server/auth.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ type AuthHeader = {
|
|
|
16
16
|
token: string;
|
|
17
17
|
};
|
|
18
18
|
export declare function readAuthHeader(req: IncomingMessage): AuthHeader;
|
|
19
|
-
type AuthOpts = Pick<ResolvedServeOpts, 'hippoRoot' | 'authResolver' | 'authResolverTimeoutMs' | 'store' | 'callerLimiter'> & Partial<Pick<ResolvedServeOpts, 'failedAuthLimiter'>>;
|
|
19
|
+
type AuthOpts = Pick<ResolvedServeOpts, 'hippoRoot' | 'authResolver' | 'authResolverTimeoutMs' | 'store' | 'callerLimiter'> & Partial<Pick<ResolvedServeOpts, 'failedAuthLimiter' | 'keylessLocalRole'>>;
|
|
20
20
|
/** Add-ons call this to refuse a subject that would collide with a built-in actor. */
|
|
21
21
|
export declare function isReservedActor(subject: string): boolean;
|
|
22
22
|
/** Key cap for every serve() bucket, shared by the warn map so it never tracks more callers than the buckets do. */
|
package/dist/server/auth.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Bearer and loopback auth for the HTTP server.
|
|
2
|
-
import { envAllowKeylessLocal, envRequireAuth } from '../util/env.js';
|
|
2
|
+
import { envAllowKeylessLocal, envKeylessLocalRole, envRequireAuth } from '../util/env.js';
|
|
3
3
|
import { resolveTenantId } from '../store/tenant.js';
|
|
4
4
|
import { errorFields, errorMessage, log } from '../util/log.js';
|
|
5
5
|
import { API_KEY_PREFIX, verifyApiKeyCached } from '../store/auth.js';
|
|
@@ -253,16 +253,15 @@ export async function buildContextWithAuth(req, opts) {
|
|
|
253
253
|
export async function recheckContextWithAuth(req, opts) {
|
|
254
254
|
return contextFor(await checkAuth(req, opts), opts);
|
|
255
255
|
}
|
|
256
|
+
/** The keyless local caller: any process on this machine can reach loopback, so it is a member unless the operator names admin. */
|
|
257
|
+
function keylessLocalActor(opts) {
|
|
258
|
+
const role = opts.keylessLocalRole ?? envKeylessLocalRole();
|
|
259
|
+
return role === 'admin' ? { subject: 'localhost:cli', role: 'admin', hostAdmin: true } : { subject: 'localhost:cli', role: 'member' };
|
|
260
|
+
}
|
|
256
261
|
function contextFor(id, opts) {
|
|
257
262
|
if (id !== null)
|
|
258
263
|
return { hippoRoot: opts.hippoRoot, tenantId: id.tenantId, actor: bearerActor(id), store: opts.store };
|
|
259
|
-
|
|
260
|
-
return {
|
|
261
|
-
hippoRoot: opts.hippoRoot,
|
|
262
|
-
tenantId: resolveTenantId({}),
|
|
263
|
-
actor: { subject: 'localhost:cli', role: 'admin', hostAdmin: true },
|
|
264
|
-
store: opts.store,
|
|
265
|
-
};
|
|
264
|
+
return { hippoRoot: opts.hippoRoot, tenantId: resolveTenantId({}), actor: keylessLocalActor(opts), store: opts.store };
|
|
266
265
|
}
|
|
267
266
|
/** Auth check for routes that need no tenant Context (e.g. the MCP transport, which resolves its own root): throws HttpError 401 like buildContextWithAuth.
|
|
268
267
|
* A keyless local request passes only under HIPPO_ALLOW_KEYLESS_LOCAL=1. */
|
package/dist/server/boot.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ServeOpts, ServerHandle } from './types.js';
|
|
2
|
-
/** Boots the HTTP daemon on host:port and writes the pidfile under hippoRoot; refuses non-loopback
|
|
2
|
+
/** Boots the HTTP daemon on host:port and writes the pidfile under hippoRoot; refuses a non-loopback host unless HIPPO_REQUIRE_AUTH=1 and
|
|
3
|
+
* either `tls` or allowCleartext is given, and refuses HIPPO_CLIENT_IP_HEADER without HIPPO_TRUSTED_PROXIES.
|
|
3
4
|
* Use port: 0 in tests for an ephemeral port. See docs/ARCHITECTURE.md#srcserverbootts. */
|
|
4
5
|
export declare function serve(opts: ServeOpts): Promise<ServerHandle>;
|
|
5
6
|
//# sourceMappingURL=boot.d.ts.map
|
package/dist/server/boot.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { envPort, envRequireAuth, envV1Rps } from '../util/env.js';
|
|
1
|
+
import { envAllowCleartext, envCallerRps, envPort, envRequireAuth, envV1Rps } from '../util/env.js';
|
|
2
2
|
import { detectServer, removePidfileIfOwned, writePidfile } from './server-detect.js';
|
|
3
3
|
import { outsideSqliteOffLoop, runWithRequestStores, SERVER_DB_WAIT_MS, withSqliteBlocked } from '../db/index.js';
|
|
4
|
+
import { hasGroup } from '../store/index.js';
|
|
4
5
|
import { isStoreBusy } from '../store/sqlite/busy.js';
|
|
5
6
|
import { holdSqliteConnection } from '../store/sqlite/held-connection.js';
|
|
6
7
|
import { workerSqliteStore } from '../store/sqlite/worker-store.js';
|
|
@@ -14,7 +15,7 @@ import { RecallContractError } from '../api/index.js';
|
|
|
14
15
|
import { BodyTimeoutError, BodyTooLargeError, closeAfterReply, DeadlineExceededError, HttpError, JSON_HEADERS, sendJson } from '../util/http-util.js';
|
|
15
16
|
import { workerCounts } from '../store/sqlite/executor-counts.js';
|
|
16
17
|
import { isLocalCaller, LIMITER_MAX_KEYS } from './auth.js';
|
|
17
|
-
import {
|
|
18
|
+
import { assertClientIpHeaderPinned, enforceRateLimit } from './client-ip.js';
|
|
18
19
|
import { answerAtDeadline, handlerDeadlineCount, isAbandoned, requestDeadlineFor } from './deadline.js';
|
|
19
20
|
import { DEFAULT_SHUTDOWN_DRAIN_MS, drainAndClose, setKeepAliveTimeouts, shutdownBoundMs } from './lifecycle.js';
|
|
20
21
|
import { readyProbeFor } from './ready.js';
|
|
@@ -23,7 +24,7 @@ import { installCrashHandlers, installSignalHandlers } from '../util/crash-handl
|
|
|
23
24
|
import { handleMcpPost, handleMcpStream } from './mcp-http.js';
|
|
24
25
|
import { MCP_PROJECT_SCOPED_HEADER } from '../core/project-identity.js';
|
|
25
26
|
import { accessRouteOf, logRequestFailure, noteAccess, openRequest, parseRequest, rejectEncodedSlash, replyFor, sendError } from './request.js';
|
|
26
|
-
import {
|
|
27
|
+
import { assertTlsOrCleartextAllowed, createListener } from './tls.js';
|
|
27
28
|
import { assertAddonRoutes, assertPublicJson } from './route-checks.js';
|
|
28
29
|
import { dispatchAddonRoute, dispatchPublicJson, dispatchV1Route } from './route-table.js';
|
|
29
30
|
// server.address() returns AddressInfo once a TCP socket is bound; null before
|
|
@@ -156,27 +157,37 @@ function assertRateLimitSpec(name, { ratePerSec, burst }) {
|
|
|
156
157
|
if (!Number.isFinite(burst) || burst < 1)
|
|
157
158
|
throw new Error(`rateLimits.${name}.burst must be a finite number of at least 1`);
|
|
158
159
|
}
|
|
160
|
+
const DEFAULT_CALLER_RPS = 20;
|
|
161
|
+
/** The per-caller spec when serve() names none: HIPPO_CALLER_RPS (0 is off), else 20 per second with a burst of twice that. */
|
|
162
|
+
function defaultCallerSpec() {
|
|
163
|
+
const raw = envCallerRps();
|
|
164
|
+
const rps = raw === undefined ? DEFAULT_CALLER_RPS : Number(raw);
|
|
165
|
+
// A typo must not switch the limit off, so only an explicit 0 does.
|
|
166
|
+
if (!Number.isFinite(rps) || rps < 0)
|
|
167
|
+
throw new Error(`HIPPO_CALLER_RPS must be a number of at least 0 (0 turns the per-caller limit off), not '${raw}'`);
|
|
168
|
+
return rps === 0 ? 'off' : { ratePerSec: rps, burst: Math.max(1, rps * 2) };
|
|
169
|
+
}
|
|
159
170
|
/** Refuses a bad spec at boot, since a NaN or zero rate would otherwise turn a bucket off or refuse every request. */
|
|
160
171
|
function bootLimiters(rateLimits) {
|
|
161
|
-
const { perCaller, perAddress, failedAuthPerAddress = DEFAULT_FAILED_AUTH } = rateLimits ?? {};
|
|
162
|
-
if (perCaller !==
|
|
172
|
+
const { perCaller = defaultCallerSpec(), perAddress, failedAuthPerAddress = DEFAULT_FAILED_AUTH } = rateLimits ?? {};
|
|
173
|
+
if (perCaller !== 'off')
|
|
163
174
|
assertRateLimitSpec('perCaller', perCaller);
|
|
164
175
|
if (perAddress !== undefined && perAddress !== 'off')
|
|
165
176
|
assertRateLimitSpec('perAddress', perAddress);
|
|
166
177
|
assertRateLimitSpec('failedAuthPerAddress', failedAuthPerAddress);
|
|
167
|
-
|
|
178
|
+
assertClientIpHeaderPinned();
|
|
168
179
|
return {
|
|
169
180
|
perAddress: bootRateLimiter(perAddress),
|
|
170
|
-
callerLimiter: perCaller ===
|
|
181
|
+
callerLimiter: perCaller === 'off' ? undefined : limiterFor(perCaller),
|
|
171
182
|
failedAuthLimiter: limiterFor(failedAuthPerAddress),
|
|
172
183
|
};
|
|
173
184
|
}
|
|
174
185
|
function createStoreHolder(hippoRoot, store) {
|
|
175
|
-
if (store
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return
|
|
186
|
+
if (hasGroup(store, 'localFiles'))
|
|
187
|
+
return holdSqliteConnection(hippoRoot);
|
|
188
|
+
log.info(`serve: the '${store.kind}' store serves ${hippoRoot}, so no hippo.db connection is held`);
|
|
189
|
+
// A missed port under another store would otherwise create and write a hippo.db that store never reads.
|
|
190
|
+
return { hold: () => { }, afterResponse: () => { }, release: async () => { }, runRequest: (run) => withSqliteBlocked(store.kind, run) };
|
|
180
191
|
}
|
|
181
192
|
// A plugin's resolver may read hippo.db through this package's synchronous functions, so it runs outside the block of a `loop: 'off'` route.
|
|
182
193
|
function outsideRouteBlock(resolver) {
|
|
@@ -287,7 +298,8 @@ function exitOnSignalOrCrash(stop, drainMs) {
|
|
|
287
298
|
installSignalHandlers('serve', shutdown);
|
|
288
299
|
installCrashHandlers('serve', shutdown);
|
|
289
300
|
}
|
|
290
|
-
/** Boots the HTTP daemon on host:port and writes the pidfile under hippoRoot; refuses non-loopback
|
|
301
|
+
/** Boots the HTTP daemon on host:port and writes the pidfile under hippoRoot; refuses a non-loopback host unless HIPPO_REQUIRE_AUTH=1 and
|
|
302
|
+
* either `tls` or allowCleartext is given, and refuses HIPPO_CLIENT_IP_HEADER without HIPPO_TRUSTED_PROXIES.
|
|
291
303
|
* Use port: 0 in tests for an ephemeral port. See docs/ARCHITECTURE.md#srcserverbootts. */
|
|
292
304
|
export async function serve(opts) {
|
|
293
305
|
const host = opts.host ?? DEFAULT_SERVER_HOST;
|
|
@@ -296,6 +308,7 @@ export async function serve(opts) {
|
|
|
296
308
|
const publicJsonBodies = assertPublicJson(opts.publicJson ?? {});
|
|
297
309
|
const limiters = bootLimiters(opts.rateLimits);
|
|
298
310
|
assertBindable(host);
|
|
311
|
+
assertTlsOrCleartextAllowed(host, opts.tls, LOOPBACK_HOSTS, opts.allowCleartext ?? envAllowCleartext());
|
|
299
312
|
await assertNoLiveServer(opts.hippoRoot);
|
|
300
313
|
// Single source of truth for the start time: returned by GET /health and written into the pidfile, so detectServer can prove a pid-reusing impostor is not
|
|
301
314
|
// this server.
|
|
@@ -304,7 +317,7 @@ export async function serve(opts) {
|
|
|
304
317
|
const streamSlots = new Map();
|
|
305
318
|
const served = servedOptsFor(opts, routes, publicJsonBodies, limiters);
|
|
306
319
|
// A store other than hippo.db is a team's central server, so its folder's config.json must not decide shared-ness.
|
|
307
|
-
if (served.store
|
|
320
|
+
if (!hasGroup(served.store, 'localFiles'))
|
|
308
321
|
markSharedStore(opts.hippoRoot);
|
|
309
322
|
const holder = createStoreHolder(opts.hippoRoot, served.store);
|
|
310
323
|
const inflight = new Set();
|
|
@@ -343,14 +356,11 @@ function servedOptsFor(opts, routes, publicJsonBodies, { callerLimiter, failedAu
|
|
|
343
356
|
};
|
|
344
357
|
}
|
|
345
358
|
function acceptingServer(served, holder, inflight, options) {
|
|
346
|
-
const { kind } = served.store;
|
|
347
359
|
return createListener(served.tls, (req, res) => {
|
|
348
360
|
res.once('finish', holder.afterResponse);
|
|
349
361
|
inflight.add(res);
|
|
350
362
|
res.once('close', () => inflight.delete(res));
|
|
351
|
-
|
|
352
|
-
// A missed port under another store would otherwise create and write a hippo.db that store never reads.
|
|
353
|
-
answerRequest(req, res, () => (kind === 'sqlite' ? run() : withSqliteBlocked(kind, run)), served.slowRequestWarnMs);
|
|
363
|
+
answerRequest(req, res, () => holder.runRequest(() => handleRequest(req, res, served, options)), served.slowRequestWarnMs);
|
|
354
364
|
});
|
|
355
365
|
}
|
|
356
366
|
async function listenAndDescribe(server, port, host, tls) {
|
|
@@ -362,7 +372,6 @@ async function listenAndDescribe(server, port, host, tls) {
|
|
|
362
372
|
const addressInfo = address;
|
|
363
373
|
const actualPort = addressInfo.port;
|
|
364
374
|
const url = `${tls ? 'https' : 'http'}://${host.includes(':') ? `[${host}]` : host}:${actualPort}`;
|
|
365
|
-
warnIfCleartext(host, tls, LOOPBACK_HOSTS);
|
|
366
375
|
return { port: actualPort, url };
|
|
367
376
|
}
|
|
368
377
|
async function stopListening(opts, server, inflight, startedAt) {
|
|
@@ -2,11 +2,16 @@ import type { IncomingMessage } from 'node:http';
|
|
|
2
2
|
import type { RateLimiter } from './rate-limit.js';
|
|
3
3
|
/**
|
|
4
4
|
* Rate-limit key: the socket's remote address, which behind a TLS proxy collapses into one bucket unauthenticated traffic can drain.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* HIPPO_CLIENT_IP_HEADER (fly-client-ip on Fly) is read only from a peer in HIPPO_TRUSTED_PROXIES (IPs or CIDRs, or `*` for any peer),
|
|
6
|
+
* so a caller that reaches the port past the proxy cannot pick its own bucket. In a comma-joined chain the key is the rightmost hop no trusted proxy added. */
|
|
7
7
|
export declare function clientIpForRateLimit(req: IncomingMessage): string;
|
|
8
|
-
/**
|
|
9
|
-
export declare
|
|
8
|
+
/** serve() refused HIPPO_CLIENT_IP_HEADER with no HIPPO_TRUSTED_PROXIES: the header would be read from no one, so the setting is a mistake. */
|
|
9
|
+
export declare class UntrustedClientIpHeaderError extends Error {
|
|
10
|
+
readonly header: string;
|
|
11
|
+
constructor(header: string);
|
|
12
|
+
}
|
|
13
|
+
/** Checked at boot: a client-IP header needs a trusted-proxy list, and `*` is said once, since it believes any peer. */
|
|
14
|
+
export declare function assertClientIpHeaderPinned(): void;
|
|
10
15
|
/** The one key every per-address limit and slot count uses, so rotating inside a /64 never buys a fresh bucket. */
|
|
11
16
|
export declare function clientLimitKey(req: IncomingMessage): string;
|
|
12
17
|
export declare function enforceRateLimit(req: IncomingMessage, path: string, limiter?: RateLimiter): void;
|