hippo-memory 1.61.0 → 1.63.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 +37 -53
- package/dist/agent-memories/apply.d.ts +3 -1
- package/dist/agent-memories/apply.js +23 -1
- package/dist/agent-memories/claude-code.d.ts +4 -1
- package/dist/agent-memories/claude-code.js +53 -9
- package/dist/agent-memories/report.d.ts +1 -0
- package/dist/agent-memories/report.js +2 -2
- package/dist/agent-memories/sync.d.ts +3 -3
- package/dist/agent-memories/sync.js +46 -24
- package/dist/agent-memories/types.d.ts +0 -2
- package/dist/ambient-store.d.ts +4 -4
- package/dist/ambient-store.js +4 -3
- package/dist/api/assemble.d.ts +7 -10
- package/dist/api/assemble.js +62 -66
- package/dist/api/audit.d.ts +2 -2
- package/dist/api/audit.js +2 -2
- package/dist/api/auth.d.ts +6 -6
- package/dist/api/auth.js +5 -7
- package/dist/api/context-select.d.ts +50 -0
- package/dist/api/context-select.js +342 -0
- package/dist/api/context-types.d.ts +12 -17
- package/dist/api/context.d.ts +5 -4
- package/dist/api/context.js +208 -535
- package/dist/api/dormant.js +1 -1
- package/dist/api/drill-down.d.ts +4 -4
- package/dist/api/drill-down.js +56 -41
- package/dist/api/outcome.d.ts +8 -13
- package/dist/api/outcome.js +13 -17
- package/dist/api/promote.d.ts +5 -8
- package/dist/api/promote.js +55 -66
- package/dist/api/quarantine.js +3 -2
- package/dist/api/recall-types.d.ts +43 -55
- package/dist/api/recall.d.ts +3 -3
- package/dist/api/recall.js +310 -456
- package/dist/api/remember.d.ts +2 -2
- package/dist/api/sleep.d.ts +7 -35
- package/dist/api/sleep.js +206 -220
- package/dist/api/tokens.d.ts +2 -2
- package/dist/api/tokens.js +2 -2
- package/dist/api/types.d.ts +6 -10
- package/dist/api/types.js +2 -4
- package/dist/audit-prune.d.ts +4 -6
- package/dist/audit-prune.js +3 -5
- package/dist/audit.js +11 -33
- package/dist/auth.d.ts +8 -9
- package/dist/auth.js +5 -7
- package/dist/autolearn.d.ts +1 -1
- package/dist/autolearn.js +1 -1
- package/dist/availability.js +3 -5
- package/dist/capture/command.d.ts +5 -4
- package/dist/capture/command.js +8 -22
- package/dist/capture/compact.d.ts +2 -2
- package/dist/capture/compact.js +11 -15
- package/dist/capture/extract.js +37 -115
- package/dist/capture-error.d.ts +1 -1
- package/dist/capture-error.js +1 -1
- package/dist/churn-git.d.ts +1 -1
- package/dist/churn-git.js +1 -1
- package/dist/cli/audit.js +3 -4
- package/dist/cli/auth.js +3 -6
- package/dist/cli/briefs.js +324 -306
- package/dist/cli/context.js +44 -34
- package/dist/cli/continuity.js +283 -271
- package/dist/cli/curate.d.ts +1 -1
- package/dist/cli/curate.js +43 -58
- package/dist/cli/dag.js +5 -9
- package/dist/cli/decisions.js +334 -345
- package/dist/cli/explain.js +68 -61
- package/dist/cli/goals.js +1 -1
- package/dist/cli/init.js +1 -1
- package/dist/cli/maintenance.js +62 -51
- package/dist/cli/playbooks.js +391 -379
- package/dist/cli/projects.js +11 -6
- package/dist/cli/recall.js +30 -44
- package/dist/cli/remember.js +118 -87
- package/dist/cli/session-hooks.js +106 -115
- package/dist/cli/setup.d.ts +1 -1
- package/dist/cli/setup.js +267 -250
- package/dist/cli/shared.js +3 -3
- package/dist/cli/slack.js +1 -1
- package/dist/cli/sleep.js +27 -1
- package/dist/cli/status.d.ts +4 -4
- package/dist/cli/status.js +80 -76
- package/dist/cli/transfer.js +88 -107
- package/dist/cli/usage.js +9 -6
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +4 -9
- package/dist/compaction-record.d.ts +2 -2
- package/dist/compaction-record.js +89 -68
- package/dist/compare.d.ts +11 -16
- package/dist/compare.js +11 -16
- package/dist/config.d.ts +19 -18
- package/dist/config.js +90 -67
- package/dist/connectors/github/backfill.d.ts +2 -2
- package/dist/connectors/github/backfill.js +8 -15
- package/dist/connectors/github/cli-impl.js +3 -8
- package/dist/connectors/github/deletion.d.ts +5 -12
- package/dist/connectors/github/deletion.js +5 -12
- package/dist/connectors/github/dlq.d.ts +6 -9
- package/dist/connectors/github/dlq.js +2 -3
- package/dist/connectors/github/ingest.d.ts +5 -7
- package/dist/connectors/github/ingest.js +8 -12
- package/dist/connectors/github/octokit-client.d.ts +3 -5
- package/dist/connectors/github/octokit-client.js +5 -6
- package/dist/connectors/github/signature.d.ts +9 -39
- package/dist/connectors/github/signature.js +9 -39
- package/dist/connectors/github/tenant-routing.d.ts +1 -1
- package/dist/connectors/github/tenant-routing.js +1 -1
- package/dist/connectors/github/transform.js +2 -2
- package/dist/connectors/github/types.d.ts +2 -10
- package/dist/connectors/github/types.js +1 -3
- package/dist/connectors/slack/deletion.d.ts +3 -8
- package/dist/connectors/slack/deletion.js +3 -8
- package/dist/connectors/slack/dlq.d.ts +1 -1
- package/dist/connectors/slack/ingest.d.ts +1 -1
- package/dist/connectors/slack/ingest.js +7 -16
- package/dist/connectors/slack/signature.d.ts +1 -1
- package/dist/connectors/slack/tenant-routing.d.ts +3 -5
- package/dist/connectors/slack/tenant-routing.js +3 -5
- package/dist/connectors/slack/transform.d.ts +5 -6
- package/dist/connectors/slack/transform.js +5 -6
- package/dist/connectors/slack/types.d.ts +2 -6
- package/dist/connectors/slack/types.js +1 -3
- package/dist/connectors/slack/web-client.js +10 -3
- package/dist/connectors/slack/workspaces.d.ts +3 -5
- package/dist/connectors/slack/workspaces.js +3 -5
- package/dist/consolidate/conflicts.js +3 -14
- package/dist/consolidate/decay.js +9 -29
- package/dist/consolidate/llm-passes.js +4 -5
- package/dist/consolidate/merge.js +8 -23
- package/dist/consolidate/run.d.ts +1 -8
- package/dist/consolidate/run.js +3 -25
- package/dist/consolidate/sleep.js +5 -17
- package/dist/consolidate/traces.js +9 -21
- package/dist/customer-notes.d.ts +5 -7
- package/dist/customer-notes.js +82 -76
- package/dist/dag.d.ts +10 -21
- package/dist/dag.js +189 -203
- package/dist/db/continuity.js +2 -2
- package/dist/db/migrations/v14.js +1 -1
- package/dist/db/migrations/v15.js +1 -2
- package/dist/db/migrations/v16.js +3 -4
- package/dist/db/migrations/v17.js +2 -3
- package/dist/db/migrations/v19.js +1 -1
- package/dist/db/migrations/v20.js +1 -1
- package/dist/db/migrations/v21.js +2 -6
- package/dist/db/migrations/v22.js +2 -4
- package/dist/db/migrations/v23.js +1 -1
- package/dist/db/migrations/v24.js +4 -6
- package/dist/db/migrations/v25.js +2 -3
- package/dist/db/migrations/v26.js +3 -3
- package/dist/db/migrations/v27.js +2 -10
- package/dist/db/migrations/v28.js +5 -8
- package/dist/db/migrations/v29.js +3 -4
- package/dist/db/migrations/v30.js +2 -2
- package/dist/db/migrations/v31.js +1 -1
- package/dist/db/migrations/v32.js +1 -1
- package/dist/db/migrations/v33.js +3 -3
- package/dist/db/migrations/v34.js +1 -1
- package/dist/db/migrations/v35.js +3 -4
- package/dist/db/migrations/v36.js +3 -4
- package/dist/db/migrations/v37.js +5 -5
- package/dist/db/migrations/v38.js +7 -8
- package/dist/db/migrations/v39.js +1 -1
- package/dist/db/migrations/v40.js +4 -16
- package/dist/db/migrations/v41.js +3 -4
- package/dist/db/migrations/v42.js +3 -4
- package/dist/db/migrations/v45.js +1 -1
- package/dist/db/migrations/v46.js +1 -1
- package/dist/db/migrations/v47.js +1 -1
- package/dist/db/migrations/v48.js +1 -1
- package/dist/decisions.d.ts +2 -2
- package/dist/decisions.js +97 -80
- package/dist/dedupe.js +86 -61
- package/dist/delivery-recorder.js +154 -135
- package/dist/doctor.js +129 -110
- package/dist/dormant.js +1 -4
- package/dist/embedding-provider.d.ts +4 -8
- package/dist/embedding-provider.js +4 -8
- package/dist/embeddings.js +55 -47
- package/dist/env.d.ts +1 -1
- package/dist/env.js +12 -12
- package/dist/escape.d.ts +5 -0
- package/dist/escape.js +10 -0
- package/dist/eval-stats.d.ts +1 -2
- package/dist/eval-stats.js +1 -2
- package/dist/eval-suite.js +27 -21
- package/dist/extract.js +4 -9
- package/dist/failure-log.d.ts +3 -3
- package/dist/failure-log.js +1 -1
- package/dist/forward-claim-detector.d.ts +2 -4
- package/dist/forward-claim-detector.js +6 -11
- package/dist/goals.d.ts +3 -3
- package/dist/goals.js +103 -91
- package/dist/graph/read.d.ts +2 -2
- package/dist/graph/read.js +5 -6
- package/dist/graph/types.d.ts +8 -8
- package/dist/graph/write.d.ts +7 -14
- package/dist/graph/write.js +16 -23
- package/dist/graph-extract.d.ts +7 -8
- package/dist/graph-extract.js +62 -72
- package/dist/graph-recall.d.ts +2 -2
- package/dist/graph-recall.js +55 -49
- package/dist/graph-stream.d.ts +5 -6
- package/dist/graph-stream.js +66 -57
- package/dist/graph-view.d.ts +2 -2
- package/dist/graph-view.js +7 -7
- package/dist/half-life-migration.d.ts +1 -2
- package/dist/half-life-migration.js +2 -3
- package/dist/hooks/codex-session.js +1 -1
- package/dist/hooks/codex-wrapper.d.ts +1 -1
- package/dist/hooks/codex-wrapper.js +3 -2
- package/dist/hooks/json-hooks.d.ts +2 -2
- package/dist/hooks/json-hooks.js +5 -4
- package/dist/hooks/opencode.d.ts +1 -1
- package/dist/hooks/opencode.js +5 -4
- package/dist/hooks/shared.d.ts +3 -7
- package/dist/hooks/shared.js +1 -8
- package/dist/http-util.d.ts +2 -3
- package/dist/http-util.js +3 -0
- package/dist/importers/core.d.ts +2 -9
- package/dist/importers/core.js +15 -30
- package/dist/importers/sources.js +2 -1
- package/dist/importers/vault.js +2 -20
- package/dist/incidents.d.ts +1 -1
- package/dist/incidents.js +46 -39
- package/dist/instruction-detect.d.ts +1 -1
- package/dist/instruction-detect.js +1 -1
- package/dist/invalidation.d.ts +3 -0
- package/dist/invalidation.js +160 -114
- package/dist/json.d.ts +5 -0
- package/dist/json.js +4 -0
- package/dist/judgment.js +1 -2
- package/dist/local-embedding.js +1 -1
- package/dist/mcp/admin-tools.js +7 -17
- package/dist/mcp/format.js +1 -1
- package/dist/mcp/framing.js +3 -6
- package/dist/mcp/protocol.d.ts +2 -5
- package/dist/mcp/protocol.js +1 -3
- package/dist/mcp/recall-tools.js +12 -15
- package/dist/mcp/request.js +4 -3
- package/dist/mcp/session-state.js +2 -3
- package/dist/mcp/stdio.js +2 -1
- package/dist/mcp/tools.js +9 -6
- package/dist/memory-value-weights.d.ts +5 -8
- package/dist/memory-value-weights.js +5 -8
- package/dist/memory-value.d.ts +13 -13
- package/dist/memory-value.js +26 -37
- package/dist/memory.d.ts +20 -22
- package/dist/memory.js +24 -48
- package/dist/multihop.d.ts +1 -1
- package/dist/multihop.js +3 -2
- package/dist/owner-validation.d.ts +4 -5
- package/dist/owner-validation.js +4 -5
- package/dist/physics.d.ts +4 -4
- package/dist/physics.js +7 -9
- package/dist/policies.d.ts +9 -10
- package/dist/policies.js +96 -81
- package/dist/postinstall.js +3 -6
- package/dist/predictions/planning-fallacy.d.ts +9 -14
- package/dist/predictions/planning-fallacy.js +10 -16
- package/dist/predictions/store.d.ts +15 -23
- package/dist/predictions/store.js +36 -33
- package/dist/processes.d.ts +2 -7
- package/dist/processes.js +88 -72
- package/dist/project-briefs.d.ts +2 -3
- package/dist/project-briefs.js +141 -118
- package/dist/project-identity.d.ts +22 -9
- package/dist/project-identity.js +47 -12
- package/dist/project-merge.d.ts +28 -5
- package/dist/project-merge.js +213 -46
- package/dist/project-remote.d.ts +12 -0
- package/dist/project-remote.js +138 -0
- package/dist/prompt-recall.js +1 -2
- package/dist/rate-limit.d.ts +1 -1
- package/dist/rate-limit.js +1 -1
- package/dist/raw-archive.d.ts +9 -0
- package/dist/raw-archive.js +70 -53
- package/dist/recall-history.d.ts +19 -20
- package/dist/recall-history.js +24 -42
- package/dist/recall-pipeline.js +4 -28
- package/dist/recall-scope.d.ts +7 -8
- package/dist/recall-scope.js +7 -8
- package/dist/recall-trace.d.ts +5 -9
- package/dist/recall-trace.js +6 -10
- package/dist/refine-llm.d.ts +1 -1
- package/dist/refine-llm.js +2 -2
- package/dist/reject-flow.d.ts +3 -4
- package/dist/reject-flow.js +122 -117
- package/dist/rejection.d.ts +5 -6
- package/dist/rejection.js +7 -15
- package/dist/rerankers/clef.d.ts +1 -1
- package/dist/rerankers/jev.d.ts +1 -2
- package/dist/rerankers/jev.js +4 -5
- package/dist/rerankers/llm.d.ts +1 -2
- package/dist/rerankers/llm.js +1 -2
- package/dist/rerankers/types.d.ts +1 -2
- package/dist/rrf.d.ts +2 -2
- package/dist/rrf.js +2 -2
- package/dist/search/bm25-search.d.ts +1 -1
- package/dist/search/bm25-search.js +2 -1
- package/dist/search/boosts.js +2 -1
- package/dist/search/hybrid.d.ts +1 -1
- package/dist/search/hybrid.js +2 -1
- package/dist/search/physics-search.d.ts +1 -1
- package/dist/search/physics-search.js +2 -1
- package/dist/search/types.d.ts +2 -0
- package/dist/search/types.js +3 -1
- package/dist/secret-detect.d.ts +4 -5
- package/dist/secret-detect.js +6 -10
- package/dist/server/auth.js +5 -5
- package/dist/server/client-ip.js +1 -1
- package/dist/server/cursor.js +2 -1
- package/dist/server/mcp-http.js +4 -4
- package/dist/server/request.d.ts +3 -6
- package/dist/server/request.js +6 -7
- package/dist/server/routes/admin.js +5 -4
- package/dist/server/routes/customer-notes.js +6 -5
- package/dist/server/routes/decisions.js +4 -3
- package/dist/server/routes/incidents.js +7 -5
- package/dist/server/routes/memories.js +7 -7
- package/dist/server/routes/policies.js +3 -2
- package/dist/server/routes/predictions.js +12 -15
- package/dist/server/routes/processes.js +3 -2
- package/dist/server/routes/project-briefs.js +8 -7
- package/dist/server/routes/recall.js +95 -93
- package/dist/server/routes/skills.js +6 -5
- package/dist/server/types.d.ts +1 -1
- package/dist/server/validation.d.ts +1 -2
- package/dist/server/validation.js +7 -14
- package/dist/server-detect.js +72 -58
- package/dist/server.d.ts +2 -2
- package/dist/server.js +131 -117
- package/dist/shared.d.ts +26 -17
- package/dist/shared.js +102 -104
- package/dist/skills.d.ts +3 -3
- package/dist/skills.js +88 -72
- package/dist/store/audit-event.d.ts +2 -2
- package/dist/store/audit-event.js +1 -1
- package/dist/store/candidates.d.ts +2 -2
- package/dist/store/candidates.js +4 -3
- package/dist/store/conflicts.js +30 -22
- package/dist/store/delete-and-batch.d.ts +11 -14
- package/dist/store/delete-and-batch.js +40 -91
- package/dist/store/entry-reads.d.ts +17 -25
- package/dist/store/entry-reads.js +59 -40
- package/dist/store/entry-row.d.ts +6 -24
- package/dist/store/entry-row.js +6 -24
- package/dist/store/entry-writes.d.ts +6 -7
- package/dist/store/entry-writes.js +13 -11
- package/dist/store/handoffs.d.ts +1 -1
- package/dist/store/handoffs.js +7 -10
- package/dist/store/index-and-stats.d.ts +2 -6
- package/dist/store/index-and-stats.js +4 -10
- package/dist/store/mirrors.d.ts +6 -19
- package/dist/store/mirrors.js +14 -39
- package/dist/store/open.js +9 -31
- package/dist/store/rows.d.ts +5 -11
- package/dist/store/rows.js +6 -11
- package/dist/store/search-rows.d.ts +17 -34
- package/dist/store/search-rows.js +34 -56
- package/dist/store/sessions.d.ts +4 -5
- package/dist/store/sessions.js +5 -6
- package/dist/store/summaries.d.ts +13 -17
- package/dist/store/summaries.js +26 -70
- package/dist/support-bundle.js +4 -8
- package/dist/tenant.d.ts +1 -5
- package/dist/token-ledger.d.ts +1 -1
- package/dist/token-ledger.js +3 -5
- package/dist/trace.js +1 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/working-memory.d.ts +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 +1 -1
|
@@ -7,11 +7,11 @@ import { updateStats } from '../../store/index-and-stats.js';
|
|
|
7
7
|
import { appendRecall, biasHintEnabled, buildSessionKey, getOrCreateRing, hashQueryText, snapshotRing } from '../../recall-history.js';
|
|
8
8
|
import { appendAuditEvent, auditQueryFields } from '../../audit.js';
|
|
9
9
|
import { assemble, drillDown, getContext, recordTokens, retrieve } from '../../api.js';
|
|
10
|
-
import { HttpError, sendJson } from '../../http-util.js';
|
|
10
|
+
import { HttpError, MAX_ID_LEN, sendJson } from '../../http-util.js';
|
|
11
11
|
import { buildContextWithAuth } from '../auth.js';
|
|
12
12
|
import { parseListLimit, validateIdSegment } from '../validation.js';
|
|
13
|
-
//
|
|
14
|
-
// for the HTTP pipeline. Separate from CLI/MCP rings
|
|
13
|
+
// Module-level per-(tenant, session) recall-history ring map
|
|
14
|
+
// for the HTTP pipeline. Separate from CLI/MCP rings (per-
|
|
15
15
|
// pipeline rings; no IPC). HTTP is the only caller that threads its
|
|
16
16
|
// snapshot through opts.recallHistory to api.recall — api.recall's
|
|
17
17
|
// anchoringHint on the returned RecallResult IS the user-visible hint
|
|
@@ -21,8 +21,40 @@ const sessionRecallHistoryHttp = new Map();
|
|
|
21
21
|
export function __resetSessionRecallHistoryHttp() {
|
|
22
22
|
sessionRecallHistoryHttp.clear();
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
function parseFreshTail(query) {
|
|
25
|
+
// Surface the fresh-tail RecallOpts to HTTP callers so session-scoped
|
|
26
|
+
// fresh-tail and summary substitution are not JS-only.
|
|
27
|
+
const freshTailCountRaw = query.get('fresh_tail_count');
|
|
28
|
+
const freshTailCount = freshTailCountRaw === null ? undefined : Number(freshTailCountRaw);
|
|
29
|
+
if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
|
|
30
|
+
throw new HttpError(400, 'fresh_tail_count must be a non-negative number');
|
|
31
|
+
}
|
|
32
|
+
// Cap session_id length consistent with the
|
|
33
|
+
// rest of the API. Untrimmed strings round-trip through the SQL layer
|
|
34
|
+
// and through any downstream metric/log; 256 is generous for a session
|
|
35
|
+
// id and matches the rest of this file's id-shaped param parsers.
|
|
36
|
+
const freshTailSessionIdRaw = query.get('fresh_tail_session_id');
|
|
37
|
+
if (freshTailSessionIdRaw !== null && freshTailSessionIdRaw.length > MAX_ID_LEN) {
|
|
38
|
+
throw new HttpError(400, `fresh_tail_session_id exceeds ${MAX_ID_LEN}-character cap`);
|
|
39
|
+
}
|
|
40
|
+
const freshTailSessionId = freshTailSessionIdRaw && freshTailSessionIdRaw.length > 0
|
|
41
|
+
? freshTailSessionIdRaw
|
|
42
|
+
: undefined;
|
|
43
|
+
return { freshTailCount, freshTailSessionId };
|
|
44
|
+
}
|
|
45
|
+
function parseSessionId(query) {
|
|
46
|
+
// session_id for the dlPFC goal-stack boost. 256-char cap mirrors
|
|
47
|
+
// fresh_tail_session_id (above). Trim then drop if empty so api.recall
|
|
48
|
+
// sees undefined when the param is omitted or whitespace-only.
|
|
49
|
+
const sessionIdRaw = query.get('session_id');
|
|
50
|
+
if (sessionIdRaw !== null && sessionIdRaw.length > MAX_ID_LEN) {
|
|
51
|
+
throw new HttpError(400, `session_id exceeds ${MAX_ID_LEN}-character cap`);
|
|
52
|
+
}
|
|
53
|
+
return sessionIdRaw && sessionIdRaw.trim().length > 0
|
|
54
|
+
? sessionIdRaw.trim()
|
|
55
|
+
: undefined;
|
|
56
|
+
}
|
|
57
|
+
function parseRecallQuery(query) {
|
|
26
58
|
const q = query.get('q');
|
|
27
59
|
if (!q) {
|
|
28
60
|
throw new HttpError(400, 'q is required');
|
|
@@ -37,29 +69,9 @@ export async function handleRecallMemories({ req, res, opts, query }) {
|
|
|
37
69
|
const includeContinuityRaw = query.get('include_continuity');
|
|
38
70
|
const includeContinuity = includeContinuityRaw === '1'
|
|
39
71
|
|| includeContinuityRaw === 'true';
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
const freshTailCountRaw = query.get('fresh_tail_count');
|
|
44
|
-
const freshTailCount = freshTailCountRaw === null ? undefined : Number(freshTailCountRaw);
|
|
45
|
-
if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
|
|
46
|
-
throw new HttpError(400, 'fresh_tail_count must be a non-negative number');
|
|
47
|
-
}
|
|
48
|
-
// v1.6.3 senior-review P1-3: cap session_id length consistent with the
|
|
49
|
-
// rest of the API. Untrimmed strings round-trip through the SQL layer
|
|
50
|
-
// and through any downstream metric/log; 256 is generous for a session
|
|
51
|
-
// id and matches the rest of this file's id-shaped param parsers.
|
|
52
|
-
const freshTailSessionIdRaw = query.get('fresh_tail_session_id');
|
|
53
|
-
if (freshTailSessionIdRaw !== null && freshTailSessionIdRaw.length > 256) {
|
|
54
|
-
throw new HttpError(400, 'fresh_tail_session_id exceeds 256-character cap');
|
|
55
|
-
}
|
|
56
|
-
const freshTailSessionId = freshTailSessionIdRaw && freshTailSessionIdRaw.length > 0
|
|
57
|
-
? freshTailSessionIdRaw
|
|
58
|
-
: undefined;
|
|
59
|
-
// v1.6.3 senior-review P1-4: tighten parser to match the includeContinuity
|
|
60
|
-
// convention. Pre-v1.6.3 accepted any non-'0'/'false' value as `true`,
|
|
61
|
-
// so `?summarize_overflow=banana` and `?summarize_overflow=` both
|
|
62
|
-
// turned it on. Surface convention drift fixed.
|
|
72
|
+
const { freshTailCount, freshTailSessionId } = parseFreshTail(query);
|
|
73
|
+
// Strict parse matching the includeContinuity convention, so values
|
|
74
|
+
// like `?summarize_overflow=banana` or an empty value do not turn it on.
|
|
63
75
|
const summarizeOverflowRaw = query.get('summarize_overflow');
|
|
64
76
|
const summarizeOverflow = summarizeOverflowRaw === null
|
|
65
77
|
? undefined
|
|
@@ -70,40 +82,29 @@ export async function handleRecallMemories({ req, res, opts, query }) {
|
|
|
70
82
|
if (scorerWindow !== undefined && scorerWindow > 1000) {
|
|
71
83
|
throw new HttpError(400, 'scorer_window must be <= 1000');
|
|
72
84
|
}
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
// sees undefined when the param is omitted or whitespace-only.
|
|
76
|
-
const sessionIdRaw = query.get('session_id');
|
|
77
|
-
if (sessionIdRaw !== null && sessionIdRaw.length > 256) {
|
|
78
|
-
throw new HttpError(400, 'session_id exceeds 256-character cap');
|
|
79
|
-
}
|
|
80
|
-
const sessionId = sessionIdRaw && sessionIdRaw.trim().length > 0
|
|
81
|
-
? sessionIdRaw.trim()
|
|
82
|
-
: undefined;
|
|
83
|
-
// A7 recall-trace: opt-in explain flag. When set, api.recall attaches the
|
|
85
|
+
const sessionId = parseSessionId(query);
|
|
86
|
+
// Recall-trace: opt-in explain flag. When set, api.recall attaches the
|
|
84
87
|
// lifecycle re-ranking trace (goal-boost step on the api pipeline) +
|
|
85
88
|
// rerankPipeline:'api' to each result item; the field then rides on the
|
|
86
89
|
// serialized RecallResult. Mirrors the include_continuity convention.
|
|
87
90
|
const explainRaw = query.get('explain');
|
|
88
91
|
const explain = explainRaw === '1' || explainRaw === 'true';
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
92
|
+
return { q, limit, mode, scope, includeContinuity, freshTailCount, freshTailSessionId, summarizeOverflow, scorerWindow, sessionId, explain };
|
|
93
|
+
}
|
|
94
|
+
// HTTP per-pipeline anchoring detector. HTTP threads its
|
|
95
|
+
// ring snapshot via opts.recallHistory so api.recall's own
|
|
96
|
+
// anchoringHint compute path activates. Unlike CLI (which computes
|
|
97
|
+
// its own hint separately because cmdRecall runs its own physics/
|
|
98
|
+
// hybrid pipeline outside api.recall), HTTP's /v1/memories response
|
|
99
|
+
// body IS api.recall's result directly. So the api.recall-computed
|
|
100
|
+
// hint flows through. HIPPO_ANCHORING=off short-circuits.
|
|
101
|
+
function snapshotSessionRing(ctx, hippoRoot, q, sessionId) {
|
|
97
102
|
let httpRecallHistory;
|
|
98
103
|
let httpRingKey;
|
|
99
104
|
if (biasHintEnabled('anchoring')) {
|
|
100
105
|
if (sessionId) {
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
// scorer_window / fresh_tail_count would create-or-touch the
|
|
104
|
-
// session ring (LRU-evicting valid sessions) even though recall
|
|
105
|
-
// throws 400. Snapshot the EXISTING ring if present; only
|
|
106
|
-
// create-or-touch after the recall returns successfully.
|
|
106
|
+
// Do NOT mutate sessionRecallHistoryHttp before recall() preflight: a request
|
|
107
|
+
// that 400s would otherwise create-or-touch a ring and LRU-evict valid sessions.
|
|
107
108
|
httpRingKey = buildSessionKey(ctx.tenantId, sessionId);
|
|
108
109
|
const existingRing = sessionRecallHistoryHttp.get(httpRingKey);
|
|
109
110
|
httpRecallHistory = existingRing ? snapshotRing(existingRing) : [];
|
|
@@ -113,12 +114,9 @@ export async function handleRecallMemories({ req, res, opts, query }) {
|
|
|
113
114
|
// Per the normal recall-audit convention (api.ts:854 stores
|
|
114
115
|
// SHA-256/16 hash of the query, NOT raw text), avoid retaining
|
|
115
116
|
// prompts in audit_log here too — query content can contain
|
|
116
|
-
// secrets, PII, or RTBF-restricted material.
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
// entropy queries. Use the same SHA-256/16 truncation as the
|
|
120
|
-
// canonical recall audit.
|
|
121
|
-
const dbForAudit = openHippoDb(opts.hippoRoot);
|
|
117
|
+
// secrets, PII, or RTBF-restricted material. hashQueryText is a 32-bit
|
|
118
|
+
// FNV-1a, NOT a privacy hash, so use the recall audit's SHA-256/16 truncation.
|
|
119
|
+
const dbForAudit = openHippoDb(hippoRoot);
|
|
122
120
|
try {
|
|
123
121
|
appendAuditEvent(dbForAudit, {
|
|
124
122
|
tenantId: ctx.tenantId,
|
|
@@ -133,36 +131,42 @@ export async function handleRecallMemories({ req, res, opts, query }) {
|
|
|
133
131
|
}
|
|
134
132
|
}
|
|
135
133
|
}
|
|
134
|
+
return { httpRecallHistory, httpRingKey };
|
|
135
|
+
}
|
|
136
|
+
function recallExtraOpts(parsed, httpRecallHistory) {
|
|
136
137
|
const recallExtra = {};
|
|
137
|
-
if (freshTailCount !== undefined)
|
|
138
|
-
recallExtra.freshTailCount = freshTailCount;
|
|
139
|
-
if (freshTailSessionId !== undefined)
|
|
140
|
-
recallExtra.freshTailSessionId = freshTailSessionId;
|
|
141
|
-
if (summarizeOverflow !== undefined)
|
|
142
|
-
recallExtra.summarizeOverflow = summarizeOverflow;
|
|
143
|
-
if (scorerWindow !== undefined)
|
|
144
|
-
recallExtra.scorerWindow = scorerWindow;
|
|
145
|
-
if (sessionId !== undefined)
|
|
146
|
-
recallExtra.sessionId = sessionId;
|
|
138
|
+
if (parsed.freshTailCount !== undefined)
|
|
139
|
+
recallExtra.freshTailCount = parsed.freshTailCount;
|
|
140
|
+
if (parsed.freshTailSessionId !== undefined)
|
|
141
|
+
recallExtra.freshTailSessionId = parsed.freshTailSessionId;
|
|
142
|
+
if (parsed.summarizeOverflow !== undefined)
|
|
143
|
+
recallExtra.summarizeOverflow = parsed.summarizeOverflow;
|
|
144
|
+
if (parsed.scorerWindow !== undefined)
|
|
145
|
+
recallExtra.scorerWindow = parsed.scorerWindow;
|
|
146
|
+
if (parsed.sessionId !== undefined)
|
|
147
|
+
recallExtra.sessionId = parsed.sessionId;
|
|
147
148
|
if (httpRecallHistory !== undefined)
|
|
148
149
|
recallExtra.recallHistory = httpRecallHistory;
|
|
149
|
-
if (explain)
|
|
150
|
-
recallExtra.explain = explain;
|
|
150
|
+
if (parsed.explain)
|
|
151
|
+
recallExtra.explain = parsed.explain;
|
|
152
|
+
return recallExtra;
|
|
153
|
+
}
|
|
154
|
+
// GET /v1/memories?q=...&limit=...&mode=...&scope=...&include_continuity=1
|
|
155
|
+
export async function handleRecallMemories({ req, res, opts, query }) {
|
|
156
|
+
const parsed = parseRecallQuery(query);
|
|
157
|
+
const { q, includeContinuity, sessionId } = parsed;
|
|
158
|
+
const ctx = await buildContextWithAuth(req, opts);
|
|
159
|
+
const { httpRecallHistory, httpRingKey } = snapshotSessionRing(ctx, opts.hippoRoot, q, sessionId);
|
|
151
160
|
const result = await retrieve(ctx, {
|
|
152
161
|
query: q,
|
|
153
|
-
limit,
|
|
154
|
-
mode: mode ?? undefined,
|
|
155
|
-
scope: scope ?? undefined,
|
|
162
|
+
limit: parsed.limit,
|
|
163
|
+
mode: parsed.mode ?? undefined,
|
|
164
|
+
scope: parsed.scope ?? undefined,
|
|
156
165
|
includeContinuity,
|
|
157
|
-
...
|
|
166
|
+
...recallExtraOpts(parsed, httpRecallHistory),
|
|
158
167
|
});
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
// (api.recall computed it from the same snapshot we passed in), feeding
|
|
162
|
-
// the cooldown logic for the NEXT recall on this session.
|
|
163
|
-
// Codex round-5 P2 fix: create-or-touch the ring ONLY HERE, after recall
|
|
164
|
-
// returns successfully. Invalid requests that throw 400 in recall()
|
|
165
|
-
// never reach this point, so they cannot LRU-evict valid sessions.
|
|
168
|
+
// Append only after recall succeeds, so a 400 cannot LRU-evict valid sessions;
|
|
169
|
+
// anchoredOn feeds the cooldown logic for the NEXT recall on this session.
|
|
166
170
|
if (httpRingKey) {
|
|
167
171
|
const httpRing = getOrCreateRing(sessionRecallHistoryHttp, httpRingKey);
|
|
168
172
|
const topId = result.results[0]?.id ?? null;
|
|
@@ -196,10 +200,8 @@ export async function handleAssembleSession({ req, res, opts, query }, assembleM
|
|
|
196
200
|
if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
|
|
197
201
|
throw new HttpError(400, 'freshTail must be a non-negative number');
|
|
198
202
|
}
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
// non-'0'/'false' as true; ?summarizeOlder=banana now correctly returns
|
|
202
|
-
// false (matches includeContinuity convention).
|
|
203
|
+
// Same strict-parse convention as summarize_overflow on /v1/memories:
|
|
204
|
+
// ?summarizeOlder=banana is false (matches includeContinuity convention).
|
|
203
205
|
const sumOlderRaw = query.get('summarizeOlder');
|
|
204
206
|
const summarizeOlder = sumOlderRaw === null
|
|
205
207
|
? undefined
|
|
@@ -239,12 +241,12 @@ export async function handleDrillRecall({ req, res, opts, query }, drillMatch) {
|
|
|
239
241
|
if (budget !== undefined && (!Number.isFinite(budget) || budget <= 0)) {
|
|
240
242
|
throw new HttpError(400, 'budget must be a positive number');
|
|
241
243
|
}
|
|
242
|
-
//
|
|
244
|
+
// depth query param walks N levels (default 1, hard cap 10).
|
|
243
245
|
const depthRaw = query.get('depth');
|
|
244
246
|
let depth;
|
|
245
247
|
if (depthRaw !== null) {
|
|
246
248
|
const parsed = Number(depthRaw);
|
|
247
|
-
//
|
|
249
|
+
// Reject out-of-range explicitly (no silent clamp).
|
|
248
250
|
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 10) {
|
|
249
251
|
throw new HttpError(400, 'depth must be a positive integer between 1 and 10');
|
|
250
252
|
}
|
|
@@ -260,7 +262,7 @@ export async function handleDrillRecall({ req, res, opts, query }, drillMatch) {
|
|
|
260
262
|
drillExtra.depth = depth;
|
|
261
263
|
const result = drillDown(ctx, drillMatch.id, { ...drillExtra, cost: drillCost });
|
|
262
264
|
if ('failure' in result) {
|
|
263
|
-
//
|
|
265
|
+
// Leaf id maps to 422 (caller-actionable). Other cases stay
|
|
264
266
|
// as 404 to avoid leaking cross-tenant existence or scope grants.
|
|
265
267
|
if (result.failure === 'not_drillable') {
|
|
266
268
|
throw new HttpError(422, 'Id is a leaf row, not a level-2+ summary; nothing to drill into');
|
|
@@ -277,7 +279,7 @@ export async function handleDrillRecall({ req, res, opts, query }, drillMatch) {
|
|
|
277
279
|
// (matches cmdContext); real-query hybrid search emits one 'recall' row.
|
|
278
280
|
export async function handleGetContext({ req, res, opts, query }) {
|
|
279
281
|
const q = query.get('q') ?? undefined;
|
|
280
|
-
//
|
|
282
|
+
// DoS cap on q-param length. 1024 covers real multi-clause queries
|
|
281
283
|
// (pasted error messages, multi-stem searches) while bounding BM25
|
|
282
284
|
// tokenisation cost (~150 tokens worst case at 1024 chars).
|
|
283
285
|
if (q !== undefined && q.length > 1024) {
|
|
@@ -302,8 +304,8 @@ export async function handleGetContext({ req, res, opts, query }) {
|
|
|
302
304
|
const pinnedOnlyRaw = query.get('pinned_only');
|
|
303
305
|
const pinnedOnly = pinnedOnlyRaw === '1' || pinnedOnlyRaw === 'true';
|
|
304
306
|
const scopeRaw = query.get('scope');
|
|
305
|
-
if (scopeRaw !== null && scopeRaw.length >
|
|
306
|
-
throw new HttpError(400,
|
|
307
|
+
if (scopeRaw !== null && scopeRaw.length > MAX_ID_LEN) {
|
|
308
|
+
throw new HttpError(400, `scope exceeds ${MAX_ID_LEN}-character cap`);
|
|
307
309
|
}
|
|
308
310
|
const scope = scopeRaw === null ? undefined : scopeRaw;
|
|
309
311
|
const includeRecentRaw = query.get('include_recent');
|
|
@@ -330,7 +332,7 @@ export async function handleGetContext({ req, res, opts, query }) {
|
|
|
330
332
|
scope,
|
|
331
333
|
includeRecent,
|
|
332
334
|
crossProject,
|
|
333
|
-
currentProject: resolveProjectIdentity(dirname(resolve(opts.hippoRoot)))
|
|
335
|
+
currentProject: resolveProjectIdentity(dirname(resolve(opts.hippoRoot))),
|
|
334
336
|
cost: contextCost('markdown', 'observe'), // clients render; the budget prices the block `hippo context` would print
|
|
335
337
|
});
|
|
336
338
|
recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// /v1/skills routes.
|
|
2
|
-
import { closeSkill, exportSkills, loadSkillById, loadSkills, saveSkill, VALID_SKILL_STATES } from '../../skills.js';
|
|
2
|
+
import { closeSkill, exportSkills, loadSkillById, loadSkills, MAX_SKILL_NAME_LEN, saveSkill, VALID_SKILL_STATES } from '../../skills.js';
|
|
3
3
|
import { HttpError, sendJson } from '../../http-util.js';
|
|
4
4
|
import { buildContextWithAuth } from '../auth.js';
|
|
5
5
|
import { byCreatedAt, pageOf, parseCursor } from '../cursor.js';
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import { isSetMember, parseJsonBody, parseListLimit } from '../validation.js';
|
|
7
|
+
import { isJsonString } from '../../json.js';
|
|
8
|
+
// ── skills (first-class object, executable/exportable) ──
|
|
8
9
|
//
|
|
9
10
|
// 6 routes: POST /v1/skills (new; body skillName + instructions + trigger?),
|
|
10
11
|
// GET /v1/skills (list, status filter; shared parseListLimit), GET
|
|
@@ -23,8 +24,8 @@ export async function handleCreateSkill({ req, res, opts }) {
|
|
|
23
24
|
if (!isJsonString(skillName) || skillName.trim().length === 0) {
|
|
24
25
|
throw new HttpError(400, 'skillName is required (non-empty string)');
|
|
25
26
|
}
|
|
26
|
-
if (skillName.length >
|
|
27
|
-
throw new HttpError(400,
|
|
27
|
+
if (skillName.length > MAX_SKILL_NAME_LEN) {
|
|
28
|
+
throw new HttpError(400, `skillName exceeds ${MAX_SKILL_NAME_LEN}-character cap`);
|
|
28
29
|
}
|
|
29
30
|
const instructions = body['instructions'];
|
|
30
31
|
if (!isJsonString(instructions) || instructions.trim().length === 0) {
|
package/dist/server/types.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export interface ServerHandle {
|
|
|
3
3
|
port: number;
|
|
4
4
|
url: string;
|
|
5
5
|
stop: () => Promise<void>;
|
|
6
|
-
/** Introspection-only
|
|
6
|
+
/** Introspection-only: the underlying node:http Server, exposed so
|
|
7
7
|
* tests can assert keep-alive/headers timeout hardening without reaching
|
|
8
8
|
* into serve()'s closure. Additive field — do not depend on it for control
|
|
9
9
|
* flow outside tests. */
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage } from 'node:http';
|
|
2
2
|
import type { Context } from '../api.js';
|
|
3
|
-
import { type JsonValue } from '../
|
|
4
|
-
export declare function isJsonString(value: JsonValue | undefined): value is string;
|
|
3
|
+
import { type JsonValue } from '../json.js';
|
|
5
4
|
export declare function isJsonNumber(value: JsonValue | undefined): value is number;
|
|
6
5
|
export declare function isJsonBoolean(value: JsonValue | undefined): value is boolean;
|
|
7
6
|
export declare function isSetMember<T extends string>(set: ReadonlySet<T>, value: string): value is T;
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import { HttpError, isJsonObjectRecord, readBody } from '../http-util.js';
|
|
2
|
-
|
|
3
|
-
return typeof value === 'string';
|
|
4
|
-
}
|
|
1
|
+
import { HttpError, isJsonObjectRecord, MAX_ID_LEN, readBody } from '../http-util.js';
|
|
2
|
+
import { isJsonString } from '../json.js';
|
|
5
3
|
export function isJsonNumber(value) {
|
|
6
4
|
return typeof value === 'number';
|
|
7
5
|
}
|
|
@@ -21,13 +19,8 @@ export function isSetMember(set, value) {
|
|
|
21
19
|
// performs the actual narrowing for callers.
|
|
22
20
|
return set.has(value);
|
|
23
21
|
}
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// "1.5" that Number.isFinite would pass but SQLite `LIMIT ?` rejects with a
|
|
27
|
-
// datatype mismatch (a 500). Shared across the decision/incident/process/policy
|
|
28
|
-
// list routes so the guard cannot drift (codex review 2026-05-30 P2: fractional
|
|
29
|
-
// limit reached SQLite on the policy route; the same latent hole existed in the
|
|
30
|
-
// sibling routes this was copied from).
|
|
22
|
+
// Number.isInteger, not isFinite: SQLite `LIMIT ?` rejects "1.5" with a 500.
|
|
23
|
+
// Shared by every first-class-object list route so the guard cannot drift.
|
|
31
24
|
export function parseListLimit(limitRaw, defaultLimit = 100, maxLimit = 1000) {
|
|
32
25
|
if (limitRaw === null)
|
|
33
26
|
return defaultLimit;
|
|
@@ -68,7 +61,7 @@ export function getStringArray(obj, key) {
|
|
|
68
61
|
return v;
|
|
69
62
|
}
|
|
70
63
|
/**
|
|
71
|
-
*
|
|
64
|
+
* Charset + length validation for `:id` route captures. Routes call
|
|
72
65
|
* this immediately after `matchPath` to reject empty / overlong / illegal
|
|
73
66
|
* ids with a useful 400 instead of silently falling through to "not found".
|
|
74
67
|
*
|
|
@@ -82,8 +75,8 @@ const ID_SEGMENT_RE = /^[A-Za-z0-9_:.\-]+$/;
|
|
|
82
75
|
export function validateIdSegment(id, fieldName) {
|
|
83
76
|
if (id.length === 0)
|
|
84
77
|
throw new HttpError(400, `${fieldName} is required`);
|
|
85
|
-
if (id.length >
|
|
86
|
-
throw new HttpError(400, `${fieldName} exceeds
|
|
78
|
+
if (id.length > MAX_ID_LEN)
|
|
79
|
+
throw new HttpError(400, `${fieldName} exceeds ${MAX_ID_LEN}-character cap`);
|
|
87
80
|
if (!ID_SEGMENT_RE.test(id)) {
|
|
88
81
|
throw new HttpError(400, `${fieldName} contains invalid characters; allowed: A-Z a-z 0-9 _ : . -`);
|
|
89
82
|
}
|
package/dist/server-detect.js
CHANGED
|
@@ -23,33 +23,8 @@ const HEALTH_BODY_MAX_BYTES = 64 * 1024;
|
|
|
23
23
|
* with any other host is malformed or forged and must not be probed.
|
|
24
24
|
*/
|
|
25
25
|
const PIDFILE_LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
* server is genuinely answering on the recorded url. Returns null on missing,
|
|
29
|
-
* malformed, or stale pidfiles, and best-effort unlinks the file in the
|
|
30
|
-
* stale/malformed cases.
|
|
31
|
-
*
|
|
32
|
-
* Liveness is proven in two steps. `process.kill(pid, 0)` rules out dead
|
|
33
|
-
* pids. But a pid can be reused by an unrelated process, so a GET /health
|
|
34
|
-
* then confirms the process that answers is *this* hippo server: its
|
|
35
|
-
* `started_at` must equal the pidfile's. A mismatch, non-200, malformed
|
|
36
|
-
* body, or timeout all mean the pidfile is stale.
|
|
37
|
-
*
|
|
38
|
-
* The /health probe runs only when a pidfile exists and the pid is live, so
|
|
39
|
-
* the common no-server path stays a single fast file existence check.
|
|
40
|
-
*/
|
|
41
|
-
export async function detectServer(hippoRoot) {
|
|
42
|
-
const path = join(hippoRoot, PIDFILE);
|
|
43
|
-
if (!existsSync(path))
|
|
44
|
-
return null;
|
|
45
|
-
let info;
|
|
46
|
-
try {
|
|
47
|
-
info = JSON.parse(readFileSync(path, 'utf8'));
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
removePidfile(hippoRoot);
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
26
|
+
/** False when the pidfile's pid is dead or its url could not be one serve() wrote. */
|
|
27
|
+
function isLiveLoopbackTarget(info) {
|
|
53
28
|
// Probe the process. Sending signal 0 throws if the pid is dead or owned
|
|
54
29
|
// by another user we cannot signal. Either way, treat as stale.
|
|
55
30
|
// Node's process.kill(pid, 0) is implemented on Windows via OpenProcess +
|
|
@@ -58,8 +33,7 @@ export async function detectServer(hippoRoot) {
|
|
|
58
33
|
process.kill(info.pid, 0);
|
|
59
34
|
}
|
|
60
35
|
catch {
|
|
61
|
-
|
|
62
|
-
return null;
|
|
36
|
+
return false;
|
|
63
37
|
}
|
|
64
38
|
// The pid is live, but it may have been reused by an unrelated process, and
|
|
65
39
|
// the recorded url is read from a file anyone could forge. serve() only ever
|
|
@@ -71,15 +45,37 @@ export async function detectServer(hippoRoot) {
|
|
|
71
45
|
probeUrl = new URL(info.url);
|
|
72
46
|
}
|
|
73
47
|
catch {
|
|
74
|
-
|
|
75
|
-
return null;
|
|
48
|
+
return false;
|
|
76
49
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
probeUrl.port
|
|
80
|
-
|
|
81
|
-
|
|
50
|
+
return (probeUrl.protocol === 'http:' &&
|
|
51
|
+
PIDFILE_LOOPBACK_HOSTS.has(probeUrl.hostname.replace(/^\[(.*)\]$/, '$1')) &&
|
|
52
|
+
probeUrl.port === String(info.port));
|
|
53
|
+
}
|
|
54
|
+
/** The whole body as text, or null once it passes HEALTH_BODY_MAX_BYTES (the stream is then cancelled). */
|
|
55
|
+
async function readCappedBody(body) {
|
|
56
|
+
// Read the body under a hard byte cap. The process answering on info.url
|
|
57
|
+
// may not be hippo (pid reuse is the case this probe guards against), so
|
|
58
|
+
// its response is untrusted: never hand an unbounded stream to a parser.
|
|
59
|
+
const reader = body.getReader();
|
|
60
|
+
const decoder = new TextDecoder();
|
|
61
|
+
let raw = '';
|
|
62
|
+
let received = 0;
|
|
63
|
+
for (;;) {
|
|
64
|
+
const { done, value } = await reader.read();
|
|
65
|
+
if (done)
|
|
66
|
+
break;
|
|
67
|
+
received += value.byteLength;
|
|
68
|
+
if (received > HEALTH_BODY_MAX_BYTES) {
|
|
69
|
+
await reader.cancel();
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
raw += decoder.decode(value, { stream: true });
|
|
82
73
|
}
|
|
74
|
+
raw += decoder.decode();
|
|
75
|
+
return raw;
|
|
76
|
+
}
|
|
77
|
+
/** True when /health on info.url reports the pidfile's started_at; unlinks the pidfile on any definitive mismatch. */
|
|
78
|
+
async function healthMatchesPidfile(hippoRoot, info) {
|
|
83
79
|
// Confirm the process answering on info.url is this hippo server by matching
|
|
84
80
|
// the /health `started_at` against the pidfile. A connection refusal, a
|
|
85
81
|
// non-200, or a malformed body unlink the pidfile as stale. A probe timeout
|
|
@@ -92,33 +88,19 @@ export async function detectServer(hippoRoot) {
|
|
|
92
88
|
});
|
|
93
89
|
if (!res.ok || !res.body) {
|
|
94
90
|
removePidfile(hippoRoot);
|
|
95
|
-
return
|
|
91
|
+
return false;
|
|
96
92
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const decoder = new TextDecoder();
|
|
102
|
-
let raw = '';
|
|
103
|
-
let received = 0;
|
|
104
|
-
for (;;) {
|
|
105
|
-
const { done, value } = await reader.read();
|
|
106
|
-
if (done)
|
|
107
|
-
break;
|
|
108
|
-
received += value.byteLength;
|
|
109
|
-
if (received > HEALTH_BODY_MAX_BYTES) {
|
|
110
|
-
await reader.cancel();
|
|
111
|
-
removePidfile(hippoRoot);
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
raw += decoder.decode(value, { stream: true });
|
|
93
|
+
const raw = await readCappedBody(res.body);
|
|
94
|
+
if (raw === null) {
|
|
95
|
+
removePidfile(hippoRoot);
|
|
96
|
+
return false;
|
|
115
97
|
}
|
|
116
|
-
raw += decoder.decode();
|
|
117
98
|
const body = JSON.parse(raw);
|
|
118
99
|
if (body.started_at !== info.started_at) {
|
|
119
100
|
removePidfile(hippoRoot);
|
|
120
|
-
return
|
|
101
|
+
return false;
|
|
121
102
|
}
|
|
103
|
+
return true;
|
|
122
104
|
}
|
|
123
105
|
catch (err) {
|
|
124
106
|
// A timeout is ambiguous (the server may be alive but busy), so keep the
|
|
@@ -129,9 +111,41 @@ export async function detectServer(hippoRoot) {
|
|
|
129
111
|
if (err?.name !== 'TimeoutError') {
|
|
130
112
|
removePidfile(hippoRoot);
|
|
131
113
|
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Read .hippo/server.pid and return the embedded ServerInfo if a live hippo
|
|
119
|
+
* server is genuinely answering on the recorded url. Returns null on missing,
|
|
120
|
+
* malformed, or stale pidfiles, and best-effort unlinks the file in the
|
|
121
|
+
* stale/malformed cases.
|
|
122
|
+
*
|
|
123
|
+
* Liveness is proven in two steps. `process.kill(pid, 0)` rules out dead
|
|
124
|
+
* pids. But a pid can be reused by an unrelated process, so a GET /health
|
|
125
|
+
* then confirms the process that answers is *this* hippo server: its
|
|
126
|
+
* `started_at` must equal the pidfile's. A mismatch, non-200, malformed
|
|
127
|
+
* body, or timeout all mean the pidfile is stale.
|
|
128
|
+
*
|
|
129
|
+
* The /health probe runs only when a pidfile exists and the pid is live, so
|
|
130
|
+
* the common no-server path stays a single fast file existence check.
|
|
131
|
+
*/
|
|
132
|
+
export async function detectServer(hippoRoot) {
|
|
133
|
+
const path = join(hippoRoot, PIDFILE);
|
|
134
|
+
if (!existsSync(path))
|
|
135
|
+
return null;
|
|
136
|
+
let info;
|
|
137
|
+
try {
|
|
138
|
+
info = JSON.parse(readFileSync(path, 'utf8'));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
removePidfile(hippoRoot);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
if (!isLiveLoopbackTarget(info)) {
|
|
145
|
+
removePidfile(hippoRoot);
|
|
132
146
|
return null;
|
|
133
147
|
}
|
|
134
|
-
return info;
|
|
148
|
+
return (await healthMatchesPidfile(hippoRoot, info)) ? info : null;
|
|
135
149
|
}
|
|
136
150
|
/**
|
|
137
151
|
* Atomically write the pidfile. Writes to a process-scoped temp file then
|
package/dist/server.d.ts
CHANGED
|
@@ -10,8 +10,8 @@ export type { AuthResolver, ResolvedBearer, ServeOpts, ServerHandle } from './se
|
|
|
10
10
|
/**
|
|
11
11
|
* Boot the HTTP daemon on host:port and write the pidfile under hippoRoot.
|
|
12
12
|
*
|
|
13
|
-
* Refuses non-loopback hosts at boot
|
|
14
|
-
* HIPPO_REQUIRE_AUTH=1 is set. The
|
|
13
|
+
* Refuses non-loopback hosts at boot unless
|
|
14
|
+
* HIPPO_REQUIRE_AUTH=1 is set. The auth middleware (buildContextWithAuth /
|
|
15
15
|
* requireAuth) has shipped and every route checks it except GET /health
|
|
16
16
|
* (public by design for platform health checks) and the two connector
|
|
17
17
|
* webhooks in PUBLIC_ROUTES, which are HMAC-gated by their own signing
|