hippo-memory 1.56.0 → 1.58.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 +11 -0
- package/dist/agent-memories/claude-code.js +1 -1
- package/dist/agent-memories/gemini.js +1 -1
- package/dist/api-errors.d.ts +27 -0
- package/dist/api-errors.js +37 -0
- package/dist/api.d.ts +21 -14
- package/dist/api.js +97 -71
- package/dist/audit.d.ts +4 -0
- package/dist/audit.js +11 -0
- package/dist/autolearn.d.ts +1 -1
- package/dist/autolearn.js +7 -5
- package/dist/capture-contract.d.ts +47 -0
- package/dist/capture-contract.js +49 -0
- package/dist/capture-error.js +2 -1
- package/dist/capture.d.ts +0 -13
- package/dist/capture.js +5 -66
- package/dist/card-detail.d.ts +1 -1
- package/dist/card-detail.js +1 -1
- package/dist/cli/shared.d.ts +137 -0
- package/dist/cli/shared.js +834 -0
- package/dist/cli/sleep.d.ts +10 -0
- package/dist/cli/sleep.js +171 -0
- package/dist/cli.d.ts +0 -7
- package/dist/cli.js +322 -1827
- package/dist/client.js +9 -0
- package/dist/codex-patch.js +1 -1
- package/dist/compaction-record.d.ts +1 -1
- package/dist/compaction-record.js +3 -2
- package/dist/config.d.ts +5 -0
- package/dist/config.js +17 -0
- package/dist/connectors/github/dlq.js +5 -2
- package/dist/connectors/github/octokit-client.js +4 -2
- package/dist/connectors/github/webhook.d.ts +19 -0
- package/dist/connectors/github/webhook.js +313 -0
- package/dist/connectors/slack/dlq.js +6 -2
- package/dist/connectors/slack/web-client.js +7 -5
- package/dist/connectors/slack/webhook.d.ts +22 -0
- package/dist/connectors/slack/webhook.js +203 -0
- package/dist/consolidate.d.ts +10 -0
- package/dist/consolidate.js +38 -35
- package/dist/context-auto.d.ts +3 -0
- package/dist/context-auto.js +34 -0
- package/dist/customer-notes.js +16 -14
- package/dist/dag.js +3 -2
- package/dist/dashboard.js +3 -2
- package/dist/db.d.ts +12 -0
- package/dist/db.js +62 -1
- package/dist/decisions.js +11 -9
- package/dist/doctor.js +5 -0
- package/dist/embedding-provider.js +3 -3
- package/dist/embeddings.d.ts +4 -4
- package/dist/embeddings.js +72 -16
- package/dist/eval-stats.d.ts +58 -0
- package/dist/eval-stats.js +111 -0
- package/dist/extract.js +3 -2
- package/dist/goals.d.ts +49 -25
- package/dist/goals.js +39 -22
- package/dist/graph-extract.js +1 -1
- package/dist/graph-recall.d.ts +1 -1
- package/dist/graph-recall.js +1 -1
- package/dist/graph.js +1 -1
- package/dist/hooks.d.ts +1 -3
- package/dist/hooks.js +2 -4
- package/dist/http-retry.d.ts +21 -0
- package/dist/http-retry.js +50 -0
- package/dist/http-util.d.ts +39 -0
- package/dist/http-util.js +56 -0
- package/dist/importers.d.ts +2 -0
- package/dist/importers.js +16 -5
- package/dist/incidents.js +13 -11
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- package/dist/judgment.js +10 -17
- package/dist/log.d.ts +25 -0
- package/dist/log.js +48 -0
- package/dist/mcp/server.js +224 -308
- package/dist/mcp/tool-args.d.ts +21 -0
- package/dist/mcp/tool-args.js +80 -0
- package/dist/memory.d.ts +19 -0
- package/dist/memory.js +41 -2
- package/dist/overlap-index.d.ts +7 -0
- package/dist/overlap-index.js +38 -0
- package/dist/pilot-arm.d.ts +9 -0
- package/dist/pilot-arm.js +47 -0
- package/dist/policies.js +14 -12
- package/dist/predictions.js +11 -9
- package/dist/processes.js +16 -14
- package/dist/project-briefs.js +19 -16
- package/dist/project-identity.d.ts +1 -1
- package/dist/project-identity.js +25 -1
- package/dist/prompt-recall.js +1 -1
- package/dist/raw-archive.js +7 -6
- package/dist/recall-history.d.ts +5 -0
- package/dist/recall-history.js +9 -0
- package/dist/recall-pipeline.d.ts +101 -0
- package/dist/recall-pipeline.js +313 -0
- package/dist/recall-scope.d.ts +24 -1
- package/dist/recall-scope.js +29 -2
- package/dist/refine-llm.js +3 -2
- package/dist/reject-flow.js +6 -9
- package/dist/rejection.d.ts +2 -1
- package/dist/rejection.js +2 -1
- package/dist/search.d.ts +0 -20
- package/dist/search.js +16 -51
- package/dist/secret-detect.d.ts +13 -1
- package/dist/secret-detect.js +33 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +1854 -2566
- package/dist/session-digest.js +2 -1
- package/dist/shared.js +7 -6
- package/dist/skills.js +17 -15
- package/dist/store-cards.d.ts +53 -0
- package/dist/store-cards.js +512 -0
- package/dist/store.d.ts +2 -89
- package/dist/store.js +10 -566
- package/dist/tenant.d.ts +22 -0
- package/dist/tenant.js +26 -0
- package/dist/token-ledger.d.ts +4 -2
- package/dist/token-ledger.js +2 -2
- package/dist/tokenize.d.ts +2 -0
- package/dist/tokenize.js +8 -0
- package/dist/version.d.ts +1 -1
- package/dist/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 +1 -1
- package/dist/connectors/slack/ratelimit.d.ts +0 -9
- package/dist/connectors/slack/ratelimit.js +0 -18
package/dist/cli.js
CHANGED
|
@@ -37,10 +37,11 @@ import * as path from 'path';
|
|
|
37
37
|
import * as fs from 'fs';
|
|
38
38
|
import * as os from 'os';
|
|
39
39
|
import { fileURLToPath } from 'node:url';
|
|
40
|
-
import { execFileSync,
|
|
41
|
-
import { installJsonHooks, uninstallJsonHooks, resolveJsonHookPaths, detectInstalledTools, defaultSleepLogPath, ensureCodexWrapperInstalled, installCodexWrapper, detectRealCodexPath, isCodexPresent, isCodexWrapperInstalled,
|
|
40
|
+
import { execFileSync, spawn } from 'child_process';
|
|
41
|
+
import { installJsonHooks, uninstallJsonHooks, resolveJsonHookPaths, detectInstalledTools, defaultSleepLogPath, ensureCodexWrapperInstalled, installCodexWrapper, detectRealCodexPath, isCodexPresent, isCodexWrapperInstalled, repairCodexWrapperIfInstalled, uninstallCodexWrapper, resolveCodexSessionTranscript, resolveCodexWrapperPaths, installOpencodePlugin, uninstallOpencodePlugin, resolveOpencodePluginPath, } from './hooks.js';
|
|
42
42
|
import { createMemory, createSuccessor, calculateStrength, calculateRewardFactor, deriveHalfLife, resolveConfidence, confidenceFacets, confidenceLabel, computeSchemaFit, Layer, } from './memory.js';
|
|
43
|
-
import { getHippoRoot, isInitialized, initStore, writeEntry, strengthenRetrieved, readEntry, deleteEntry, loadAllEntries, loadSearchEntries,
|
|
43
|
+
import { getHippoRoot, isInitialized, initStore, writeEntry, strengthenRetrieved, readEntry, deleteEntry, loadAllEntries, loadSearchEntries, loadIndex, saveIndex, loadStats, updateStats, saveActiveTaskSnapshot, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, closeTaskSnapshotsForSession, clearActiveTaskSnapshot, appendSessionEvent, listSessionEvents, listMemoryConflicts, resolveConflict, saveSessionHandoff, loadLatestHandoff, loadHandoffById, stampHandoffOutcome, writeSessionEndHandoff, memoriesBackingObjects, } from './store.js';
|
|
44
|
+
import { createCard, loadCard, listCards, loadCardRuns, claimCard, heartbeatCard, blockCard, reviewCard, completeCard, reclaimExpiredCards, addCardComment, } from './store-cards.js';
|
|
44
45
|
import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
|
|
45
46
|
import { RejectedValueError } from './rejection.js';
|
|
46
47
|
import { isHandoffOutcome, formatHandoffEvidenceLine } from './handoff.js';
|
|
@@ -48,45 +49,47 @@ import { readSessionScan, recordSessionDigest } from './session-digest.js';
|
|
|
48
49
|
import { isCardStatus } from './card.js';
|
|
49
50
|
import { loadCardDetail } from './card-detail.js';
|
|
50
51
|
import { passesScopeFilterForRecall } from './recall-scope.js';
|
|
51
|
-
import { estimateTokens, fitBudget,
|
|
52
|
-
import { compareEntryIdentity } from './compare.js';
|
|
52
|
+
import { estimateTokens, fitBudget, explainMatch } from './search.js';
|
|
53
53
|
import { renderTraceContent, parseSteps } from './trace.js';
|
|
54
54
|
import { writeDeliveryEventAtRoot, writeDeliveryEventOnHandle, writeRecallTraceAtRoot } from './recall-trace.js';
|
|
55
55
|
import { createDeliveryRecorder } from './delivery-recorder.js';
|
|
56
56
|
import { deduplicateStore } from './dedupe.js';
|
|
57
|
-
import {
|
|
57
|
+
import { embedAll, embedMemory, loadEmbeddingIndex, resolveEmbeddingModel, embeddingModelRequiresReindex, } from './embeddings.js';
|
|
58
58
|
import { resolveEmbeddingProvider } from './embedding-provider.js';
|
|
59
59
|
import { loadPhysicsState, resetAllPhysicsState } from './physics-state.js';
|
|
60
60
|
import { computeSystemEnergy, vecNorm } from './physics.js';
|
|
61
61
|
import { loadConfig } from './config.js';
|
|
62
|
-
import {
|
|
62
|
+
import { ensurePilotArm, hashArm, readPilotArm } from './pilot-arm.js';
|
|
63
|
+
import { openHippoDb, closeHippoDb, withSharedStoreHandles, HOOK_DB_WAIT_MS, isSqliteBusy, noteStoreBusy } from './db.js';
|
|
63
64
|
import { runDoctor, formatDoctor } from './doctor.js';
|
|
64
65
|
import { buildSupportBundle, TAIL_MAX_LINES } from './support-bundle.js';
|
|
65
66
|
import { PACKAGE_VERSION } from './version.js';
|
|
66
67
|
import { captureToolFailure } from './capture-error.js';
|
|
67
|
-
import { blockHash,
|
|
68
|
+
import { blockHash, isSubagentPayload, lastSentState, readApiCalls, recordRereads, recordTokenUse, shouldSkipUnchanged, } from './token-ledger.js';
|
|
68
69
|
import { FAILURE_LOG_RETENTION_DAYS } from './failure-log.js';
|
|
69
|
-
import { pushGoal, getActiveGoals, completeGoal, suspendGoal, resumeGoal,
|
|
70
|
+
import { pushGoal, getActiveGoals, completeGoal, suspendGoal, resumeGoal, writeGoalRecallLog } from './goals.js';
|
|
70
71
|
import { rowToGoal } from './goals.js';
|
|
71
|
-
import { captureError,
|
|
72
|
-
import { dropHeldCopies
|
|
72
|
+
import { captureError, runWatched, isGitRepo, } from './autolearn.js';
|
|
73
|
+
import { dropHeldCopies } from './same-text.js';
|
|
73
74
|
import { currentMachine, importAtCompaction, importAtSessionEnd, importForStore, importProjectMemories, importUserMemories, } from './agent-memories/sync.js';
|
|
74
75
|
import { detailLines, emptyReport, mergeReports, summaryLine } from './agent-memories/report.js';
|
|
75
|
-
import {
|
|
76
|
-
import { deriveOriginProject, isGlobalStoreRoot
|
|
76
|
+
import { invalidateMatching } from './invalidation.js';
|
|
77
|
+
import { deriveOriginProject, isGlobalStoreRoot } from './project-identity.js';
|
|
77
78
|
import { extractPathTags } from './path-context.js';
|
|
79
|
+
import { autoDetectContext } from './context-auto.js';
|
|
78
80
|
import { detectScope } from './scope.js';
|
|
79
|
-
import { getGlobalRoot, initGlobal, shareMemory, listPeers, autoShare, transferScore,
|
|
80
|
-
import {
|
|
81
|
+
import { getGlobalRoot, initGlobal, shareMemory, listPeers, autoShare, transferScore, syncGlobalToLocal, } from './shared.js';
|
|
82
|
+
import { listRegisteredWorkspaces, registerWorkspace, runDailyMaintenance, } from './scheduler.js';
|
|
81
83
|
import { importChatGPT, importClaude, importCursor, importGenericFile, importMarkdown, importVault, } from './importers.js';
|
|
82
|
-
import { cmdCapture, cmdPreCompact, cmdPostCompact, resolveLastSessionTranscript, truncateCodePointSafe,
|
|
84
|
+
import { cmdCapture, cmdPreCompact, cmdPostCompact, resolveLastSessionTranscript, truncateCodePointSafe, transcriptWorkingState } from './capture.js';
|
|
83
85
|
import { COMPACTION_DB_WAIT_MS, replayCompactionsAt } from './compaction-record.js';
|
|
84
86
|
import { readStdinBounded } from './stdin.js';
|
|
85
|
-
import { auditMemories,
|
|
86
|
-
import { listApiKeys
|
|
87
|
+
import { auditMemories, auditQueryFields, AUDIT_OPS, } from './audit.js';
|
|
88
|
+
import { listApiKeys } from './auth.js';
|
|
87
89
|
import { buildProvenanceCoverage } from './provenance-coverage.js';
|
|
88
90
|
import { buildCorrectionLatency } from './correction-latency.js';
|
|
89
91
|
import * as api from './api.js';
|
|
92
|
+
import { vetSecrets } from './secret-detect.js';
|
|
90
93
|
import * as predictionsModule from './predictions.js';
|
|
91
94
|
import { computePlanningFallacyOutput } from './predictions.js';
|
|
92
95
|
import * as decisionsModule from './decisions.js';
|
|
@@ -98,8 +101,7 @@ import * as briefsModule from './project-briefs.js';
|
|
|
98
101
|
import * as customerNotesModule from './customer-notes.js';
|
|
99
102
|
import { extractGraph } from './graph-extract.js';
|
|
100
103
|
import { buildGraphModel, renderGraphHtml, renderGraphCanvas, DEFAULT_VIEW_LIMIT } from './graph-view.js';
|
|
101
|
-
import {
|
|
102
|
-
import { detectAnchoring, hashQueryText, buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, } from './recall-history.js';
|
|
104
|
+
import { detectAnchoring, hashQueryText, biasHintEnabled, buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, } from './recall-history.js';
|
|
103
105
|
import { detectAvailabilityBias } from './availability.js';
|
|
104
106
|
// v0.33 / J1 — Module-level per-(tenant, session) recall-history ring map.
|
|
105
107
|
// Each CLI process maintains its OWN Map; no IPC / no cross-process sharing
|
|
@@ -124,16 +126,14 @@ export function __resetSessionRecallHistoryCli() {
|
|
|
124
126
|
sessionRecallHistoryCli.clear();
|
|
125
127
|
}
|
|
126
128
|
import * as client from './client.js';
|
|
127
|
-
import { detectServer, removePidfileIfOwned } from './server-detect.js';
|
|
128
129
|
import { resolveTenantId } from './tenant.js';
|
|
129
130
|
import { runEval, bootstrapCorpus, compareSummaries } from './eval.js';
|
|
130
131
|
import { runFeatureEval, formatResult, resultToBaseline, detectRegressions } from './eval-suite.js';
|
|
131
132
|
import { refineStore } from './refine-llm.js';
|
|
132
133
|
import { wmPush, wmRead, wmClear, wmFlush } from './working-memory.js';
|
|
133
|
-
import {
|
|
134
|
-
import { graphExpandRecall, MAX_HOPS, DEFAULT_MAX_NEIGHBORS } from './graph-recall.js';
|
|
135
|
-
import { DEFAULT_GRAPH_STREAM_WEIGHT } from './graph-stream.js';
|
|
134
|
+
import { MAX_HOPS, DEFAULT_MAX_NEIGHBORS } from './graph-recall.js';
|
|
136
135
|
import { getReranker } from './rerankers/index.js';
|
|
136
|
+
import { rankRecall } from './recall-pipeline.js';
|
|
137
137
|
import { JEV_DEFAULT_TOP_K } from './rerankers/jev.js';
|
|
138
138
|
import { computeSalience } from './salience.js';
|
|
139
139
|
import { renderAmbientSummary } from './ambient.js';
|
|
@@ -145,145 +145,10 @@ import { backfillChannel } from './connectors/slack/backfill.js';
|
|
|
145
145
|
import { slackHistoryFetcher } from './connectors/slack/web-client.js';
|
|
146
146
|
import { addWorkspace as addSlackWorkspace, listWorkspaces as listSlackWorkspaces, removeWorkspace as removeSlackWorkspace, } from './connectors/slack/workspaces.js';
|
|
147
147
|
import { cmdGithub, printGithubBackfillUsage } from './connectors/github/cli-impl.js';
|
|
148
|
+
import { parseLimitFlag, parseCountFlag, parseBudgetFlag, emitCliAudit, requireInit, runChurnStaleForRepo, runViaServerIfAvailable, fmt, recallEntryText, recallHeading, printAgentImport, hippoBlock, installCodexMemoryHooks, setupDailySchedule, parseAsOfFlag, engineFlags, collectHandoffEvidence, logSessionEndImport, appendSessionEndCloseLog, printActiveTaskSnapshot, printSessionEvents, printHandoff, cardStringFlag, hostSessionId, resetHookInjection, captureConsole, hookStoreRoot, withLedgerDb, learnFromRepo, HOOK_MARKERS, HOOKS, resolveAuthRoot, } from './cli/shared.js';
|
|
148
149
|
// ---------------------------------------------------------------------------
|
|
149
150
|
// Helpers
|
|
150
151
|
// ---------------------------------------------------------------------------
|
|
151
|
-
function parseLimitFlag(value) {
|
|
152
|
-
if (!value)
|
|
153
|
-
return Infinity;
|
|
154
|
-
const parsed = parseInt(String(value), 10);
|
|
155
|
-
return Number.isFinite(parsed) && parsed >= 1 ? parsed : Infinity;
|
|
156
|
-
}
|
|
157
|
-
function parseCountFlag(value) {
|
|
158
|
-
if (!value || value === true || Array.isArray(value))
|
|
159
|
-
return 0;
|
|
160
|
-
const parsed = parseInt(String(value), 10);
|
|
161
|
-
return Number.isFinite(parsed) && parsed >= 1 ? parsed : 0;
|
|
162
|
-
}
|
|
163
|
-
function parseBudgetFlag(value, fallback) {
|
|
164
|
-
if (value === undefined)
|
|
165
|
-
return fallback;
|
|
166
|
-
// A value-less flag and a junk value are different typos; the --hops guard already splits them.
|
|
167
|
-
if (typeof value !== 'string') {
|
|
168
|
-
console.error('--budget requires an integer value (e.g. --budget 1500).');
|
|
169
|
-
process.exit(1);
|
|
170
|
-
}
|
|
171
|
-
// Number(), like the --hops guard: parseInt('12abc') is 12, silently accepting what this message rejects.
|
|
172
|
-
const parsed = Number(value);
|
|
173
|
-
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
174
|
-
console.error(`Invalid --budget: "${value}". Must be a non-negative integer.`);
|
|
175
|
-
process.exit(1);
|
|
176
|
-
}
|
|
177
|
-
return parsed;
|
|
178
|
-
}
|
|
179
|
-
/**
|
|
180
|
-
* Emit an audit event against `hippoRoot`'s db. Opens its own short-lived
|
|
181
|
-
* connection so callers don't have to thread a db handle. Swallows all errors
|
|
182
|
-
* — audit must never crash a CLI command.
|
|
183
|
-
*/
|
|
184
|
-
function emitCliAudit(hippoRoot, op, targetId, metadata) {
|
|
185
|
-
try {
|
|
186
|
-
const db = openHippoDb(hippoRoot);
|
|
187
|
-
try {
|
|
188
|
-
appendAuditEvent(db, {
|
|
189
|
-
tenantId: resolveTenantId({}),
|
|
190
|
-
actor: 'cli',
|
|
191
|
-
op,
|
|
192
|
-
targetId,
|
|
193
|
-
metadata,
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
finally {
|
|
197
|
-
closeHippoDb(db);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
catch {
|
|
201
|
-
// Audit is best-effort; surface failures only via missing rows.
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
function requireInit(hippoRoot) {
|
|
205
|
-
if (!isInitialized(hippoRoot)) {
|
|
206
|
-
console.error(`No hippo store at ${hippoRoot} (searched ${process.cwd()} and its parents up to your home directory). Run \`hippo init\` first.`);
|
|
207
|
-
process.exit(1);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
/** FE2: run detectChurnStale against every store this repo's memories can live in. */
|
|
211
|
-
function runChurnStaleForRepo(hippoRoot, dryRun) {
|
|
212
|
-
const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf8', windowsHide: true }).trim();
|
|
213
|
-
const projectName = resolveProjectIdentity(process.cwd()).name;
|
|
214
|
-
const globalRoot = getGlobalRoot();
|
|
215
|
-
const roots = globalRoot !== hippoRoot && isInitialized(globalRoot) ? [hippoRoot, globalRoot] : [hippoRoot];
|
|
216
|
-
const tenantId = resolveTenantId({});
|
|
217
|
-
return roots.map((root) => {
|
|
218
|
-
// One store failing must not abort sleep's later phases or skip the other store.
|
|
219
|
-
try {
|
|
220
|
-
return { root, result: detectChurnStale(root, repoRoot, { tenantId, projectName, dryRun }) };
|
|
221
|
-
}
|
|
222
|
-
catch (err) {
|
|
223
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
224
|
-
return { root, result: { checked: 0, marked: 0, alreadyMarked: 0, skippedPinned: [], dryRun, preview: [], error: message } };
|
|
225
|
-
}
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
/**
|
|
229
|
-
* H2: when HIPPO_REQUIRE_SERVER is set, the CLI must not silently fall back to
|
|
230
|
-
* direct DB mode — a missing server then masks a real misconfiguration (the
|
|
231
|
-
* configured HIPPO_API_KEY is also silently discarded on fallback). Throws a
|
|
232
|
-
* clear error then. It guards only the routed writes (remember, forget, archive,
|
|
233
|
-
* promote); every other command opens the store directly, knob or not.
|
|
234
|
-
*/
|
|
235
|
-
function failIfServerRequired(reason) {
|
|
236
|
-
if (process.env['HIPPO_REQUIRE_SERVER']) {
|
|
237
|
-
throw new Error(`hippo: HIPPO_REQUIRE_SERVER is set but ${reason}. ` +
|
|
238
|
-
`Start \`hippo serve\`, or unset HIPPO_REQUIRE_SERVER to allow direct-mode fallback.`);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
/**
|
|
242
|
-
* Run an HTTP-routed command if a `hippo serve` instance is detected for
|
|
243
|
-
* `hippoRoot`. Returns:
|
|
244
|
-
* - true if the HTTP path ran (success OR a structured server error that
|
|
245
|
-
* was already surfaced to stdout/stderr by `httpFn`),
|
|
246
|
-
* - false if no server was detected, or if the detected pidfile turned out
|
|
247
|
-
* to be stale (connection refused). On stale, the pidfile is removed
|
|
248
|
-
* if it still names that dead server (a newer one may have replaced
|
|
249
|
-
* it) and the caller should fall back to the direct path.
|
|
250
|
-
*
|
|
251
|
-
* Per the A1 plan footgun #1: stale pidfiles must self-heal, not crash.
|
|
252
|
-
* H2: when HIPPO_REQUIRE_SERVER is set, both fallback paths throw instead of
|
|
253
|
-
* returning false, so a missing server fails loudly rather than silently
|
|
254
|
-
* degrading to direct mode.
|
|
255
|
-
*/
|
|
256
|
-
async function runViaServerIfAvailable(hippoRoot, httpFn) {
|
|
257
|
-
const info = await detectServer(hippoRoot);
|
|
258
|
-
if (!info) {
|
|
259
|
-
failIfServerRequired('no running server was detected for this hippoRoot');
|
|
260
|
-
return false;
|
|
261
|
-
}
|
|
262
|
-
const apiKey = process.env['HIPPO_API_KEY'];
|
|
263
|
-
try {
|
|
264
|
-
await httpFn(info, apiKey);
|
|
265
|
-
return true;
|
|
266
|
-
}
|
|
267
|
-
catch (err) {
|
|
268
|
-
const failure = client.classifyTransportFailure(err);
|
|
269
|
-
if (failure === 'never-sent') {
|
|
270
|
-
failIfServerRequired('the server pidfile was stale (connection refused)');
|
|
271
|
-
console.error('hippo: stale server pidfile detected, falling back to direct mode');
|
|
272
|
-
// Clear the pidfile only if it still names the dead server we just
|
|
273
|
-
// probed — a newer server may have rewritten it (removePidfileIfOwned).
|
|
274
|
-
removePidfileIfOwned(hippoRoot, { pid: info.pid, startedAt: info.started_at });
|
|
275
|
-
return false;
|
|
276
|
-
}
|
|
277
|
-
if (failure === 'delivery-unknown') {
|
|
278
|
-
// Every caller of this helper is a non-idempotent write, so replaying on
|
|
279
|
-
// the direct path would store a row the server may already have committed.
|
|
280
|
-
// Leave the pidfile alone: the next command's connect-phase failure heals it.
|
|
281
|
-
console.error(`hippo: the connection to ${info.url} dropped mid-request, so the write may already have been applied. Not retrying locally. Check with \`hippo recall\` before running this again.`);
|
|
282
|
-
process.exit(1);
|
|
283
|
-
}
|
|
284
|
-
throw err;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
152
|
// Every switch the CLI reads. A value on one reads as on under Boolean() (`--fix=false` would fix)
|
|
288
153
|
// and as off under === true (`--pin=true` would not pin), so parseArgs and main() refuse one.
|
|
289
154
|
// tests/cli-parse-flag-equals.test.ts fails when a switch read is missing from this set.
|
|
@@ -410,51 +275,6 @@ export function parseArgs(argv) {
|
|
|
410
275
|
}
|
|
411
276
|
return { command, args, flags };
|
|
412
277
|
}
|
|
413
|
-
function fmt(n, digits = 2) {
|
|
414
|
-
return n.toFixed(digits);
|
|
415
|
-
}
|
|
416
|
-
// What `hippo recall` prints for one result; the budget prices this same text.
|
|
417
|
-
function recallEntryText(r, query, showWhy, isGlobal) {
|
|
418
|
-
const e = r.entry;
|
|
419
|
-
const label = confidenceLabel(e);
|
|
420
|
-
const confLabel = label.warn ? `[${label.text}] ⚠️` : `[${label.text}]`;
|
|
421
|
-
const bars = Math.round(e.strength * 10);
|
|
422
|
-
const graphMark = r.graphVia ? ` [graph: ${r.graphVia.hops}hop ${r.graphVia.relType}]` : '';
|
|
423
|
-
const lines = [
|
|
424
|
-
`--- ${e.id} [${e.layer}] ${confLabel}${isGlobal ? ' [global]' : ''}${e.superseded_by ? ' [superseded]' : ''}${graphMark} score=${fmt(r.score, 3)} strength=${fmt(e.strength)}`,
|
|
425
|
-
` [${'█'.repeat(bars)}${'░'.repeat(10 - bars)}] tags: ${e.tags.join(', ') || 'none'} | retrieved: ${e.retrieval_count}x`,
|
|
426
|
-
];
|
|
427
|
-
if (showWhy) {
|
|
428
|
-
const explanation = explainMatch(query, r);
|
|
429
|
-
lines.push(` source:${isGlobal ? ' [global]' : ' [local]'} | layer: [${e.layer}] | confidence: [${label.text}]`, ` reason: ${explanation.reason}`);
|
|
430
|
-
const env = explanation.envelope;
|
|
431
|
-
if (env) {
|
|
432
|
-
lines.push(` kind: ${env.kind}`);
|
|
433
|
-
if (env.scope)
|
|
434
|
-
lines.push(` scope: ${env.scope}`);
|
|
435
|
-
if (env.owner)
|
|
436
|
-
lines.push(` owner: ${env.owner}`);
|
|
437
|
-
if (env.artifact_ref)
|
|
438
|
-
lines.push(` artifact_ref: ${env.artifact_ref}`);
|
|
439
|
-
if (env.session_id)
|
|
440
|
-
lines.push(` session_id: ${env.session_id}`);
|
|
441
|
-
lines.push(` confidence: ${env.confidence}`);
|
|
442
|
-
}
|
|
443
|
-
// A7 recall-trace, e.g. "ranking: base 0.420 -> interference x0.30 -> 0.126 -> goal-boost x1.50 -> 0.189".
|
|
444
|
-
if (r.rerankTrace && r.rerankTrace.length > 0) {
|
|
445
|
-
const parts = [`base ${fmt(r.rerankTrace[0].scoreBefore, 3)}`];
|
|
446
|
-
for (const step of r.rerankTrace) {
|
|
447
|
-
parts.push(`${step.stage}${step.multiplier !== undefined ? ` x${fmt(step.multiplier, 2)}` : ''}`, fmt(step.scoreAfter, 3));
|
|
448
|
-
}
|
|
449
|
-
lines.push(` ranking: ${parts.join(' -> ')}`);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
lines.push('', e.content, '');
|
|
453
|
-
return lines.join('\n');
|
|
454
|
-
}
|
|
455
|
-
function recallHeading(entries, tokens, query) {
|
|
456
|
-
return `Found ${entries} memories (${tokens} tokens) for: "${query}"\n`;
|
|
457
|
-
}
|
|
458
278
|
// JSON.stringify keeps quotes or parens in the matched phrase from blurring the line.
|
|
459
279
|
function planningLine(p) {
|
|
460
280
|
if (p.hint)
|
|
@@ -633,14 +453,6 @@ function cmdInit(hippoRoot, flags) {
|
|
|
633
453
|
if (!flags['no-learn'])
|
|
634
454
|
printAgentImport(importForStore(hippoRoot, { machine: currentMachine() }));
|
|
635
455
|
}
|
|
636
|
-
/** One line when an agent memory import moved anything; its warnings go to stderr. */
|
|
637
|
-
function printAgentImport(report, indent = ' ') {
|
|
638
|
-
const line = summaryLine(report);
|
|
639
|
-
if (line !== null)
|
|
640
|
-
console.log(`${indent}${line}`);
|
|
641
|
-
for (const warning of report.warnings)
|
|
642
|
-
console.error(`hippo: agent memories: ${warning}`);
|
|
643
|
-
}
|
|
644
456
|
/** Every write init makes into agent config (instruction blocks, hooks, plugins) is an automatic integration, so one switch skips them all. */
|
|
645
457
|
function initInstallsIntegrations(flags) {
|
|
646
458
|
if (flags['no-hooks'])
|
|
@@ -698,19 +510,6 @@ function patchInstructionFiles(dir, agents) {
|
|
|
698
510
|
console.log(` Auto-installed ${hook} hook in ${hookDef.file}`);
|
|
699
511
|
}
|
|
700
512
|
}
|
|
701
|
-
/** The first hippo block in `text` and the agent whose current or shipped text it is; `owner` is undefined for an edited block. */
|
|
702
|
-
function hippoBlock(text) {
|
|
703
|
-
const at = text.indexOf(HOOK_MARKERS.start);
|
|
704
|
-
const start = at + HOOK_MARKERS.start.length;
|
|
705
|
-
const end = text.indexOf(HOOK_MARKERS.end, start);
|
|
706
|
-
if (at < 0 || end < 0)
|
|
707
|
-
return null;
|
|
708
|
-
// git autocrlf checks these files out with CRLF: match as LF, write back in the file's own ending.
|
|
709
|
-
const raw = text.slice(start, end);
|
|
710
|
-
const inner = raw.replace(/\r\n/g, '\n').trim();
|
|
711
|
-
const owner = Object.keys(HOOKS).find((k) => HOOKS[k].content === inner) ?? SHIPPED_HOOK_HASHES.get(createHash('sha256').update(inner).digest('hex'));
|
|
712
|
-
return { start, end, eol: raw.includes('\r\n') ? '\r\n' : '\n', inner, owner };
|
|
713
|
-
}
|
|
714
513
|
/** Swap an unedited block from an earlier hippo for the current one; an edited block stays, with a hint. */
|
|
715
514
|
function refreshShippedBlock(filePath, text, hook) {
|
|
716
515
|
const block = hippoBlock(text);
|
|
@@ -725,22 +524,6 @@ function refreshShippedBlock(filePath, text, hook) {
|
|
|
725
524
|
fs.writeFileSync(filePath, `${text.slice(0, start)}${eol}${HOOKS[owner].content.replace(/\n/g, eol)}${eol}${text.slice(end)}`, 'utf8');
|
|
726
525
|
console.log(` Refreshed the ${owner} hippo block in ${name}`);
|
|
727
526
|
}
|
|
728
|
-
/** Adds hippo's two Codex hooks and says what changed; each install ends on the trust reminder, since Codex skips an untrusted hook. */
|
|
729
|
-
function installCodexMemoryHooks(indent) {
|
|
730
|
-
const result = installJsonHooks('codex');
|
|
731
|
-
if (result.invalidJson) {
|
|
732
|
-
console.log(`${indent}WARNING: ${result.settingsPath} is not a hooks file hippo can merge into; fix it, then run \`hippo hook install codex\`.`);
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
const added = [
|
|
736
|
-
result.installedUserPromptSubmit ? 'UserPromptSubmit' : '',
|
|
737
|
-
result.installedCompactResume ? 'SessionStart(compact)' : '',
|
|
738
|
-
].filter(Boolean);
|
|
739
|
-
console.log(added.length > 0
|
|
740
|
-
? `${indent}Installed hippo's Codex memory hooks (${added.join(', ')}) in ${result.settingsPath}`
|
|
741
|
-
: `${indent}hippo's Codex memory hooks already in ${result.settingsPath}`);
|
|
742
|
-
console.log(`${indent}${CODEX_TRUST_LINE}`);
|
|
743
|
-
}
|
|
744
527
|
/** Claude Code settings hooks, Codex's hooks.json and the OpenCode plugin, under the home directory; idempotent, so re-running init adds newer hooks. */
|
|
745
528
|
function installUserLevelHooks(agents, codexHint) {
|
|
746
529
|
for (const hook of agents) {
|
|
@@ -804,66 +587,6 @@ function installUserLevelHooks(agents, codexHint) {
|
|
|
804
587
|
}
|
|
805
588
|
}
|
|
806
589
|
}
|
|
807
|
-
/**
|
|
808
|
-
* Set up a machine-level daily runner that sweeps all registered Hippo
|
|
809
|
-
* workspaces.
|
|
810
|
-
* Linux/macOS: writes to user crontab.
|
|
811
|
-
* Windows: creates a scheduled task.
|
|
812
|
-
* Skips if already installed.
|
|
813
|
-
*/
|
|
814
|
-
function setupDailySchedule(globalRoot) {
|
|
815
|
-
const runnerDir = path.resolve(globalRoot);
|
|
816
|
-
// Reject paths with characters that could break shell/crontab quoting
|
|
817
|
-
// (backslash is normal on Windows, only dangerous in Unix shell/crontab)
|
|
818
|
-
const unsafeChars = process.platform === 'win32' ? /["`$%\n\r]/ : /["`$\n\r\\]/;
|
|
819
|
-
if (unsafeChars.test(runnerDir)) {
|
|
820
|
-
console.log(` Skipping schedule: runner path contains unsafe characters.`);
|
|
821
|
-
return;
|
|
822
|
-
}
|
|
823
|
-
const isWindows = process.platform === 'win32';
|
|
824
|
-
const taskName = DAILY_TASK_NAME;
|
|
825
|
-
const cmd = buildDailyRunnerCommand(runnerDir);
|
|
826
|
-
if (isWindows) {
|
|
827
|
-
// Check if task already exists
|
|
828
|
-
try {
|
|
829
|
-
const existing = execSync(`schtasks /query /tn "${taskName}" 2>nul`, { encoding: 'utf-8', windowsHide: true });
|
|
830
|
-
if (existing.includes(taskName)) {
|
|
831
|
-
return; // already scheduled
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
catch {
|
|
835
|
-
// Task doesn't exist, create it
|
|
836
|
-
}
|
|
837
|
-
try {
|
|
838
|
-
execFileSync('schtasks', buildSchtasksCreateArgs(taskName, cmd), { stdio: 'pipe', windowsHide: true });
|
|
839
|
-
console.log(` Scheduled machine-level daily runner (6:15am) via Task Scheduler: ${taskName}`);
|
|
840
|
-
}
|
|
841
|
-
catch {
|
|
842
|
-
// No admin rights or schtasks unavailable, fall back to printing instructions
|
|
843
|
-
console.log(` To schedule the machine-level daily runner, run:`);
|
|
844
|
-
console.log(` schtasks /create /tn "${taskName}" /tr "${buildWindowsTaskRun(cmd).replace(/"/g, '\\"')}" /sc daily /st 06:15`);
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
else {
|
|
848
|
-
// Unix: check crontab for existing entry
|
|
849
|
-
const marker = `# hippo:${taskName}`;
|
|
850
|
-
try {
|
|
851
|
-
const existing = execSync('crontab -l 2>/dev/null', { encoding: 'utf-8', windowsHide: true });
|
|
852
|
-
if (existing.includes(marker)) {
|
|
853
|
-
return; // already scheduled
|
|
854
|
-
}
|
|
855
|
-
const cronLine = `15 6 * * * ${cmd} ${marker}`;
|
|
856
|
-
const newCrontab = existing.trimEnd() + '\n' + cronLine + '\n';
|
|
857
|
-
execSync('crontab -', { input: newCrontab, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
858
|
-
console.log(` Scheduled machine-level daily runner (6:15am) via crontab`);
|
|
859
|
-
}
|
|
860
|
-
catch {
|
|
861
|
-
const cronLine = `15 6 * * * ${cmd}`;
|
|
862
|
-
console.log(` To schedule the machine-level daily runner, add to crontab (crontab -e):`);
|
|
863
|
-
console.log(` ${cronLine}`);
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
590
|
// Shared by the direct write and the routed request so both store the same tags.
|
|
868
591
|
function rememberTags(flags, cwd) {
|
|
869
592
|
const requested = Array.isArray(flags['tag']) ? [...flags['tag']] : [];
|
|
@@ -974,6 +697,8 @@ async function cmdRemember(hippoRoot, text, flags) {
|
|
|
974
697
|
console.log(` Tags: ${entry.tags.join(', ')}`);
|
|
975
698
|
if (entry.pinned)
|
|
976
699
|
console.log(' Pinned (no decay)');
|
|
700
|
+
for (const w of vetSecrets(entry.content, entry.tags, false).warnings)
|
|
701
|
+
console.error(`Warning: ${w}`);
|
|
977
702
|
void embedMemory(targetRoot, entry);
|
|
978
703
|
const config = loadConfig(targetRoot);
|
|
979
704
|
const shouldExtract = flags['extract'] || config.extraction.enabled === true;
|
|
@@ -1050,669 +775,177 @@ function cmdSupersede(hippoRoot, oldId, newContent, flags) {
|
|
|
1050
775
|
emitCliAudit(hippoRoot, 'supersede', oldId, { newId: newEntry.id });
|
|
1051
776
|
console.log(`Superseded ${oldId} → ${newEntry.id}`);
|
|
1052
777
|
}
|
|
778
|
+
function failWith(message) {
|
|
779
|
+
return () => {
|
|
780
|
+
console.error(message);
|
|
781
|
+
process.exit(1);
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
/** `--graph-stream` implies rrf fusion as well as the graph stream; the CLI fuses the local store only. */
|
|
785
|
+
function parseGraphStreamFlags(flags) {
|
|
786
|
+
let hops;
|
|
787
|
+
if (flags['graph-hops'] !== undefined) {
|
|
788
|
+
if (typeof flags['graph-hops'] === 'boolean')
|
|
789
|
+
failWith(`--graph-hops requires an integer value 1..${MAX_HOPS} (e.g. --graph-hops 2).`)();
|
|
790
|
+
const h = Number(flags['graph-hops']);
|
|
791
|
+
if (!Number.isInteger(h) || h < 1 || h > MAX_HOPS) {
|
|
792
|
+
failWith(`Invalid --graph-hops: "${String(flags['graph-hops'])}". Must be an integer 1..${MAX_HOPS}.`)();
|
|
793
|
+
}
|
|
794
|
+
hops = h;
|
|
795
|
+
}
|
|
796
|
+
let seeds;
|
|
797
|
+
if (flags['graph-seeds'] !== undefined) {
|
|
798
|
+
if (typeof flags['graph-seeds'] === 'boolean')
|
|
799
|
+
failWith('--graph-seeds requires a positive integer value (e.g. --graph-seeds 10).')();
|
|
800
|
+
const s = Number(flags['graph-seeds']);
|
|
801
|
+
if (!Number.isInteger(s) || s < 1)
|
|
802
|
+
failWith(`Invalid --graph-seeds: "${String(flags['graph-seeds'])}". Must be a positive integer.`)();
|
|
803
|
+
seeds = s;
|
|
804
|
+
}
|
|
805
|
+
return { hops, seeds };
|
|
806
|
+
}
|
|
807
|
+
function parseHopsFlags(flags) {
|
|
808
|
+
if (flags['hops'] === undefined)
|
|
809
|
+
return {};
|
|
810
|
+
// A value-less `--hops` parses as true, and Number(true) === 1 would silently run a 1-hop expansion.
|
|
811
|
+
if (typeof flags['hops'] === 'boolean')
|
|
812
|
+
return { fail: failWith(`--hops requires an integer value 0..${MAX_HOPS} (e.g. --hops 1).`) };
|
|
813
|
+
const hops = Number(flags['hops']);
|
|
814
|
+
if (!Number.isInteger(hops) || hops < 0 || hops > MAX_HOPS) {
|
|
815
|
+
return { fail: failWith(`Invalid --hops: "${String(flags['hops'])}". Must be an integer 0..${MAX_HOPS}.`) };
|
|
816
|
+
}
|
|
817
|
+
const raw = flags['max-neighbors'];
|
|
818
|
+
if (raw === undefined)
|
|
819
|
+
return { value: { hops, maxNeighbors: DEFAULT_MAX_NEIGHBORS } };
|
|
820
|
+
if (typeof raw === 'boolean')
|
|
821
|
+
return { fail: failWith(`--max-neighbors requires an integer value 1..200.`) };
|
|
822
|
+
const maxNeighbors = Number(raw);
|
|
823
|
+
if (!Number.isInteger(maxNeighbors) || maxNeighbors < 1 || maxNeighbors > 200) {
|
|
824
|
+
return { fail: failWith(`Invalid --max-neighbors: "${String(raw)}". Must be an integer 1..200.`) };
|
|
825
|
+
}
|
|
826
|
+
return { value: { hops, maxNeighbors } };
|
|
827
|
+
}
|
|
828
|
+
function parseRerankerFlag(flags) {
|
|
829
|
+
const name = flags['reranker'] !== undefined ? String(flags['reranker']).trim() : '';
|
|
830
|
+
let fn;
|
|
831
|
+
try {
|
|
832
|
+
fn = getReranker(name);
|
|
833
|
+
}
|
|
834
|
+
catch (err) {
|
|
835
|
+
// An unknown name throws to the top-level handler, as it did when the lookup sat mid-pipeline.
|
|
836
|
+
return { fail: () => { throw err; } };
|
|
837
|
+
}
|
|
838
|
+
if (!fn)
|
|
839
|
+
return {};
|
|
840
|
+
const topK = flags['reranker-top-k'] !== undefined
|
|
841
|
+
? parseInt(String(flags['reranker-top-k']), 10)
|
|
842
|
+
: name === 'jev' ? JEV_DEFAULT_TOP_K : 50;
|
|
843
|
+
return { value: { fn, topK } };
|
|
844
|
+
}
|
|
845
|
+
function parseSalienceFlag(flags) {
|
|
846
|
+
const raw = flags['salience-threshold'];
|
|
847
|
+
if (raw === undefined)
|
|
848
|
+
return {};
|
|
849
|
+
const threshold = Number(raw);
|
|
850
|
+
if (!Number.isFinite(threshold) || threshold <= 0) {
|
|
851
|
+
return { fail: failWith(`Invalid --salience-threshold: "${String(raw)}". Must be a positive number.`) };
|
|
852
|
+
}
|
|
853
|
+
return { value: threshold };
|
|
854
|
+
}
|
|
855
|
+
function parseChoiceFlag(flags, name, valid) {
|
|
856
|
+
const value = flags[name] !== undefined ? String(flags[name]).trim() : '';
|
|
857
|
+
if (!value)
|
|
858
|
+
return {};
|
|
859
|
+
if (!valid.includes(value))
|
|
860
|
+
return { fail: failWith(`Invalid --${name}: "${value}". Must be one of: ${valid.join(', ')}.`) };
|
|
861
|
+
return { value };
|
|
862
|
+
}
|
|
863
|
+
/** Parses every flag a ranking stage reads; the first invalid one in pipeline order becomes `error`. */
|
|
864
|
+
function parseRecallLateFlags(flags) {
|
|
865
|
+
const graphHops = parseHopsFlags(flags);
|
|
866
|
+
const reranker = parseRerankerFlag(flags);
|
|
867
|
+
const salience = parseSalienceFlag(flags);
|
|
868
|
+
const outcome = parseChoiceFlag(flags, 'outcome', ['success', 'failure', 'partial']);
|
|
869
|
+
const layer = parseChoiceFlag(flags, 'layer', Object.values(Layer));
|
|
870
|
+
const staged = [
|
|
871
|
+
['expand', graphHops.fail], ['rerank', reranker.fail], ['salience', salience.fail], ['outcome', outcome.fail], ['layer', layer.fail],
|
|
872
|
+
];
|
|
873
|
+
let error;
|
|
874
|
+
for (const [stage, fail] of staged) {
|
|
875
|
+
if (fail) {
|
|
876
|
+
error = { stage, fail };
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return {
|
|
881
|
+
graphHops: graphHops.value,
|
|
882
|
+
reranker: reranker.value,
|
|
883
|
+
salienceThreshold: salience.value,
|
|
884
|
+
outcome: outcome.value,
|
|
885
|
+
layer: layer.value,
|
|
886
|
+
error,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
1053
889
|
async function cmdRecall(hippoRoot, query, flags) {
|
|
1054
890
|
requireInit(hippoRoot);
|
|
1055
891
|
const budget = parseBudgetFlag(flags['budget'], 4000);
|
|
1056
892
|
const limit = parseLimitFlag(flags['limit']);
|
|
1057
893
|
const asJson = Boolean(flags['json']);
|
|
1058
894
|
const showWhy = Boolean(flags['why']);
|
|
1059
|
-
const forcePhysics = Boolean(flags['physics']);
|
|
1060
|
-
const forceClassic = Boolean(flags['classic']);
|
|
1061
895
|
const includeSuperseded = Boolean(flags['include-superseded']);
|
|
1062
|
-
const asOf =
|
|
1063
|
-
if (asOf !== undefined && Number.isNaN(new Date(asOf).getTime())) {
|
|
1064
|
-
console.error(`Error: --as-of value "${asOf}" is not a valid ISO date (e.g. 2026-04-22 or 2026-04-22T12:00:00Z).`);
|
|
1065
|
-
process.exit(1);
|
|
1066
|
-
}
|
|
896
|
+
const asOf = parseAsOfFlag(flags);
|
|
1067
897
|
const globalRoot = getGlobalRoot();
|
|
1068
898
|
const primaryIsGlobal = isGlobalStoreRoot(hippoRoot);
|
|
1069
|
-
//
|
|
1070
|
-
// recall-time SELECT against `memories`. Cross-tenant rows must never surface.
|
|
899
|
+
// Cross-tenant rows must never surface, so the tenant is resolved once and threaded through every load.
|
|
1071
900
|
const tenantId = resolveTenantId({});
|
|
1072
|
-
//
|
|
1073
|
-
// scope rule. SQL half via loadRecallSearchEntries in 'additive' mode —
|
|
1074
|
-
// default-deny (`unknown:legacy` quarantine) always, and an explicit
|
|
1075
|
-
// `--scope X` UNLOCKS envelope scope X on top of the default-admitted set
|
|
1076
|
-
// (the CLI flag is historically a tag-boost hint over scope-NULL rows, so
|
|
1077
|
-
// api.recall's narrowing exact-match would empty every tag-scoped recall —
|
|
1078
|
-
// see passesCliRecallScopeFilter in recall-scope.ts). The regex-only
|
|
1079
|
-
// `<source>:private:*` half is the JS post-filter below. Hoisted here
|
|
1080
|
-
// (it previously lived with the boost flags): recallExplicitScope is the
|
|
1081
|
-
// FILTER input; recallActiveScope (which falls back to detectScope()) stays
|
|
1082
|
-
// boost-only — auto-detection must never become a filter input or
|
|
1083
|
-
// detected-project recalls would change shape.
|
|
901
|
+
// The explicit --scope is the filter input; the detected scope only boosts, so auto-detection never filters.
|
|
1084
902
|
const recallExplicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
|
|
1085
|
-
const requestedScopeForFilter = recallExplicitScope || undefined;
|
|
1086
|
-
const loadSuperseded = includeSuperseded || Boolean(asOf);
|
|
1087
|
-
let localEntries = loadRecallSearchEntries(hippoRoot, query, undefined, tenantId, requestedScopeForFilter, 'additive', loadSuperseded);
|
|
1088
|
-
let globalEntries = globalRoot !== hippoRoot && isInitialized(globalRoot)
|
|
1089
|
-
? loadRecallSearchEntries(globalRoot, query, undefined, tenantId, requestedScopeForFilter, 'additive', loadSuperseded)
|
|
1090
|
-
: [];
|
|
1091
|
-
// v1.12.13 / C5 — WYSIATI counters. Track filter activity per the plan v3
|
|
1092
|
-
// Task 3 mapping table. dropped_pre_rank is the SUM of all non-budget
|
|
1093
|
-
// filter drops (pre-rank AND post-rank); its meaning is unchanged by C5.
|
|
1094
|
-
//
|
|
1095
|
-
// C5 (2026-08-24): search-engine internal drops (scored-to-zero rows that
|
|
1096
|
-
// hybridSearch/physicsSearch returns fewer of than they were given) now
|
|
1097
|
-
// count toward droppedByBudget, not "not counted at all" as the old v1
|
|
1098
|
-
// convention had it. That old convention is exactly why the `Cutoff:` line
|
|
1099
|
-
// never printed: cmdRecall measured droppedByBudget from `results.length -
|
|
1100
|
-
// limit` (cli.ts ~1547) AFTER the search call, but `results` had already
|
|
1101
|
-
// been ranked and truncated by the search engine to a handful of rows, so
|
|
1102
|
-
// `limit < results.length` was almost always false and the counter stayed
|
|
1103
|
-
// 0 while hundreds of candidates silently vanished (see the plan's measured
|
|
1104
|
-
// table: 397 of 400 candidates gone, every counter reading 0). droppedByBudget
|
|
1105
|
-
// is now derived as "everything not attributed to a named pre-rank filter",
|
|
1106
|
-
// computed after the final `--limit` slice — see the definition near
|
|
1107
|
-
// line ~1545 for the exact formula and the double-count argument.
|
|
1108
|
-
// totalCandidates = post-SQL-predicate count (api.recall parity: measured
|
|
1109
|
-
// after loadRecallSearchEntries, before the JS scope filter). NOTE the
|
|
1110
|
-
// v1.12.13 accounting convention: SQL-excluded rows (quarantine + the
|
|
1111
|
-
// v1.25.0 pre-window ':private:' exclusion) are pre-candidate and are NOT
|
|
1112
|
-
// counted as drops; the JS half below normally drops 0 and exists as
|
|
1113
|
-
// defense-in-depth (LIKE/regex divergence, exact-mode mismatch).
|
|
1114
|
-
const totalCandidatesCountCmd = localEntries.length + globalEntries.length;
|
|
1115
|
-
let droppedPreRankCountCmd = 0;
|
|
1116
|
-
// Graph expansion adds candidates AFTER totalCandidatesCountCmd is taken,
|
|
1117
|
-
// so they are folded back in before the budget residual is derived.
|
|
1118
|
-
let graphAddedCountCmd = 0;
|
|
1119
|
-
// v1.25.0: JS half of the recall scope rule (private-scope regex deny with
|
|
1120
|
-
// explicit-request unlock), via the canonical helper — do not inline a
|
|
1121
|
-
// fourth copy of this predicate.
|
|
1122
|
-
const passesRecallScope = (e) => api.passesCliRecallScopeFilter(e.scope ?? null, requestedScopeForFilter);
|
|
1123
|
-
const beforeScopeFilterCmd = localEntries.length + globalEntries.length;
|
|
1124
|
-
localEntries = localEntries.filter(passesRecallScope);
|
|
1125
|
-
globalEntries = globalEntries.filter(passesRecallScope);
|
|
1126
|
-
droppedPreRankCountCmd += beforeScopeFilterCmd - (localEntries.length + globalEntries.length);
|
|
1127
|
-
// Bi-temporal filtering for physics path (hybridSearch handles it internally)
|
|
1128
|
-
if (asOf) {
|
|
1129
|
-
const filterAsOf = (entries) => {
|
|
1130
|
-
const asOfDate = new Date(asOf);
|
|
1131
|
-
const successorValidFrom = new Map();
|
|
1132
|
-
for (const e of entries) {
|
|
1133
|
-
if (e.superseded_by) {
|
|
1134
|
-
const successor = entries.find(s => s.id === e.superseded_by);
|
|
1135
|
-
if (successor)
|
|
1136
|
-
successorValidFrom.set(e.id, successor.valid_from);
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
return entries.filter(e => {
|
|
1140
|
-
if (new Date(e.valid_from) > asOfDate)
|
|
1141
|
-
return false;
|
|
1142
|
-
if (!e.superseded_by)
|
|
1143
|
-
return true;
|
|
1144
|
-
const succVf = successorValidFrom.get(e.id);
|
|
1145
|
-
return succVf ? new Date(succVf) > asOfDate : true;
|
|
1146
|
-
});
|
|
1147
|
-
};
|
|
1148
|
-
const beforeAsOf = localEntries.length + globalEntries.length;
|
|
1149
|
-
localEntries = filterAsOf(localEntries);
|
|
1150
|
-
globalEntries = filterAsOf(globalEntries);
|
|
1151
|
-
droppedPreRankCountCmd += beforeAsOf - (localEntries.length + globalEntries.length);
|
|
1152
|
-
}
|
|
1153
|
-
else if (!includeSuperseded) {
|
|
1154
|
-
const beforeSupersededDrop = localEntries.length + globalEntries.length;
|
|
1155
|
-
localEntries = localEntries.filter(e => !e.superseded_by);
|
|
1156
|
-
globalEntries = globalEntries.filter(e => !e.superseded_by);
|
|
1157
|
-
droppedPreRankCountCmd += beforeSupersededDrop - (localEntries.length + globalEntries.length);
|
|
1158
|
-
}
|
|
1159
|
-
const hasGlobal = globalEntries.length > 0;
|
|
1160
|
-
// Determine search mode: --physics forces physics, --classic forces BM25+cosine,
|
|
1161
|
-
// default uses physics if config.physics.enabled is not false
|
|
1162
903
|
const config = loadConfig(hippoRoot);
|
|
1163
|
-
const usePhysics = forcePhysics
|
|
1164
|
-
|| (!forceClassic && config.physics.enabled !== false);
|
|
1165
|
-
const noMmr = Boolean(flags['no-mmr']);
|
|
1166
|
-
const mmrLambda = flags['mmr-lambda'] !== undefined
|
|
1167
|
-
? parseFloat(String(flags['mmr-lambda']))
|
|
1168
|
-
: config.mmr.lambda;
|
|
1169
|
-
const mmrEnabled = !noMmr && config.mmr.enabled;
|
|
1170
|
-
const localBump = flags['equal-sources']
|
|
1171
|
-
? 1.0
|
|
1172
|
-
: flags['local-bump'] !== undefined
|
|
1173
|
-
? parseFloat(String(flags['local-bump']))
|
|
1174
|
-
: config.search.localBump;
|
|
1175
904
|
const minResults = flags['min-results'] !== undefined
|
|
1176
905
|
? parseInt(String(flags['min-results']), 10)
|
|
1177
906
|
: undefined;
|
|
1178
|
-
// recallExplicitScope hoisted above the candidate loads (v1.25.0) — see the
|
|
1179
|
-
// scope-filter block near the top of cmdRecall.
|
|
1180
907
|
const recallActiveScope = recallExplicitScope || detectScope();
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
let graphStreamHops;
|
|
1188
|
-
if (useGraphStream && flags['graph-hops'] !== undefined) {
|
|
1189
|
-
if (typeof flags['graph-hops'] === 'boolean') {
|
|
1190
|
-
console.error(`--graph-hops requires an integer value 1..${MAX_HOPS} (e.g. --graph-hops 2).`);
|
|
1191
|
-
process.exit(1);
|
|
1192
|
-
}
|
|
1193
|
-
const h = Number(flags['graph-hops']);
|
|
1194
|
-
if (!Number.isInteger(h) || h < 1 || h > MAX_HOPS) {
|
|
1195
|
-
console.error(`Invalid --graph-hops: "${String(flags['graph-hops'])}". Must be an integer 1..${MAX_HOPS}.`);
|
|
1196
|
-
process.exit(1);
|
|
1197
|
-
}
|
|
1198
|
-
graphStreamHops = h;
|
|
1199
|
-
}
|
|
1200
|
-
let graphStreamSeeds;
|
|
1201
|
-
if (useGraphStream && flags['graph-seeds'] !== undefined) {
|
|
1202
|
-
if (typeof flags['graph-seeds'] === 'boolean') {
|
|
1203
|
-
console.error('--graph-seeds requires a positive integer value (e.g. --graph-seeds 10).');
|
|
1204
|
-
process.exit(1);
|
|
1205
|
-
}
|
|
1206
|
-
const s = Number(flags['graph-seeds']);
|
|
1207
|
-
if (!Number.isInteger(s) || s < 1) {
|
|
1208
|
-
console.error(`Invalid --graph-seeds: "${String(flags['graph-seeds'])}". Must be a positive integer.`);
|
|
1209
|
-
process.exit(1);
|
|
1210
|
-
}
|
|
1211
|
-
graphStreamSeeds = s;
|
|
1212
|
-
}
|
|
908
|
+
const graphStream = flags['graph-stream'] === true ? parseGraphStreamFlags(flags) : undefined;
|
|
909
|
+
const late = parseRecallLateFlags(flags);
|
|
910
|
+
const goalTag = flags['goal'] !== undefined ? String(flags['goal']).trim() : '';
|
|
911
|
+
const sessionId = (flags['session-id'] !== undefined
|
|
912
|
+
? String(flags['session-id'])
|
|
913
|
+
: process.env.HIPPO_SESSION_ID ?? '').trim();
|
|
1213
914
|
// Engines spend the budget on the text each result prints as, less the header, so selection and print agree.
|
|
1214
915
|
const localIndex = loadIndex(hippoRoot);
|
|
1215
916
|
const globalOn = isInitialized(globalRoot);
|
|
1216
917
|
const entryText = (r) => recallEntryText(r, query, showWhy, primaryIsGlobal || (globalOn && !localIndex.entries[r.entry.id]));
|
|
1217
918
|
const printCost = (r) => printedTokens(entryText(r));
|
|
1218
919
|
const entryBudget = Math.max(0, budget - printedTokens(recallHeading(budget, budget, query)));
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
graphStream: { weight: DEFAULT_GRAPH_STREAM_WEIGHT, tenantId, hops: graphStreamHops, seedCount: graphStreamSeeds },
|
|
1238
|
-
});
|
|
1239
|
-
}
|
|
1240
|
-
else if (useMultihop) {
|
|
1241
|
-
// Unlike searchBothHybrid below, multihop ranks one pooled list, so a shared memory's two copies both compete.
|
|
1242
|
-
const allEntries = api.oneCopyPerMemory(localEntries, globalEntries, evalNow()).flat();
|
|
1243
|
-
results = multihopSearch(query, allEntries, {
|
|
1244
|
-
budget: entryBudget,
|
|
1245
|
-
cost: printCost,
|
|
1246
|
-
hippoRoot,
|
|
1247
|
-
minResults,
|
|
1248
|
-
includeSuperseded,
|
|
1249
|
-
asOf,
|
|
1250
|
-
});
|
|
1251
|
-
}
|
|
1252
|
-
else if (usePhysics && !hasGlobal) {
|
|
1253
|
-
results = await physicsSearch(query, localEntries, {
|
|
1254
|
-
budget: entryBudget,
|
|
1255
|
-
cost: printCost,
|
|
1256
|
-
hippoRoot,
|
|
1257
|
-
physicsConfig: config.physics,
|
|
1258
|
-
minResults,
|
|
1259
|
-
scope: recallActiveScope,
|
|
1260
|
-
includeSuperseded,
|
|
1261
|
-
asOf,
|
|
1262
|
-
});
|
|
1263
|
-
}
|
|
1264
|
-
else if (hasGlobal) {
|
|
1265
|
-
// Use searchBothHybrid for merged results with embedding support.
|
|
1266
|
-
// recallScope (v1.25.0): searchBothHybrid re-loads candidates internally,
|
|
1267
|
-
// so the scope rule must be plumbed in — the filtered localEntries /
|
|
1268
|
-
// globalEntries above are NOT what this path ranks.
|
|
1269
|
-
results = await searchBothHybrid(query, hippoRoot, globalRoot, {
|
|
1270
|
-
budget: entryBudget, cost: printCost, mmr: mmrEnabled, mmrLambda, localBump, minResults, scope: recallActiveScope, tenantId,
|
|
1271
|
-
includeSuperseded, asOf,
|
|
1272
|
-
recallScope: recallExplicitScope
|
|
1273
|
-
? { requested: recallExplicitScope, additive: true }
|
|
1274
|
-
: {},
|
|
1275
|
-
});
|
|
1276
|
-
}
|
|
1277
|
-
else {
|
|
1278
|
-
results = await hybridSearch(query, localEntries, {
|
|
1279
|
-
budget: entryBudget, cost: printCost, hippoRoot, mmr: mmrEnabled, mmrLambda, minResults, scope: recallActiveScope,
|
|
1280
|
-
includeSuperseded, asOf,
|
|
1281
|
-
});
|
|
1282
|
-
}
|
|
1283
|
-
// E3.2 multi-hop graph recall. After the base branch produces `results`, optionally
|
|
1284
|
-
// augment with memories reached by walking the entities/relations graph `--hops N` out
|
|
1285
|
-
// from the lexical seeds. Runs BEFORE the opt-in re-rankers below so graph-reached
|
|
1286
|
-
// results are first-class candidates in any downstream re-ranking / --why. Default OFF
|
|
1287
|
-
// (absent or 0 = no-op). Reached memories are loaded directly by id (NOT via the
|
|
1288
|
-
// lexical candidate set, which would exclude the orthogonal neighbours graph recall
|
|
1289
|
-
// exists to surface); the engine re-applies the same superseded/asOf hard filters.
|
|
1290
|
-
if (flags['hops'] !== undefined) {
|
|
1291
|
-
// Reject a value-less `--hops` (parseArgs stores boolean true): Number(true) === 1
|
|
1292
|
-
// would otherwise silently run a 1-hop expansion when the user fat-fingered the value.
|
|
1293
|
-
if (typeof flags['hops'] === 'boolean') {
|
|
1294
|
-
console.error(`--hops requires an integer value 0..${MAX_HOPS} (e.g. --hops 1).`);
|
|
1295
|
-
process.exit(1);
|
|
1296
|
-
}
|
|
1297
|
-
const hops = Number(flags['hops']);
|
|
1298
|
-
if (!Number.isInteger(hops) || hops < 0 || hops > MAX_HOPS) {
|
|
1299
|
-
console.error(`Invalid --hops: "${String(flags['hops'])}". Must be an integer 0..${MAX_HOPS}.`);
|
|
1300
|
-
process.exit(1);
|
|
1301
|
-
}
|
|
1302
|
-
let maxNeighbors = DEFAULT_MAX_NEIGHBORS;
|
|
1303
|
-
if (flags['max-neighbors'] !== undefined) {
|
|
1304
|
-
if (typeof flags['max-neighbors'] === 'boolean') {
|
|
1305
|
-
console.error(`--max-neighbors requires an integer value 1..200.`);
|
|
1306
|
-
process.exit(1);
|
|
1307
|
-
}
|
|
1308
|
-
maxNeighbors = Number(flags['max-neighbors']);
|
|
1309
|
-
if (!Number.isInteger(maxNeighbors) || maxNeighbors < 1 || maxNeighbors > 200) {
|
|
1310
|
-
console.error(`Invalid --max-neighbors: "${String(flags['max-neighbors'])}". Must be an integer 1..200.`);
|
|
1311
|
-
process.exit(1);
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
if (hops > 0) {
|
|
1315
|
-
// graphExpandRecall can SURFACE rows that were never in the lexical
|
|
1316
|
-
// candidate pool (a graph neighbour reached by entity edge, not by
|
|
1317
|
-
// query match), and totalCandidatesCountCmd was snapshotted before the
|
|
1318
|
-
// search. Without this the derived budget count goes negative, clamps
|
|
1319
|
-
// to 0, and the accounting silently breaks: 1 candidate, 2 returned,
|
|
1320
|
-
// 0 drops. Found independently by two reviewers. Count the additions
|
|
1321
|
-
// so the invariant holds on graph-expanded recalls too.
|
|
1322
|
-
// GROSS, not net. graphExpandRecall both adds neighbours AND evicts weak
|
|
1323
|
-
// base rows in one call (graph-recall.ts:285), so a net delta of 0 hides
|
|
1324
|
-
// 3 added + 3 evicted: the additions escape the candidate total and the
|
|
1325
|
-
// evictions escape the drop count, and the Cutoff line goes silent again.
|
|
1326
|
-
// Compare ID sets so both directions are counted.
|
|
1327
|
-
const beforeGraphIds = new Set(results.map((r) => r.entry.id));
|
|
1328
|
-
results = graphExpandRecall(results, {
|
|
1329
|
-
hops,
|
|
1330
|
-
maxNeighbors,
|
|
1331
|
-
hippoRoot,
|
|
1332
|
-
globalRoot: isInitialized(globalRoot) && globalRoot !== hippoRoot ? globalRoot : undefined,
|
|
1333
|
-
tenantId,
|
|
1334
|
-
includeSuperseded,
|
|
1335
|
-
asOf,
|
|
1336
|
-
budget: entryBudget,
|
|
1337
|
-
cost: printCost,
|
|
1338
|
-
minResults: minResults ?? 1,
|
|
1339
|
-
recallScope: recallExplicitScope
|
|
1340
|
-
? { requested: recallExplicitScope, additive: true }
|
|
1341
|
-
: {},
|
|
1342
|
-
});
|
|
1343
|
-
// Rows the graph surfaced that the lexical pool never held.
|
|
1344
|
-
for (const r of results) {
|
|
1345
|
-
if (!beforeGraphIds.has(r.entry.id))
|
|
1346
|
-
graphAddedCountCmd++;
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
// ACC EVC-adaptive recall (RESEARCH.md §PFC.ACC). When the initial top-K is
|
|
1351
|
-
// dominated by lexically similar but distinct memories (high pairwise token
|
|
1352
|
-
// overlap = same topic, different facts = conflict), allocate extra retrieval
|
|
1353
|
-
// effort: take a wider candidate pool, drop low-relevance distractors, and
|
|
1354
|
-
// re-rank by recency to surface the most up-to-date item from the cluster.
|
|
1355
|
-
// Default off; opt-in via --evc-adaptive.
|
|
1356
|
-
if (flags['evc-adaptive'] && results.length >= 2) {
|
|
1357
|
-
const sliceSize = Math.min(3, results.length);
|
|
1358
|
-
const slice = results.slice(0, sliceSize);
|
|
1359
|
-
let pairs = 0;
|
|
1360
|
-
let overlapSum = 0;
|
|
1361
|
-
for (let i = 0; i < slice.length; i++) {
|
|
1362
|
-
for (let j = i + 1; j < slice.length; j++) {
|
|
1363
|
-
overlapSum += textOverlap(slice[i].entry.content, slice[j].entry.content);
|
|
1364
|
-
pairs++;
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
const avgOverlap = pairs > 0 ? overlapSum / pairs : 0;
|
|
1368
|
-
if (avgOverlap >= 0.4) {
|
|
1369
|
-
const poolSize = Math.min(results.length, Math.max(sliceSize * 3, 9));
|
|
1370
|
-
const pool = results.slice(0, poolSize);
|
|
1371
|
-
const tail = results.slice(poolSize);
|
|
1372
|
-
const maxScore = pool.reduce((m, r) => Math.max(m, r.score), 0);
|
|
1373
|
-
const scoreFloor = maxScore * 0.5;
|
|
1374
|
-
// On-topic test: score floor OR query coverage. The score floor alone
|
|
1375
|
-
// proxied topicality via ranking score, but the disambiguating update
|
|
1376
|
-
// this mechanic exists to surface is BY NATURE phrased differently
|
|
1377
|
-
// (weaker lexical/embedding overlap), so under the #t2 embed-text
|
|
1378
|
-
// format (docs/plans/2026-07-09-recall-determinism.md T1, which
|
|
1379
|
-
// de-compressed similarity gaps) it fell below any sane floor
|
|
1380
|
-
// (measured 0.33x max on the acc-evc micro fixture). Query coverage —
|
|
1381
|
-
// the fraction of query tokens present in the candidate — is the
|
|
1382
|
-
// mechanic's own definition of "same topic, different fact" applied
|
|
1383
|
-
// to the query, and is score-scale-independent. Principled EVC
|
|
1384
|
-
// calibration remains roadmapped as B1 depth.
|
|
1385
|
-
const queryTokens = new Set(tokenizeQuery(query));
|
|
1386
|
-
const onTopic = [];
|
|
1387
|
-
const offTopic = [];
|
|
1388
|
-
for (const r of pool) {
|
|
1389
|
-
let hits = 0;
|
|
1390
|
-
if (queryTokens.size > 0) {
|
|
1391
|
-
const candTokens = new Set(tokenizeQuery(r.entry.content));
|
|
1392
|
-
for (const t of queryTokens)
|
|
1393
|
-
if (candTokens.has(t))
|
|
1394
|
-
hits++;
|
|
1395
|
-
}
|
|
1396
|
-
const queryCoverage = queryTokens.size > 0 ? hits / queryTokens.size : 0;
|
|
1397
|
-
(r.score >= scoreFloor || queryCoverage >= 0.6 ? onTopic : offTopic).push(r);
|
|
1398
|
-
}
|
|
1399
|
-
// Recency is the true primary key (unchanged) — that's the whole
|
|
1400
|
-
// point of --evc-adaptive. compareEntryIdentity is only a TAIL for
|
|
1401
|
-
// entries created at the exact same timestamp, which previously fell
|
|
1402
|
-
// to array/scan order (T2, deterministic tie keys).
|
|
1403
|
-
onTopic.sort((a, b) => {
|
|
1404
|
-
const ta = new Date(a.entry.created).getTime();
|
|
1405
|
-
const tb = new Date(b.entry.created).getTime();
|
|
1406
|
-
return tb !== ta ? tb - ta : compareEntryIdentity(a.entry, b.entry);
|
|
1407
|
-
});
|
|
1408
|
-
results = [...onTopic, ...offTopic, ...tail];
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
// vlPFC interference filter (RESEARCH.md §PFC.vlPFC). Suppress task-irrelevant
|
|
1412
|
-
// memories using *recorded* supersession + conflict structure only. Default
|
|
1413
|
-
// off; opt-in via --filter-conflicts. Two effects, both surgical:
|
|
1414
|
-
// 1. Drop entries with `superseded_by` set. (No-op under default recall,
|
|
1415
|
-
// which already filters them; matters when `--include-superseded` was
|
|
1416
|
-
// passed. The flag re-asserts the gate.)
|
|
1417
|
-
// 2. Apply a 0.3x score multiplier to entries whose `conflicts_with` list
|
|
1418
|
-
// references another entry that ALSO appears in the result set. The
|
|
1419
|
-
// multiplier is conservative — we never delete on conflict, only
|
|
1420
|
-
// down-rank, so the user can still surface the loser via --include-*.
|
|
1421
|
-
// We never infer conflicts from lexical overlap. The v1 salience gate did
|
|
1422
|
-
// that and destroyed LoCoMo (0.28 → 0.02). Recorded structure only.
|
|
1423
|
-
if (flags['filter-conflicts']) {
|
|
1424
|
-
const beforeFilterConflicts = results.length;
|
|
1425
|
-
results = results.filter((r) => !r.entry.superseded_by);
|
|
1426
|
-
droppedPreRankCountCmd += beforeFilterConflicts - results.length;
|
|
1427
|
-
const presentIds = new Set(results.map((r) => r.entry.id));
|
|
1428
|
-
results = results.map((r) => {
|
|
1429
|
-
const peers = r.entry.conflicts_with || [];
|
|
1430
|
-
const hasPeerInResults = peers.some((peerId) => presentIds.has(peerId));
|
|
1431
|
-
if (!hasPeerInResults)
|
|
1432
|
-
return r;
|
|
1433
|
-
const next = { ...r, score: r.score * 0.3 };
|
|
1434
|
-
// A7 recall-trace: interference (vlPFC) down-rank. Only when --why.
|
|
1435
|
-
if (showWhy) {
|
|
1436
|
-
next.rerankTrace = [
|
|
1437
|
-
...(r.rerankTrace ?? []),
|
|
1438
|
-
{ stage: 'interference', multiplier: 0.3, scoreBefore: r.score, scoreAfter: next.score },
|
|
1439
|
-
];
|
|
1440
|
-
}
|
|
1441
|
-
return next;
|
|
1442
|
-
});
|
|
1443
|
-
// T2 note: PLAIN stable score sort on purpose (here and in the rerank
|
|
1444
|
-
// blocks below) -- these are RE-SORTS of an already deterministically-
|
|
1445
|
-
// ordered ranking, so sort stability inherits the upstream content-tail
|
|
1446
|
-
// determinism, and ties preserve the prior rank rather than reordering
|
|
1447
|
-
// by content (a no-signal boost must not shuffle its input).
|
|
1448
|
-
results.sort((a, b) => b.score - a.score);
|
|
1449
|
-
}
|
|
1450
|
-
// vmPFC continuous value attribution (RESEARCH.md §PFC.vmPFC). Continuous
|
|
1451
|
-
// value scoring per memory based on cumulative outcome attribution. Memories
|
|
1452
|
-
// with positive cumulative outcomes are boosted; those with negative outcomes
|
|
1453
|
-
// are demoted. The multiplier is a tanh-shaped function clamped to [0.7, 1.3]
|
|
1454
|
-
// — wider than the always-on outcomeBoost (which clamps [0.85, 1.15]) so this
|
|
1455
|
-
// flag has additional decisive effect when value attribution should drive
|
|
1456
|
-
// ranking. Default off; opt-in via --value-aware. Reuses outcome_positive /
|
|
1457
|
-
// outcome_negative columns; no schema change.
|
|
1458
|
-
if (flags['value-aware'] && results.length >= 1) {
|
|
1459
|
-
results = results.map((r) => {
|
|
1460
|
-
const pos = r.entry.outcome_positive ?? 0;
|
|
1461
|
-
const neg = r.entry.outcome_negative ?? 0;
|
|
1462
|
-
if (pos === 0 && neg === 0)
|
|
1463
|
-
return r;
|
|
1464
|
-
const raw = 1 + 0.3 * Math.tanh(pos - neg);
|
|
1465
|
-
const valueMult = Math.max(0.7, Math.min(1.3, raw));
|
|
1466
|
-
const next = { ...r, score: r.score * valueMult };
|
|
1467
|
-
// A7 recall-trace: vmPFC continuous value attribution. Only when --why.
|
|
1468
|
-
if (showWhy) {
|
|
1469
|
-
next.rerankTrace = [
|
|
1470
|
-
...(r.rerankTrace ?? []),
|
|
1471
|
-
{ stage: 'value', multiplier: valueMult, scoreBefore: r.score, scoreAfter: next.score },
|
|
1472
|
-
];
|
|
1473
|
-
}
|
|
1474
|
-
return next;
|
|
1475
|
-
});
|
|
1476
|
-
results.sort((a, b) => b.score - a.score); // T2: plain stable re-sort, see --filter-conflicts note
|
|
1477
|
-
}
|
|
1478
|
-
// OFC option-value re-ranker MVP (RESEARCH.md §PFC.OFC). Combine relevance,
|
|
1479
|
-
// strength, and integration cost into a single utility score and re-sort.
|
|
1480
|
-
// OFC neurons encode a "common currency" across heterogeneous attributes
|
|
1481
|
-
// (Rangel et al., 2008); this is the simplest demonstration of that mechanism.
|
|
1482
|
-
// Default off; opt-in via --rerank-utility.
|
|
1483
|
-
//
|
|
1484
|
-
// utility = score * (0.5 + 0.5 * strength) * (1 - cost_factor)
|
|
1485
|
-
// cost_factor = min(0.3, tokens / 10000)
|
|
1486
|
-
//
|
|
1487
|
-
// The full OFC spec (option_valuation table in RESEARCH.md) decomposes value
|
|
1488
|
-
// into reward / cost / risk / confidence components. The MVP collapses these
|
|
1489
|
-
// to: score (relevance proxy), strength (persistence proxy), tokens (cost).
|
|
1490
|
-
// CAVEAT: cost penalty is monotone with token count; LoCoMo's harder QAs
|
|
1491
|
-
// often live in long evidence-rich memories. Default off — needs LoCoMo
|
|
1492
|
-
// eval before enabling broadly.
|
|
1493
|
-
if (flags['rerank-utility']) {
|
|
1494
|
-
results = results
|
|
1495
|
-
.map((r) => {
|
|
1496
|
-
const strength = typeof r.entry.strength === 'number' ? r.entry.strength : 1.0;
|
|
1497
|
-
const costFactor = Math.min(0.3, (r.tokens || 0) / 10000);
|
|
1498
|
-
const utilityMult = (0.5 + 0.5 * strength) * (1 - costFactor);
|
|
1499
|
-
const utility = r.score * utilityMult;
|
|
1500
|
-
const next = { ...r, score: utility };
|
|
1501
|
-
// A7 recall-trace: OFC option-value re-rank. Only when --why.
|
|
1502
|
-
if (showWhy) {
|
|
1503
|
-
next.rerankTrace = [
|
|
1504
|
-
...(r.rerankTrace ?? []),
|
|
1505
|
-
{ stage: 'utility', multiplier: utilityMult, scoreBefore: r.score, scoreAfter: utility },
|
|
1506
|
-
];
|
|
1507
|
-
}
|
|
1508
|
-
return next;
|
|
1509
|
-
})
|
|
1510
|
-
.sort((a, b) => b.score - a.score); // T2: plain stable re-sort, see --filter-conflicts note
|
|
1511
|
-
}
|
|
1512
|
-
// F6 reranker pass (docs/plans/2026-05-10-f6-reranker-hardening.md). When
|
|
1513
|
-
// --reranker <name> is set, look up the reranker fn from the registry
|
|
1514
|
-
// (src/rerankers/index.ts) and apply it to the top-K candidates. The
|
|
1515
|
-
// reranker reorders (and may rescale) results; the post-budget set is
|
|
1516
|
-
// returned. Default off; opt-in via --reranker <cross-encoder|jev|llm>. The
|
|
1517
|
-
// structurally similar --rerank-utility block above is the OFC MVP and is
|
|
1518
|
-
// independent — both can run in the same recall, with --rerank-utility
|
|
1519
|
-
// applied first. Available rerankers: cross-encoder, jev, llm (see
|
|
1520
|
-
// src/rerankers/index.ts). The Track 1 `features` reranker was removed in
|
|
1521
|
-
// v1.9.1 per the F10 HARD RETRACTION; it is no longer a valid value.
|
|
1522
|
-
const rerankerName = flags['reranker'] !== undefined ? String(flags['reranker']).trim() : '';
|
|
1523
|
-
if (rerankerName) {
|
|
1524
|
-
const rerankerFn = getReranker(rerankerName);
|
|
1525
|
-
if (rerankerFn) {
|
|
1526
|
-
const topK = flags['reranker-top-k'] !== undefined
|
|
1527
|
-
? parseInt(String(flags['reranker-top-k']), 10)
|
|
1528
|
-
: rerankerName === 'jev' ? JEV_DEFAULT_TOP_K : 50;
|
|
1529
|
-
const head = results.slice(0, topK);
|
|
1530
|
-
const tail = results.slice(topK);
|
|
1531
|
-
const rerankInput = head.map((r, i) => ({ ...r, preRerankRank: i + 1 }));
|
|
1532
|
-
const reranked = await rerankerFn(query, rerankInput, { topK });
|
|
1533
|
-
// Copy rerankScore into score so downstream blocks (--goal, goal-stack,
|
|
1534
|
-
// salience) that sort by `r.score` honor the reranker's order rather
|
|
1535
|
-
// than unwinding it. Original score is preserved on rerankScore's
|
|
1536
|
-
// input, but downstream sorters key on `score`.
|
|
1537
|
-
const withPostRank = reranked.map((r, i) => {
|
|
1538
|
-
const next = {
|
|
1539
|
-
...r,
|
|
1540
|
-
score: r.rerankScore,
|
|
1541
|
-
postRerankRank: i + 1,
|
|
1542
|
-
};
|
|
1543
|
-
// A7 recall-trace: F6 reranker pass (reorder + rescale; not a scalar
|
|
1544
|
-
// multiply, so no multiplier field). Only when --why.
|
|
1545
|
-
if (showWhy) {
|
|
1546
|
-
next.rerankTrace = [
|
|
1547
|
-
...(r.rerankTrace ?? []),
|
|
1548
|
-
{ stage: 'reranker', scoreBefore: r.score, scoreAfter: r.rerankScore },
|
|
1549
|
-
];
|
|
1550
|
-
}
|
|
1551
|
-
return next;
|
|
1552
|
-
});
|
|
1553
|
-
results = [...withPostRank, ...tail];
|
|
1554
|
-
}
|
|
1555
|
-
}
|
|
1556
|
-
// dlPFC goal-conditioned recall MVP (RESEARCH.md §PFC.dlPFC). When --goal
|
|
1557
|
-
// <tag> is set, memories whose `tags` array contains the goal tag receive
|
|
1558
|
-
// a 1.5x score boost and results are re-sorted. The full dlPFC spec
|
|
1559
|
-
// (goal_stack + retrieval_policy tables) maintains a hierarchical task
|
|
1560
|
-
// stack with weighted retrieval policies; this MVP collapses that to a
|
|
1561
|
-
// single-tag boost — the smallest demonstrable goal-conditioning signal.
|
|
1562
|
-
// Default off; opt-in via --goal <tag>. No schema change.
|
|
1563
|
-
const goalTag = flags['goal'] !== undefined ? String(flags['goal']).trim() : '';
|
|
1564
|
-
if (goalTag) {
|
|
1565
|
-
results = results
|
|
1566
|
-
.map((r) => {
|
|
1567
|
-
if (!r.entry.tags?.includes(goalTag))
|
|
1568
|
-
return r;
|
|
1569
|
-
const boosted = { ...r, score: r.score * 1.5 };
|
|
1570
|
-
// A7 recall-trace: the explicit `--goal <tag>` boost is a public CLI
|
|
1571
|
-
// re-ranker (score *= 1.5) and is mutually exclusive with the session
|
|
1572
|
-
// goal-stack boost below, so it must record its OWN step or `--why
|
|
1573
|
-
// --goal` produces no ranking line (codex review). Stage `goal`
|
|
1574
|
-
// (explicit flag) is distinct from `goal-boost` (session stack).
|
|
1575
|
-
if (showWhy) {
|
|
1576
|
-
boosted.rerankTrace = [
|
|
1577
|
-
...(r.rerankTrace ?? []),
|
|
1578
|
-
{ stage: 'goal', multiplier: 1.5, scoreBefore: r.score, scoreAfter: r.score * 1.5, note: `--goal ${goalTag}` },
|
|
1579
|
-
];
|
|
1580
|
-
}
|
|
1581
|
-
return boosted;
|
|
1582
|
-
})
|
|
1583
|
-
.sort((a, b) => b.score - a.score); // T2: plain stable re-sort, see --filter-conflicts note
|
|
1584
|
-
}
|
|
1585
|
-
// dlPFC depth (B3, v0.38; lifted v1.7.4 into applyGoalStackBoost). When
|
|
1586
|
-
// HIPPO_SESSION_ID is set (env or --session-id flag) and the
|
|
1587
|
-
// (tenant, session) has active goals, the helper boosts memories whose tags
|
|
1588
|
-
// overlap any active goal's name and logs (memory, goal) pairs into
|
|
1589
|
-
// goal_recall_log. Runs AFTER the explicit `--goal <tag>` block so an
|
|
1590
|
-
// explicit flag always wins (gated on `goalTag === ''`).
|
|
1591
|
-
const sessionId = (flags['session-id'] !== undefined
|
|
1592
|
-
? String(flags['session-id'])
|
|
1593
|
-
: process.env.HIPPO_SESSION_ID ?? '').trim();
|
|
1594
|
-
if (sessionId && goalTag === '') {
|
|
920
|
+
const rank = await rankRecall({ hippoRoot, globalRoot: globalRoot !== hippoRoot && globalOn ? globalRoot : undefined, tenantId, note: (line) => console.error(line) }, {
|
|
921
|
+
query, budget: entryBudget, cost: printCost, limit, why: showWhy, includeSuperseded, asOf,
|
|
922
|
+
explicitScope: recallExplicitScope, activeScope: recallActiveScope,
|
|
923
|
+
search: { ...engineFlags(flags, config), multihop: flags['multihop'] === true || config.multihop.enabled, graphStream, minResults, explain: false },
|
|
924
|
+
graphHops: late.graphHops,
|
|
925
|
+
evcAdaptive: Boolean(flags['evc-adaptive']),
|
|
926
|
+
filterConflicts: Boolean(flags['filter-conflicts']),
|
|
927
|
+
valueAware: Boolean(flags['value-aware']),
|
|
928
|
+
rerankUtility: Boolean(flags['rerank-utility']),
|
|
929
|
+
reranker: late.reranker,
|
|
930
|
+
goalTag,
|
|
931
|
+
sessionId,
|
|
932
|
+
salienceThreshold: late.salienceThreshold,
|
|
933
|
+
outcome: late.outcome,
|
|
934
|
+
layer: late.layer,
|
|
935
|
+
haltBefore: late.error?.stage,
|
|
936
|
+
});
|
|
937
|
+
if (rank.goalRecallLog.length > 0) {
|
|
1595
938
|
const dbForGoals = openHippoDb(hippoRoot);
|
|
1596
|
-
// A7 recall-trace: goal-boost is the shared helper, not an inline map. It
|
|
1597
|
-
// writes its steps into this SEPARATE accumulator (keyed by entry id), NOT
|
|
1598
|
-
// onto the row (the helper strips internal markers on re-spread). Only
|
|
1599
|
-
// allocated under --why.
|
|
1600
|
-
const goalBoostTrace = showWhy ? new Map() : undefined;
|
|
1601
939
|
try {
|
|
1602
|
-
|
|
1603
|
-
sessionId,
|
|
1604
|
-
tenantId,
|
|
1605
|
-
limit,
|
|
1606
|
-
...(goalBoostTrace ? { trace: goalBoostTrace } : {}),
|
|
1607
|
-
});
|
|
940
|
+
writeGoalRecallLog(dbForGoals, rank.goalRecallLog);
|
|
1608
941
|
}
|
|
1609
942
|
finally {
|
|
1610
943
|
closeHippoDb(dbForGoals);
|
|
1611
944
|
}
|
|
1612
|
-
// Merge the accumulated goal-boost steps onto the matching SearchResult.
|
|
1613
|
-
if (goalBoostTrace && goalBoostTrace.size > 0) {
|
|
1614
|
-
results = results.map((r) => {
|
|
1615
|
-
const step = goalBoostTrace.get(r.entry.id);
|
|
1616
|
-
if (!step)
|
|
1617
|
-
return r;
|
|
1618
|
-
return { ...r, rerankTrace: [...(r.rerankTrace ?? []), step] };
|
|
1619
|
-
});
|
|
1620
|
-
}
|
|
1621
|
-
}
|
|
1622
|
-
// Pineal salience MVP (RESEARCH.md §"AI Pineal Gland — Intuition and Awareness
|
|
1623
|
-
// Module"). When --salience-threshold T is set (T > 0), memories whose
|
|
1624
|
-
// retrieval_count is below T are downweighted: score *= max(0.5, count / T).
|
|
1625
|
-
// At or above T, no change. This makes salience emerge from USE — high-recall
|
|
1626
|
-
// memories earn full ranking weight, low-recall memories are softly demoted.
|
|
1627
|
-
//
|
|
1628
|
-
// CRITICAL HISTORY: The v1 salience gate (60% lexical-overlap gate at memory
|
|
1629
|
-
// CREATION time) destroyed LoCoMo recall (0.28 -> 0.02) by dropping same-
|
|
1630
|
-
// session relevant turns at intake. See MEMORY.md "Hippo salience gate
|
|
1631
|
-
// destroys benchmark recall". This v2 is the inverse:
|
|
1632
|
-
// - retrieval-side only (no creation-time gating)
|
|
1633
|
-
// - retrieval_count signal only (no lexical overlap, no novelty heuristic)
|
|
1634
|
-
// - default OFF, opt-in via the flag (no behaviour change without it)
|
|
1635
|
-
// - 0.5 floor so non-salient entries stay reachable, never dropped
|
|
1636
|
-
// Reuses the existing retrieval_count column; no schema change.
|
|
1637
|
-
const salienceThresholdRaw = flags['salience-threshold'];
|
|
1638
|
-
if (salienceThresholdRaw !== undefined) {
|
|
1639
|
-
const T = Number(salienceThresholdRaw);
|
|
1640
|
-
if (!Number.isFinite(T) || T <= 0) {
|
|
1641
|
-
console.error(`Invalid --salience-threshold: "${salienceThresholdRaw}". Must be a positive number.`);
|
|
1642
|
-
process.exit(1);
|
|
1643
|
-
}
|
|
1644
|
-
results = results
|
|
1645
|
-
.map((r) => {
|
|
1646
|
-
const count = r.entry.retrieval_count ?? 0;
|
|
1647
|
-
if (count >= T)
|
|
1648
|
-
return r;
|
|
1649
|
-
const mult = Math.max(0.5, count / T);
|
|
1650
|
-
const next = { ...r, score: r.score * mult };
|
|
1651
|
-
// A7 recall-trace: pineal salience (retrieval_count) down-weight. The
|
|
1652
|
-
// stage names the ACTUAL transform (retrieval_count, not goal-stack).
|
|
1653
|
-
// Only when --why.
|
|
1654
|
-
if (showWhy) {
|
|
1655
|
-
next.rerankTrace = [
|
|
1656
|
-
...(r.rerankTrace ?? []),
|
|
1657
|
-
{ stage: 'retrieval-count-downweight', multiplier: mult, scoreBefore: r.score, scoreAfter: next.score },
|
|
1658
|
-
];
|
|
1659
|
-
}
|
|
1660
|
-
return next;
|
|
1661
|
-
})
|
|
1662
|
-
.sort((a, b) => b.score - a.score); // T2: plain stable re-sort, see --filter-conflicts note
|
|
1663
|
-
}
|
|
1664
|
-
// --outcome filter: drop trace entries whose trace_outcome !== target.
|
|
1665
|
-
// Non-trace entries pass through unaffected (traces are the only layer with
|
|
1666
|
-
// a meaningful outcome; filtering non-traces by outcome would be incoherent).
|
|
1667
|
-
const outcomeFilter = flags['outcome'] !== undefined ? String(flags['outcome']).trim() : '';
|
|
1668
|
-
if (outcomeFilter) {
|
|
1669
|
-
const validOutcomes = ['success', 'failure', 'partial'];
|
|
1670
|
-
if (!validOutcomes.includes(outcomeFilter)) {
|
|
1671
|
-
console.error(`Invalid --outcome: "${outcomeFilter}". Must be one of: ${validOutcomes.join(', ')}.`);
|
|
1672
|
-
process.exit(1);
|
|
1673
|
-
}
|
|
1674
|
-
const beforeOutcomeFilter = results.length;
|
|
1675
|
-
results = results.filter((r) => {
|
|
1676
|
-
if (r.entry.layer !== Layer.Trace)
|
|
1677
|
-
return true;
|
|
1678
|
-
return r.entry.trace_outcome === outcomeFilter;
|
|
1679
|
-
});
|
|
1680
|
-
droppedPreRankCountCmd += beforeOutcomeFilter - results.length;
|
|
1681
|
-
}
|
|
1682
|
-
// --layer filter: strict, drops entries whose layer does not match.
|
|
1683
|
-
const layerFilter = flags['layer'] !== undefined ? String(flags['layer']).trim() : '';
|
|
1684
|
-
if (layerFilter) {
|
|
1685
|
-
const validLayers = Object.values(Layer);
|
|
1686
|
-
if (!validLayers.includes(layerFilter)) {
|
|
1687
|
-
console.error(`Invalid --layer: "${layerFilter}". Must be one of: ${validLayers.join(', ')}.`);
|
|
1688
|
-
process.exit(1);
|
|
1689
|
-
}
|
|
1690
|
-
const beforeLayerFilter = results.length;
|
|
1691
|
-
results = results.filter((r) => r.entry.layer === layerFilter);
|
|
1692
|
-
droppedPreRankCountCmd += beforeLayerFilter - results.length;
|
|
1693
|
-
}
|
|
1694
|
-
// v1.12.13 / C5 — WYSIATI dropped_by_budget counter. Apply the final
|
|
1695
|
-
// `--limit` slice first, then derive the count ARITHMETICALLY as
|
|
1696
|
-
// "everything lost that droppedPreRank did not already claim":
|
|
1697
|
-
//
|
|
1698
|
-
// droppedByBudget = totalCandidates - droppedPreRank - returned
|
|
1699
|
-
//
|
|
1700
|
-
// This is the invariant the plan requires (totalCandidates == droppedPreRank
|
|
1701
|
-
// + droppedByBudget + returned) restated as an assignment, so it holds by
|
|
1702
|
-
// construction rather than by two counters happening to agree. It also
|
|
1703
|
-
// cannot double-count the post-search droppedPreRank sites (--filter-
|
|
1704
|
-
// conflicts, --outcome, --layer, ~1270/1528/1541): those are subtracted
|
|
1705
|
-
// once here, not re-counted, because this line does not re-walk any filter
|
|
1706
|
-
// — it only compares the two totals already tracked above. Everything left
|
|
1707
|
-
// over — search-engine internal rank-step drops AND the `--limit` slice
|
|
1708
|
-
// itself — lands in droppedByBudget, per the C5 accounting change in the
|
|
1709
|
-
// comment near line ~976. Clamped at 0 as a defensive floor: if a future
|
|
1710
|
-
// pipeline change ever returns MORE rows than totalCandidates minus
|
|
1711
|
-
// droppedPreRank (should not happen), report "nothing dropped" rather than
|
|
1712
|
-
// a negative count.
|
|
1713
|
-
if (limit < results.length) {
|
|
1714
|
-
results = results.slice(0, limit);
|
|
1715
945
|
}
|
|
946
|
+
late.error?.fail();
|
|
947
|
+
const { localEntries, globalEntries, totalCandidates: totalCandidatesCountCmd, droppedPreRank: droppedPreRankCountCmd, graphAdded: graphAddedCountCmd, } = rank;
|
|
948
|
+
let results = rank.results;
|
|
1716
949
|
// Continuity assembly (--continuity). Lives BEFORE the zero-result branch
|
|
1717
950
|
// so a no-match query with active continuity state still returns a useful
|
|
1718
951
|
// resume packet. Same three tenant-scoped store helpers as api.recall.
|
|
@@ -1768,11 +1001,11 @@ async function cmdRecall(hippoRoot, query, flags) {
|
|
|
1768
1001
|
results = shown(kept);
|
|
1769
1002
|
// J1, J2 and C5: each pipeline computes its hints over the list it returns, so they follow the list as it shrinks.
|
|
1770
1003
|
// HIPPO_ANCHORING=off and HIPPO_AVAILABILITY=off skip the work entirely.
|
|
1771
|
-
const anchorRing =
|
|
1004
|
+
const anchorRing = biasHintEnabled('anchoring') && sessionId
|
|
1772
1005
|
? getOrCreateRing(sessionRecallHistoryCli, buildSessionKey(tenantId, sessionId))
|
|
1773
1006
|
: null;
|
|
1774
1007
|
const queryHash = hashQueryText(query);
|
|
1775
|
-
const availabilityPool =
|
|
1008
|
+
const availabilityPool = biasHintEnabled('availability')
|
|
1776
1009
|
? [...localEntries, ...globalEntries].map((e) => ({ id: e.id, created: e.created }))
|
|
1777
1010
|
: null;
|
|
1778
1011
|
const hintsFor = (list, held) => {
|
|
@@ -1852,7 +1085,7 @@ async function cmdRecall(hippoRoot, query, flags) {
|
|
|
1852
1085
|
// Appended after every detect: anchoredOn feeds the cooldown for the next recall on this session.
|
|
1853
1086
|
appendRecall(anchorRing, queryHash, results[0]?.entry.id ?? null, cmdAnchoringHint?.memoryId);
|
|
1854
1087
|
}
|
|
1855
|
-
else if (
|
|
1088
|
+
else if (biasHintEnabled('anchoring')) {
|
|
1856
1089
|
// SHA-256/16 per the recall-audit convention; hashQueryText is FNV-1a and brute-forceable on short queries.
|
|
1857
1090
|
emitCliAudit(hippoRoot, 'recall_anchor_skipped_no_session', undefined, auditQueryFields(query));
|
|
1858
1091
|
}
|
|
@@ -2039,131 +1272,49 @@ async function cmdRecall(hippoRoot, query, flags) {
|
|
|
2039
1272
|
}
|
|
2040
1273
|
emit(recallText);
|
|
2041
1274
|
}
|
|
1275
|
+
/** The SQL predicate drops denied rows before the window, so an unscoped probe counts what the policy hides. */
|
|
1276
|
+
function noteScopeHidden(hippoRoot, globalRoot, query, tenantId, requested) {
|
|
1277
|
+
const probe = [
|
|
1278
|
+
...loadSearchEntries(hippoRoot, query, undefined, tenantId),
|
|
1279
|
+
...(globalRoot ? loadSearchEntries(globalRoot, query, undefined, tenantId) : []),
|
|
1280
|
+
];
|
|
1281
|
+
// Window-capped, so the count is a floor on large stores; fine for a "why is my row missing" hint.
|
|
1282
|
+
const hidden = probe.filter((e) => !api.passesCliRecallScopeFilter(e.scope ?? null, requested)).length;
|
|
1283
|
+
if (hidden > 0) {
|
|
1284
|
+
console.error(`[note] ${hidden} candidate${hidden === 1 ? '' : 's'} hidden by recall scope policy (pass an explicit --scope to inspect).`);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
2042
1287
|
async function cmdExplain(hippoRoot, query, flags) {
|
|
2043
1288
|
requireInit(hippoRoot);
|
|
2044
1289
|
const budget = parseBudgetFlag(flags['budget'], 4000);
|
|
2045
1290
|
const limit = parseLimitFlag(flags['limit']);
|
|
2046
1291
|
const asJson = Boolean(flags['json']);
|
|
2047
|
-
const
|
|
2048
|
-
const
|
|
2049
|
-
const explainIncludeSuperseded = Boolean(flags['include-superseded']);
|
|
2050
|
-
const explainAsOf = typeof flags['as-of'] === 'string' ? flags['as-of'] : undefined;
|
|
2051
|
-
if (explainAsOf !== undefined && Number.isNaN(new Date(explainAsOf).getTime())) {
|
|
2052
|
-
console.error(`Error: --as-of value "${explainAsOf}" is not a valid ISO date (e.g. 2026-04-22 or 2026-04-22T12:00:00Z).`);
|
|
2053
|
-
process.exit(1);
|
|
2054
|
-
}
|
|
1292
|
+
const includeSuperseded = Boolean(flags['include-superseded']);
|
|
1293
|
+
const asOf = parseAsOfFlag(flags);
|
|
2055
1294
|
const globalRoot = getGlobalRoot();
|
|
2056
|
-
// A5: scope explain results to the active tenant.
|
|
2057
1295
|
const tenantId = resolveTenantId({});
|
|
2058
|
-
//
|
|
2059
|
-
|
|
2060
|
-
// recall
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
const explainRequestedScope = explainExplicitScope || undefined;
|
|
2064
|
-
const explainLoadSuperseded = explainIncludeSuperseded || Boolean(explainAsOf);
|
|
2065
|
-
let explainLocalEntries = loadRecallSearchEntries(hippoRoot, query, undefined, tenantId, explainRequestedScope, 'additive', explainLoadSuperseded);
|
|
2066
|
-
let explainGlobalEntries = isInitialized(globalRoot) ? loadRecallSearchEntries(globalRoot, query, undefined, tenantId, explainRequestedScope, 'additive', explainLoadSuperseded) : [];
|
|
2067
|
-
const passesExplainScope = (e) => api.passesCliRecallScopeFilter(e.scope ?? null, explainRequestedScope);
|
|
2068
|
-
explainLocalEntries = explainLocalEntries.filter(passesExplainScope);
|
|
2069
|
-
explainGlobalEntries = explainGlobalEntries.filter(passesExplainScope);
|
|
2070
|
-
// Honesty note (grill finding #2): the SQL predicate excludes denied rows
|
|
2071
|
-
// BEFORE the candidate window (codex P2 fix), so the pipeline never sees
|
|
2072
|
-
// them. For the diagnostic note only, probe the unscoped window and count
|
|
2073
|
-
// what the scope policy hides — window-capped, so the count is a floor on
|
|
2074
|
-
// large stores, which is fine for a "why is my row missing" hint.
|
|
2075
|
-
const explainUnscopedProbe = [
|
|
2076
|
-
...loadSearchEntries(hippoRoot, query, undefined, tenantId),
|
|
2077
|
-
...(isInitialized(globalRoot) ? loadSearchEntries(globalRoot, query, undefined, tenantId) : []),
|
|
2078
|
-
];
|
|
2079
|
-
const explainScopeDropped = explainUnscopedProbe.filter((e) => !passesExplainScope(e)).length;
|
|
2080
|
-
if (explainScopeDropped > 0) {
|
|
2081
|
-
console.error(`[note] ${explainScopeDropped} candidate${explainScopeDropped === 1 ? '' : 's'} hidden by recall scope policy (pass an explicit --scope to inspect).`);
|
|
2082
|
-
}
|
|
2083
|
-
// Bi-temporal filtering
|
|
2084
|
-
if (explainAsOf) {
|
|
2085
|
-
const filterAsOfExplain = (entries) => {
|
|
2086
|
-
const asOfDate = new Date(explainAsOf);
|
|
2087
|
-
const successorValidFrom = new Map();
|
|
2088
|
-
for (const e of entries) {
|
|
2089
|
-
if (e.superseded_by) {
|
|
2090
|
-
const successor = entries.find(s => s.id === e.superseded_by);
|
|
2091
|
-
if (successor)
|
|
2092
|
-
successorValidFrom.set(e.id, successor.valid_from);
|
|
2093
|
-
}
|
|
2094
|
-
}
|
|
2095
|
-
return entries.filter(e => {
|
|
2096
|
-
if (new Date(e.valid_from) > asOfDate)
|
|
2097
|
-
return false;
|
|
2098
|
-
if (!e.superseded_by)
|
|
2099
|
-
return true;
|
|
2100
|
-
const succVf = successorValidFrom.get(e.id);
|
|
2101
|
-
return succVf ? new Date(succVf) > asOfDate : true;
|
|
2102
|
-
});
|
|
2103
|
-
};
|
|
2104
|
-
explainLocalEntries = filterAsOfExplain(explainLocalEntries);
|
|
2105
|
-
explainGlobalEntries = filterAsOfExplain(explainGlobalEntries);
|
|
2106
|
-
}
|
|
2107
|
-
else if (!explainIncludeSuperseded) {
|
|
2108
|
-
explainLocalEntries = explainLocalEntries.filter(e => !e.superseded_by);
|
|
2109
|
-
explainGlobalEntries = explainGlobalEntries.filter(e => !e.superseded_by);
|
|
2110
|
-
}
|
|
2111
|
-
const hasGlobal = explainGlobalEntries.length > 0;
|
|
1296
|
+
// Explain shows what recall would see, so it applies the same scope rule.
|
|
1297
|
+
const explicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
|
|
1298
|
+
// Unlike recall, explain reads the global store whenever it exists, even when it is the local root.
|
|
1299
|
+
const explainGlobalOn = isInitialized(globalRoot);
|
|
1300
|
+
noteScopeHidden(hippoRoot, explainGlobalOn ? globalRoot : undefined, query, tenantId, explicitScope || undefined);
|
|
2112
1301
|
const config = loadConfig(hippoRoot);
|
|
2113
|
-
const
|
|
2114
|
-
|| (!forceClassic && config.physics.enabled !== false);
|
|
2115
|
-
const noMmr = Boolean(flags['no-mmr']);
|
|
2116
|
-
const mmrLambda = flags['mmr-lambda'] !== undefined
|
|
2117
|
-
? parseFloat(String(flags['mmr-lambda']))
|
|
2118
|
-
: config.mmr.lambda;
|
|
2119
|
-
const mmrEnabled = !noMmr && config.mmr.enabled;
|
|
2120
|
-
const localBump = flags['equal-sources']
|
|
2121
|
-
? 1.0
|
|
2122
|
-
: flags['local-bump'] !== undefined
|
|
2123
|
-
? parseFloat(String(flags['local-bump']))
|
|
2124
|
-
: config.search.localBump;
|
|
2125
|
-
// explainExplicitScope hoisted above the candidate loads (v1.25.0).
|
|
2126
|
-
const explainActiveScope = explainExplicitScope || detectScope();
|
|
1302
|
+
const engine = engineFlags(flags, config);
|
|
2127
1303
|
// Priced as recall prints each result, so explain returns what recall's engines would.
|
|
2128
1304
|
const explainIndex = loadIndex(hippoRoot);
|
|
2129
|
-
const explainGlobalOn = isInitialized(globalRoot);
|
|
2130
1305
|
const cost = (r) => printedTokens(recallEntryText(r, query, false, explainGlobalOn && !explainIndex.entries[r.entry.id]));
|
|
2131
1306
|
const entryBudget = Math.max(0, budget - printedTokens(recallHeading(budget, budget, query)));
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
modeUsed = 'physics';
|
|
2144
|
-
}
|
|
2145
|
-
else if (hasGlobal) {
|
|
2146
|
-
results = await searchBothHybrid(query, hippoRoot, globalRoot, {
|
|
2147
|
-
budget: entryBudget, cost, explain: true, mmr: mmrEnabled, mmrLambda, localBump, scope: explainActiveScope,
|
|
2148
|
-
includeSuperseded: explainIncludeSuperseded, asOf: explainAsOf, tenantId,
|
|
2149
|
-
recallScope: explainExplicitScope
|
|
2150
|
-
? { requested: explainExplicitScope, additive: true }
|
|
2151
|
-
: {},
|
|
2152
|
-
});
|
|
2153
|
-
modeUsed = 'searchBothHybrid';
|
|
2154
|
-
}
|
|
2155
|
-
else {
|
|
2156
|
-
results = await hybridSearch(query, explainLocalEntries, {
|
|
2157
|
-
budget: entryBudget, cost, hippoRoot, explain: true, mmr: mmrEnabled, mmrLambda, scope: explainActiveScope,
|
|
2158
|
-
includeSuperseded: explainIncludeSuperseded, asOf: explainAsOf,
|
|
2159
|
-
});
|
|
2160
|
-
modeUsed = 'hybrid';
|
|
2161
|
-
}
|
|
2162
|
-
if (limit < results.length) {
|
|
2163
|
-
results = results.slice(0, limit);
|
|
2164
|
-
}
|
|
2165
|
-
results = dropHeldCopies(results, (r) => r.entry);
|
|
2166
|
-
const candidates = explainLocalEntries.length + explainGlobalEntries.length;
|
|
1307
|
+
const rank = await rankRecall({ hippoRoot, globalRoot: explainGlobalOn ? globalRoot : undefined, tenantId }, {
|
|
1308
|
+
query, budget: entryBudget, cost, limit, includeSuperseded, asOf,
|
|
1309
|
+
explicitScope, activeScope: explicitScope || detectScope(),
|
|
1310
|
+
search: { ...engine, multihop: false, explain: true },
|
|
1311
|
+
});
|
|
1312
|
+
const hasGlobal = rank.globalEntries.length > 0;
|
|
1313
|
+
const modeUsed = engine.usePhysics && !hasGlobal
|
|
1314
|
+
? 'physics'
|
|
1315
|
+
: hasGlobal ? 'searchBothHybrid' : 'hybrid';
|
|
1316
|
+
const results = dropHeldCopies(rank.results, (r) => r.entry);
|
|
1317
|
+
const candidates = rank.localEntries.length + rank.globalEntries.length;
|
|
2167
1318
|
if (asJson) {
|
|
2168
1319
|
const output = results.map((r, rank) => ({
|
|
2169
1320
|
rank: rank + 1,
|
|
@@ -2666,164 +1817,6 @@ function cmdDedup(hippoRoot, flags) {
|
|
|
2666
1817
|
console.log(` ... and ${result.pairs.length - 15} more (run with --dry-run to see all)`);
|
|
2667
1818
|
}
|
|
2668
1819
|
}
|
|
2669
|
-
async function cmdSleep(hippoRoot, flags) {
|
|
2670
|
-
// Tee stdout/stderr to a log file when --log-file is set. The SessionEnd
|
|
2671
|
-
// hook uses this so the output is captured somewhere the SessionStart hook
|
|
2672
|
-
// can re-display it next time the agent UI starts.
|
|
2673
|
-
const logFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : null;
|
|
2674
|
-
let restoreStdout = null;
|
|
2675
|
-
if (logFile) {
|
|
2676
|
-
try {
|
|
2677
|
-
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
2678
|
-
fs.writeFileSync(logFile, `[hippo] ${new Date().toISOString()} consolidating memory...\n`, 'utf8');
|
|
2679
|
-
const origStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
2680
|
-
const origStderrWrite = process.stderr.write.bind(process.stderr);
|
|
2681
|
-
const tee = (chunk) => {
|
|
2682
|
-
try {
|
|
2683
|
-
const buf = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
|
|
2684
|
-
fs.appendFileSync(logFile, buf, 'utf8');
|
|
2685
|
-
}
|
|
2686
|
-
catch {
|
|
2687
|
-
// log failures are non-fatal — still write to the real stream
|
|
2688
|
-
}
|
|
2689
|
-
};
|
|
2690
|
-
process.stdout.write = ((chunk, enc, cb) => {
|
|
2691
|
-
tee(chunk);
|
|
2692
|
-
return origStdoutWrite(chunk, enc, cb);
|
|
2693
|
-
});
|
|
2694
|
-
process.stderr.write = ((chunk, enc, cb) => {
|
|
2695
|
-
tee(chunk);
|
|
2696
|
-
return origStderrWrite(chunk, enc, cb);
|
|
2697
|
-
});
|
|
2698
|
-
restoreStdout = () => {
|
|
2699
|
-
process.stdout.write = origStdoutWrite;
|
|
2700
|
-
process.stderr.write = origStderrWrite;
|
|
2701
|
-
};
|
|
2702
|
-
}
|
|
2703
|
-
catch (err) {
|
|
2704
|
-
console.error(`[hippo] warning: could not open log file ${logFile}: ${err.message}`);
|
|
2705
|
-
}
|
|
2706
|
-
}
|
|
2707
|
-
try {
|
|
2708
|
-
await cmdSleepCore(hippoRoot, flags);
|
|
2709
|
-
if (logFile)
|
|
2710
|
-
console.log('[hippo] sleep complete');
|
|
2711
|
-
}
|
|
2712
|
-
catch (err) {
|
|
2713
|
-
if (logFile)
|
|
2714
|
-
console.log(`[hippo] sleep failed: ${err.message}`);
|
|
2715
|
-
throw err;
|
|
2716
|
-
}
|
|
2717
|
-
finally {
|
|
2718
|
-
if (restoreStdout)
|
|
2719
|
-
restoreStdout();
|
|
2720
|
-
}
|
|
2721
|
-
}
|
|
2722
|
-
/**
|
|
2723
|
-
* Render an api.sleep result as console output, byte-identical to the
|
|
2724
|
-
* pre-extraction inline implementation in cmdSleepCore.
|
|
2725
|
-
*/
|
|
2726
|
-
/** @internal — exported for snapshot tests (tests/cli-context-render-snapshot.test.ts). NOT a stable public API. */
|
|
2727
|
-
export function renderSleepResult(result) {
|
|
2728
|
-
console.log(`Running consolidation${result.dryRun ? ' (dry run)' : ''}...`);
|
|
2729
|
-
console.log(`\nResults:`);
|
|
2730
|
-
console.log(` Active memories: ${result.active}`);
|
|
2731
|
-
console.log(` Removed (decayed): ${result.removed}`);
|
|
2732
|
-
// Only when dormant.enabled moved something, so every other render stays
|
|
2733
|
-
// byte-identical (tests/cli-context-render-snapshot.test.ts).
|
|
2734
|
-
if (result.dormant !== undefined && result.dormant > 0) {
|
|
2735
|
-
console.log(` Kept dormant: ${result.dormant} (hippo dormant to list)`);
|
|
2736
|
-
}
|
|
2737
|
-
if (result.dormantExpired !== undefined && result.dormantExpired > 0) {
|
|
2738
|
-
console.log(` Expired dormant: ${result.dormantExpired} (past dormant.retentionDays)`);
|
|
2739
|
-
}
|
|
2740
|
-
console.log(` Merged episodic: ${result.mergedEpisodic}`);
|
|
2741
|
-
console.log(` New semantic: ${result.newSemantic}`);
|
|
2742
|
-
if (result.details && result.details.length > 0) {
|
|
2743
|
-
console.log('\nDetails:');
|
|
2744
|
-
for (const d of result.details) {
|
|
2745
|
-
console.log(d);
|
|
2746
|
-
}
|
|
2747
|
-
}
|
|
2748
|
-
if (result.dryRun)
|
|
2749
|
-
console.log('\n(dry run - nothing written)');
|
|
2750
|
-
if (result.deduped && result.deduped.removed > 0) {
|
|
2751
|
-
const { removed, semDups, epiDups, crossDups } = result.deduped;
|
|
2752
|
-
const parts = [];
|
|
2753
|
-
if (semDups > 0)
|
|
2754
|
-
parts.push(`${semDups} redundant semantic patterns`);
|
|
2755
|
-
if (epiDups > 0)
|
|
2756
|
-
parts.push(`${epiDups} duplicate episodic lessons`);
|
|
2757
|
-
if (crossDups > 0)
|
|
2758
|
-
parts.push(`${crossDups} cross-layer duplicates`);
|
|
2759
|
-
console.log(`\n${result.dryRun ? 'Would dedupe' : 'Deduped'} ${removed} duplicates (${parts.join(', ')}). ${result.dryRun ? 'Would keep' : 'Kept'} stronger copies.`);
|
|
2760
|
-
}
|
|
2761
|
-
if (result.audit) {
|
|
2762
|
-
if (result.audit.errorsRemoved > 0) {
|
|
2763
|
-
console.log(`\nAudit: ${result.dryRun ? 'would remove' : 'removed'} ${result.audit.errorsRemoved} junk memories (too short/empty).`);
|
|
2764
|
-
}
|
|
2765
|
-
if (result.audit.warningCount > 0) {
|
|
2766
|
-
console.log(`Audit: ${result.audit.warningCount} low-quality memories detected (run \`hippo audit\` for details).`);
|
|
2767
|
-
}
|
|
2768
|
-
}
|
|
2769
|
-
if (result.shared !== undefined && result.shared > 0) {
|
|
2770
|
-
console.log(`\nAuto-shared ${result.shared} high-value memories to global store.`);
|
|
2771
|
-
}
|
|
2772
|
-
if (result.secretSkipped !== undefined && result.secretSkipped > 0) {
|
|
2773
|
-
// v1.25.0 (v39 follow-up #2): the secret veto is no longer silent.
|
|
2774
|
-
console.log(`\nAuto-share: withheld ${result.secretSkipped} secret-flagged ${result.secretSkipped === 1 ? 'memory' : 'memories'} (secret veto).`);
|
|
2775
|
-
}
|
|
2776
|
-
if (result.ambient) {
|
|
2777
|
-
console.log(`\n${renderAmbientSummary(result.ambient)}`);
|
|
2778
|
-
}
|
|
2779
|
-
if (result.graph && result.graph.tenants > 0) {
|
|
2780
|
-
const { tenants, entities, relations } = result.graph;
|
|
2781
|
-
console.log(`\nGraph: rebuilt ${tenants} tenant${tenants === 1 ? '' : 's'} (${entities} entities, ${relations} relations).`);
|
|
2782
|
-
}
|
|
2783
|
-
}
|
|
2784
|
-
async function cmdSleepCore(hippoRoot, flags) {
|
|
2785
|
-
requireInit(hippoRoot);
|
|
2786
|
-
// Phase 1: Auto-learn from git and every coding agent's own memories (CLI-only, uses process.cwd() / os.homedir()).
|
|
2787
|
-
// Stays in cli.ts; api.sleep covers Phase 2-6 only.
|
|
2788
|
-
if (!flags['no-learn'] && flags['dry-run']) {
|
|
2789
|
-
console.log("Dry run: skipped learning from git commits and coding agents' own memories (`hippo import --agents --dry-run` previews those).");
|
|
2790
|
-
}
|
|
2791
|
-
else if (!flags['no-learn']) {
|
|
2792
|
-
const config = loadConfig(hippoRoot);
|
|
2793
|
-
if (config.autoLearnOnSleep && isGitRepo(process.cwd())) {
|
|
2794
|
-
const { added } = learnFromRepo(hippoRoot, process.cwd(), 1);
|
|
2795
|
-
if (added > 0)
|
|
2796
|
-
console.log(`Auto-learned ${added} lessons from today's git commits.`);
|
|
2797
|
-
}
|
|
2798
|
-
// FE2: opt-in code-churn staleness, off by default (config.churnStaleness.enabled).
|
|
2799
|
-
if (config.churnStaleness.enabled && isGitRepo(process.cwd())) {
|
|
2800
|
-
for (const { root, result } of runChurnStaleForRepo(hippoRoot, false)) {
|
|
2801
|
-
if (result.marked > 0)
|
|
2802
|
-
console.log(`Tagged ${result.marked} memories churn-stale in ${root}.`);
|
|
2803
|
-
if (result.error)
|
|
2804
|
-
console.error(`Churn-staleness check failed for ${root}: ${result.error}`);
|
|
2805
|
-
}
|
|
2806
|
-
}
|
|
2807
|
-
printAgentImport(importForStore(hippoRoot, { machine: currentMachine() }), '');
|
|
2808
|
-
}
|
|
2809
|
-
// Finishes compactions a killed or busy post-compact hook left; never throws, and a dry run writes nothing.
|
|
2810
|
-
if (!flags['dry-run']) {
|
|
2811
|
-
const finished = replayCompactionsAt(hippoRoot, (message) => console.error(`compaction replay: ${message}`));
|
|
2812
|
-
if (finished > 0)
|
|
2813
|
-
console.log(`Finished saving ${finished} compaction${finished === 1 ? '' : 's'} left over from earlier sessions.`);
|
|
2814
|
-
}
|
|
2815
|
-
// Phase 2-6: Pure-storage pipeline (consolidate + dedup + audit + share + ambient).
|
|
2816
|
-
const ctx = {
|
|
2817
|
-
hippoRoot,
|
|
2818
|
-
tenantId: resolveTenantId({}),
|
|
2819
|
-
actor: api.adminActor('cli'),
|
|
2820
|
-
};
|
|
2821
|
-
const result = await api.sleep(ctx, {
|
|
2822
|
-
dryRun: Boolean(flags['dry-run']),
|
|
2823
|
-
noShare: Boolean(flags['no-share']),
|
|
2824
|
-
});
|
|
2825
|
-
renderSleepResult(result);
|
|
2826
|
-
}
|
|
2827
1820
|
/** Prints the SessionEnd sleep log, then clears it. Stderr, because Claude Code adds
|
|
2828
1821
|
* SessionStart stdout to the model's context and this log is for the user. */
|
|
2829
1822
|
function cmdLastSleep(flags) {
|
|
@@ -2919,6 +1912,10 @@ function cmdCompactResume(hippoRoot, stdinText, stdinTimedOut) {
|
|
|
2919
1912
|
}
|
|
2920
1913
|
}
|
|
2921
1914
|
}
|
|
1915
|
+
// A compaction follows a prompt or SessionStart that booked the arm, so this only reads it.
|
|
1916
|
+
if (!suppressOutput && payloadSessionId !== null && inPilotHoldout(hippoRoot, resolveTenantId({}), payloadSessionId, false)) {
|
|
1917
|
+
suppressOutput = true;
|
|
1918
|
+
}
|
|
2922
1919
|
if (!suppressOutput) {
|
|
2923
1920
|
const tenantId = resolveTenantId({});
|
|
2924
1921
|
const snapshot = loadFreshActiveTaskSnapshot(hippoRoot, tenantId, { maxAgeMs: COMPACT_RESUME_MAX_AGE_MS });
|
|
@@ -3024,49 +2021,6 @@ async function cmdSessionEnd(hippoRoot, flags) {
|
|
|
3024
2021
|
return;
|
|
3025
2022
|
}
|
|
3026
2023
|
}
|
|
3027
|
-
/**
|
|
3028
|
-
* Detached worker that counts re-reads, runs sleep, then capture. Invoked via the internal
|
|
3029
|
-
* `__session-end-worker` subcommand (not user-facing). Failures in one stage
|
|
3030
|
-
* do not block the other.
|
|
3031
|
-
*/
|
|
3032
|
-
// Best-effort git state; a missing git, non-repo cwd, or the timeout all
|
|
3033
|
-
// yield null fields rather than throw (autolearn.ts execFileSync shape).
|
|
3034
|
-
function collectHandoffEvidence(cwd, testStatus) {
|
|
3035
|
-
let gitRef = null;
|
|
3036
|
-
try {
|
|
3037
|
-
gitRef = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
3038
|
-
cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
|
|
3039
|
-
}).trim() || null;
|
|
3040
|
-
}
|
|
3041
|
-
catch {
|
|
3042
|
-
gitRef = null;
|
|
3043
|
-
}
|
|
3044
|
-
let dirtyTree = null;
|
|
3045
|
-
try {
|
|
3046
|
-
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
3047
|
-
cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
|
|
3048
|
-
});
|
|
3049
|
-
dirtyTree = status.trim().length > 0;
|
|
3050
|
-
}
|
|
3051
|
-
catch {
|
|
3052
|
-
dirtyTree = null;
|
|
3053
|
-
}
|
|
3054
|
-
return { gitRef, dirtyTree, testStatus };
|
|
3055
|
-
}
|
|
3056
|
-
/** A folder without its own store never sleeps at session end, so its project's agent notes go to the global store here. */
|
|
3057
|
-
function logSessionEndImport(logFile, transcriptPath) {
|
|
3058
|
-
try {
|
|
3059
|
-
const report = importAtSessionEnd(process.cwd(), transcriptPath, { machine: currentMachine() });
|
|
3060
|
-
const line = summaryLine(report);
|
|
3061
|
-
if (line !== null)
|
|
3062
|
-
appendSessionEndCloseLog(logFile, line);
|
|
3063
|
-
for (const warning of report.warnings)
|
|
3064
|
-
appendSessionEndCloseLog(logFile, `agent memories: ${warning}`);
|
|
3065
|
-
}
|
|
3066
|
-
catch (err) {
|
|
3067
|
-
appendSessionEndCloseLog(logFile, `agent memory import failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3068
|
-
}
|
|
3069
|
-
}
|
|
3070
2024
|
async function cmdSessionEndWorker(hippoRoot, flags) {
|
|
3071
2025
|
const transcriptPath = typeof flags['transcript'] === 'string' ? flags['transcript'] : undefined;
|
|
3072
2026
|
const closeLogFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : null;
|
|
@@ -3087,7 +2041,7 @@ async function cmdSessionEndWorker(hippoRoot, flags) {
|
|
|
3087
2041
|
// Sleeping the global store from here would learn this folder's git commits into it; it has its own daily sleep.
|
|
3088
2042
|
if (isInitialized(hippoRoot)) {
|
|
3089
2043
|
try {
|
|
3090
|
-
await cmdSleep(hippoRoot, flags);
|
|
2044
|
+
await (await import('./cli/sleep.js')).cmdSleep(hippoRoot, flags);
|
|
3091
2045
|
}
|
|
3092
2046
|
catch {
|
|
3093
2047
|
// sleep errors are already tee'd to the log file via cmdSleep's
|
|
@@ -3206,28 +2160,6 @@ async function bookSessionRereads(hippoRoot, transcriptPath, sessionId) {
|
|
|
3206
2160
|
lines.push(`re-read ${tokens} tokens over ${read.calls.length} model calls for session ${sessionId}${skipped}`);
|
|
3207
2161
|
return lines;
|
|
3208
2162
|
}
|
|
3209
|
-
/**
|
|
3210
|
-
* Best-effort log line for the DF1 T3 snapshot-close step in
|
|
3211
|
-
* `cmdSessionEndWorker`. `cmdSleep`/`cmdCapture` each tee console output to
|
|
3212
|
-
* `logFile` only for their own duration (the tee is restored before this
|
|
3213
|
-
* runs), so a plain `console.log` here would be silently discarded under
|
|
3214
|
-
* the detached worker's `stdio: 'ignore'` — write straight to the file
|
|
3215
|
-
* instead, matching capture.ts's `appendPreCompactLog` convention.
|
|
3216
|
-
*/
|
|
3217
|
-
function appendSessionEndCloseLog(logFile, message, opts = {}) {
|
|
3218
|
-
if (!logFile)
|
|
3219
|
-
return;
|
|
3220
|
-
try {
|
|
3221
|
-
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
3222
|
-
// sanitizeLogMessage: `message` interpolates the payload-controlled
|
|
3223
|
-
// session_id — same log-forgery guard appendPreCompactLog applies.
|
|
3224
|
-
const write = opts.startFresh ? fs.writeFileSync : fs.appendFileSync;
|
|
3225
|
-
write(logFile, `[hippo] ${new Date().toISOString()} ${sanitizeLogMessage(message)}\n`, 'utf8');
|
|
3226
|
-
}
|
|
3227
|
-
catch {
|
|
3228
|
-
// Best-effort only — never let a log-write failure surface as an error.
|
|
3229
|
-
}
|
|
3230
|
-
}
|
|
3231
2163
|
function loadCodexWrapperMetadata() {
|
|
3232
2164
|
const { metadataPath } = resolveCodexWrapperPaths();
|
|
3233
2165
|
if (!fs.existsSync(metadataPath)) {
|
|
@@ -3257,7 +2189,8 @@ function spawnRealCodex(realCodexPath, forwardArgs, cwd) {
|
|
|
3257
2189
|
function cmdCodexRun(hippoRoot, args) {
|
|
3258
2190
|
const metadata = loadCodexWrapperMetadata();
|
|
3259
2191
|
const startedAtMs = Date.now();
|
|
3260
|
-
|
|
2192
|
+
// Codex reads CODEX_HOME at each launch, so resolve it now, not from the install-time metadata.
|
|
2193
|
+
const { historyPath } = resolveCodexWrapperPaths();
|
|
3261
2194
|
const startOffsetBytes = fs.existsSync(historyPath) ? fs.statSync(historyPath).size : 0;
|
|
3262
2195
|
try {
|
|
3263
2196
|
cmdLastSleep({ path: metadata.logFile });
|
|
@@ -3332,7 +2265,7 @@ async function cmdCodexSessionEndWorker(hippoRoot, flags) {
|
|
|
3332
2265
|
// Sleeping the global store from here would learn this folder's git commits into it; it has its own daily sleep.
|
|
3333
2266
|
if (isInitialized(hippoRoot)) {
|
|
3334
2267
|
try {
|
|
3335
|
-
await cmdSleep(hippoRoot, logFile ? { 'log-file': logFile } : {});
|
|
2268
|
+
await (await import('./cli/sleep.js')).cmdSleep(hippoRoot, logFile ? { 'log-file': logFile } : {});
|
|
3336
2269
|
}
|
|
3337
2270
|
catch {
|
|
3338
2271
|
// sleep errors are already written via cmdSleep
|
|
@@ -3345,7 +2278,7 @@ async function cmdCodexSessionEndWorker(hippoRoot, flags) {
|
|
|
3345
2278
|
try {
|
|
3346
2279
|
const codexHome = typeof flags['codex-home'] === 'string'
|
|
3347
2280
|
? flags['codex-home']
|
|
3348
|
-
:
|
|
2281
|
+
: resolveCodexWrapperPaths().codexHome;
|
|
3349
2282
|
const historyPath = typeof flags['history-path'] === 'string'
|
|
3350
2283
|
? flags['history-path']
|
|
3351
2284
|
: path.join(codexHome, 'history.jsonl');
|
|
@@ -3703,12 +2636,6 @@ function cmdInspect(hippoRoot, id) {
|
|
|
3703
2636
|
console.log('-'.repeat(40));
|
|
3704
2637
|
console.log(entry.content);
|
|
3705
2638
|
}
|
|
3706
|
-
function printActiveTaskSnapshot(snapshot) {
|
|
3707
|
-
console.log(snapshotText(snapshot));
|
|
3708
|
-
}
|
|
3709
|
-
function printSessionEvents(events) {
|
|
3710
|
-
console.log(events.length === 0 ? 'No session events found.' : sessionTrailText(events));
|
|
3711
|
-
}
|
|
3712
2639
|
function cmdConflicts(hippoRoot, flags) {
|
|
3713
2640
|
requireInit(hippoRoot);
|
|
3714
2641
|
const conflicts = listMemoryConflicts(hippoRoot, String(flags['status'] ?? 'open'));
|
|
@@ -4290,9 +3217,6 @@ function cmdSession(hippoRoot, args, flags) {
|
|
|
4290
3217
|
console.error('Usage: hippo session <log|show|latest|resume|complete>');
|
|
4291
3218
|
process.exit(1);
|
|
4292
3219
|
}
|
|
4293
|
-
function printHandoff(handoff) {
|
|
4294
|
-
console.log(handoffText(handoff));
|
|
4295
|
-
}
|
|
4296
3220
|
function cmdHandoff(hippoRoot, args, flags) {
|
|
4297
3221
|
requireInit(hippoRoot);
|
|
4298
3222
|
const subcommand = args[0] ?? 'latest';
|
|
@@ -4458,18 +3382,6 @@ function printCard(detail) {
|
|
|
4458
3382
|
}
|
|
4459
3383
|
console.log('');
|
|
4460
3384
|
}
|
|
4461
|
-
// parseArgs turns a value-less flag into `true`; refuse rather than silently
|
|
4462
|
-
// stringifying it (String(true) === 'true'), mirroring cmdHandoff's guard.
|
|
4463
|
-
function cardStringFlag(flags, key) {
|
|
4464
|
-
const v = flags[key];
|
|
4465
|
-
if (v === undefined)
|
|
4466
|
-
return undefined;
|
|
4467
|
-
if (v === true || v === false || Array.isArray(v)) {
|
|
4468
|
-
console.error(`--${key} requires a value`);
|
|
4469
|
-
process.exit(1);
|
|
4470
|
-
}
|
|
4471
|
-
return v.trim();
|
|
4472
|
-
}
|
|
4473
3385
|
// A too-large --run would silently round to a different id (mirrors parsePositiveIncidentId).
|
|
4474
3386
|
function cardRunFlag(flags) {
|
|
4475
3387
|
const raw = cardStringFlag(flags, 'run');
|
|
@@ -6287,9 +5199,17 @@ function cmdCurrent(hippoRoot, args, flags) {
|
|
|
6287
5199
|
console.error('Usage: hippo current <show>');
|
|
6288
5200
|
process.exit(1);
|
|
6289
5201
|
}
|
|
6290
|
-
|
|
6291
|
-
function
|
|
6292
|
-
|
|
5202
|
+
/** Hook commands share one handle per store and wait at most HOOK_DB_WAIT_MS for a lock; a store still busy after that skips the hook's work with one warning, exit 0. */
|
|
5203
|
+
async function runHookWithStores(fn) {
|
|
5204
|
+
try {
|
|
5205
|
+
return await withSharedStoreHandles(fn, { busyWaitMs: HOOK_DB_WAIT_MS });
|
|
5206
|
+
}
|
|
5207
|
+
catch (error) {
|
|
5208
|
+
if (!isSqliteBusy(error))
|
|
5209
|
+
throw error;
|
|
5210
|
+
noteStoreBusy('hook skipped');
|
|
5211
|
+
return undefined;
|
|
5212
|
+
}
|
|
6293
5213
|
}
|
|
6294
5214
|
async function cmdContext(hippoRoot, args, flags, stdinText) {
|
|
6295
5215
|
const rec = startDeliveryRecorder(hippoRoot, flags, stdinText);
|
|
@@ -6331,6 +5251,22 @@ function flushDeliveryRecorder(rec, db) {
|
|
|
6331
5251
|
console.error(`[hippo] delivery ledger write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
6332
5252
|
}
|
|
6333
5253
|
}
|
|
5254
|
+
/**
|
|
5255
|
+
* Whether this session sits in the pilot's holdout arm (src/pilot-arm.ts). Off at rate 0 and with no session id.
|
|
5256
|
+
* `write` books the arm row; a read-only caller (env-only id, sub-agent) follows the stored arm, else the hash.
|
|
5257
|
+
*/
|
|
5258
|
+
function inPilotHoldout(hippoRoot, tenantId, sessionId, write) {
|
|
5259
|
+
if (sessionId === undefined || sessionId.trim() === '')
|
|
5260
|
+
return false;
|
|
5261
|
+
const root = isInitialized(hippoRoot) ? hippoRoot : isInitialized(getGlobalRoot()) ? getGlobalRoot() : null;
|
|
5262
|
+
if (root === null)
|
|
5263
|
+
return false;
|
|
5264
|
+
const rate = loadConfig(root).pilot.holdoutRateBp;
|
|
5265
|
+
if (rate <= 0)
|
|
5266
|
+
return false;
|
|
5267
|
+
const arm = withLedgerDb(hippoRoot, (db) => write ? ensurePilotArm(db, tenantId, sessionId, rate) : readPilotArm(db, sessionId) ?? hashArm(sessionId, rate));
|
|
5268
|
+
return (arm ?? hashArm(sessionId, rate)) === 'holdout';
|
|
5269
|
+
}
|
|
6334
5270
|
async function renderContext(hippoRoot, args, flags, stdinText, rec) {
|
|
6335
5271
|
// --pinned-only fires on every UserPromptSubmit — including in directories
|
|
6336
5272
|
// that don't have a local .hippo. Skip requireInit for that path and fall
|
|
@@ -6340,30 +5276,6 @@ async function renderContext(hippoRoot, args, flags, stdinText, rec) {
|
|
|
6340
5276
|
if (!pinnedOnly) {
|
|
6341
5277
|
requireInit(hippoRoot);
|
|
6342
5278
|
}
|
|
6343
|
-
const budget = parseBudgetFlag(flags['budget'], 1500);
|
|
6344
|
-
if (budget <= 0) {
|
|
6345
|
-
rec?.disabled();
|
|
6346
|
-
return;
|
|
6347
|
-
}
|
|
6348
|
-
// Resolve query: explicit args, --auto (git diff via CLI-side helper), or
|
|
6349
|
-
// fall through to api.getContext's '*' fallback. api.getContext is host-
|
|
6350
|
-
// agnostic so the auto-detect (which shells out to git) stays CLI-side.
|
|
6351
|
-
let query = args.join(' ').trim();
|
|
6352
|
-
if (!query && flags['auto']) {
|
|
6353
|
-
query = autoDetectContext();
|
|
6354
|
-
}
|
|
6355
|
-
// Scope detection (CLI-side: uses cwd). api.getContext takes the resolved
|
|
6356
|
-
// scope via opts.scope to stay host-agnostic.
|
|
6357
|
-
const ctxExplicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
|
|
6358
|
-
const ctxActiveScope = ctxExplicitScope || detectScope();
|
|
6359
|
-
const ctx = {
|
|
6360
|
-
hippoRoot,
|
|
6361
|
-
tenantId: resolveTenantId({}),
|
|
6362
|
-
actor: api.adminActor('cli'),
|
|
6363
|
-
};
|
|
6364
|
-
// v39 memory scope isolation: --cross-project re-includes other-project
|
|
6365
|
-
// memories (rendered under a demarcated section below).
|
|
6366
|
-
const crossProject = flags['cross-project'] === true;
|
|
6367
5279
|
// DF1 T2: resolve the calling session's id for the bounded active-task-
|
|
6368
5280
|
// snapshot read (api.getContext -> loadFreshActiveTaskSnapshot). Stdin
|
|
6369
5281
|
// payload (the UserPromptSubmit hook JSON) wins; falls back to
|
|
@@ -6391,6 +5303,36 @@ async function renderContext(hippoRoot, args, flags, stdinText, rec) {
|
|
|
6391
5303
|
const ledgerSessionId = subagent ? undefined : currentSessionId;
|
|
6392
5304
|
if (subagent)
|
|
6393
5305
|
payloadSessionId = undefined;
|
|
5306
|
+
// The pilot arm is booked at the first hook call whatever the flags, so the holdout sees no budget or content branch.
|
|
5307
|
+
const resolvedTenant = resolveTenantId({});
|
|
5308
|
+
if (inPilotHoldout(hippoRoot, resolvedTenant, currentSessionId, payloadSessionId !== undefined)) {
|
|
5309
|
+
rec?.disabled();
|
|
5310
|
+
return;
|
|
5311
|
+
}
|
|
5312
|
+
const budget = parseBudgetFlag(flags['budget'], 1500);
|
|
5313
|
+
if (budget <= 0) {
|
|
5314
|
+
rec?.disabled();
|
|
5315
|
+
return;
|
|
5316
|
+
}
|
|
5317
|
+
// Resolve query: explicit args, --auto (git diff via CLI-side helper), or
|
|
5318
|
+
// fall through to api.getContext's '*' fallback. api.getContext is host-
|
|
5319
|
+
// agnostic so the auto-detect (which shells out to git) stays CLI-side.
|
|
5320
|
+
let query = args.join(' ').trim();
|
|
5321
|
+
if (!query && flags['auto']) {
|
|
5322
|
+
query = autoDetectContext();
|
|
5323
|
+
}
|
|
5324
|
+
// Scope detection (CLI-side: uses cwd). api.getContext takes the resolved
|
|
5325
|
+
// scope via opts.scope to stay host-agnostic.
|
|
5326
|
+
const ctxExplicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
|
|
5327
|
+
const ctxActiveScope = ctxExplicitScope || detectScope();
|
|
5328
|
+
const ctx = {
|
|
5329
|
+
hippoRoot,
|
|
5330
|
+
tenantId: resolvedTenant,
|
|
5331
|
+
actor: api.adminActor('cli'),
|
|
5332
|
+
};
|
|
5333
|
+
// v39 memory scope isolation: --cross-project re-includes other-project
|
|
5334
|
+
// memories (rendered under a demarcated section below).
|
|
5335
|
+
const crossProject = flags['cross-project'] === true;
|
|
6394
5336
|
const format = String(flags['format'] ?? 'markdown');
|
|
6395
5337
|
const framing = String(flags['framing'] ?? 'observe');
|
|
6396
5338
|
const opts = {
|
|
@@ -6548,7 +5490,10 @@ async function renderContext(hippoRoot, args, flags, stdinText, rec) {
|
|
|
6548
5490
|
items: staticItems.length, tokens: estimateTokens(finalStatic), hash: blockHash(finalStatic),
|
|
6549
5491
|
});
|
|
6550
5492
|
}
|
|
6551
|
-
catch {
|
|
5493
|
+
catch (error) {
|
|
5494
|
+
if (isSqliteBusy(error))
|
|
5495
|
+
noteStoreBusy('token ledger row skipped');
|
|
5496
|
+
}
|
|
6552
5497
|
}
|
|
6553
5498
|
if (recallBlock) {
|
|
6554
5499
|
try {
|
|
@@ -6557,7 +5502,10 @@ async function renderContext(hippoRoot, args, flags, stdinText, rec) {
|
|
|
6557
5502
|
items: recallItems.length, tokens: estimateTokens(recallBlock), hash: blockHash(recallBlock),
|
|
6558
5503
|
});
|
|
6559
5504
|
}
|
|
6560
|
-
catch {
|
|
5505
|
+
catch (error) {
|
|
5506
|
+
if (isSqliteBusy(error))
|
|
5507
|
+
noteStoreBusy('token ledger row skipped');
|
|
5508
|
+
}
|
|
6561
5509
|
}
|
|
6562
5510
|
flushDeliveryRecorder(rec, db);
|
|
6563
5511
|
});
|
|
@@ -6594,83 +5542,6 @@ async function renderContext(hippoRoot, args, flags, stdinText, rec) {
|
|
|
6594
5542
|
});
|
|
6595
5543
|
}
|
|
6596
5544
|
}
|
|
6597
|
-
/**
|
|
6598
|
-
* TE2: compaction drops the pinned blocks the per-prompt hook injected
|
|
6599
|
-
* earlier, so record a `reset` for the payload's session and the next prompt
|
|
6600
|
-
* injects again even if nothing changed. `requiredSource` limits it to hook
|
|
6601
|
-
* payloads with that `source` (SessionStart fires for other reasons too).
|
|
6602
|
-
* Best-effort and silent: a malformed payload records nothing.
|
|
6603
|
-
*/
|
|
6604
|
-
function resetHookInjection(hippoRoot, stdinText, requiredSource) {
|
|
6605
|
-
const sessionId = hookPayloadSessionId(stdinText, requiredSource);
|
|
6606
|
-
// A sub-agent's compaction leaves its parent's context, and the blocks in it, as they were.
|
|
6607
|
-
if (sessionId === null || isSubagentPayload(stdinText))
|
|
6608
|
-
return;
|
|
6609
|
-
withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
|
|
6610
|
-
tenantId: resolveTenantId({}), sessionId, surface: 'hook', event: 'reset', items: 0, tokens: 0,
|
|
6611
|
-
}));
|
|
6612
|
-
}
|
|
6613
|
-
/**
|
|
6614
|
-
* Run `fn` with console.log captured; returns the captured lines joined by
|
|
6615
|
-
* newlines (what the same calls would have printed, minus the final newline).
|
|
6616
|
-
*/
|
|
6617
|
-
function captureConsole(fn) {
|
|
6618
|
-
const lines = [];
|
|
6619
|
-
const realLog = console.log;
|
|
6620
|
-
console.log = (...parts) => { lines.push(parts.map(String).join(' ')); };
|
|
6621
|
-
try {
|
|
6622
|
-
fn();
|
|
6623
|
-
}
|
|
6624
|
-
finally {
|
|
6625
|
-
console.log = realLog;
|
|
6626
|
-
}
|
|
6627
|
-
return lines.join('\n');
|
|
6628
|
-
}
|
|
6629
|
-
/**
|
|
6630
|
-
* The store a Claude Code hook writes to: the project store when there is
|
|
6631
|
-
* one, else an existing global store, else the project path (which the hook
|
|
6632
|
-
* then skips, since hooks fire in every directory and must not create one).
|
|
6633
|
-
* Pre-compact and compact-resume must agree, or a snapshot saved to one store
|
|
6634
|
-
* is looked for in the other.
|
|
6635
|
-
*/
|
|
6636
|
-
function hookStoreRoot(hippoRoot) {
|
|
6637
|
-
if (isInitialized(hippoRoot))
|
|
6638
|
-
return hippoRoot;
|
|
6639
|
-
const globalRoot = getGlobalRoot();
|
|
6640
|
-
return isInitialized(globalRoot) ? globalRoot : hippoRoot;
|
|
6641
|
-
}
|
|
6642
|
-
/**
|
|
6643
|
-
* Run `fn` against the token ledger's store: the local store when it is
|
|
6644
|
-
* initialized, else the global one (the per-prompt hook runs in directories
|
|
6645
|
-
* without a local store). Best-effort: returns undefined and never throws,
|
|
6646
|
-
* because a ledger failure must not break context or recall.
|
|
6647
|
-
*/
|
|
6648
|
-
function withLedgerDb(hippoRoot, fn) {
|
|
6649
|
-
let root = null;
|
|
6650
|
-
try {
|
|
6651
|
-
if (isInitialized(hippoRoot))
|
|
6652
|
-
root = hippoRoot;
|
|
6653
|
-
else if (isInitialized(getGlobalRoot()))
|
|
6654
|
-
root = getGlobalRoot();
|
|
6655
|
-
}
|
|
6656
|
-
catch {
|
|
6657
|
-
return undefined;
|
|
6658
|
-
}
|
|
6659
|
-
if (root === null)
|
|
6660
|
-
return undefined;
|
|
6661
|
-
let db;
|
|
6662
|
-
try {
|
|
6663
|
-
db = openHippoDb(root);
|
|
6664
|
-
return fn(db);
|
|
6665
|
-
}
|
|
6666
|
-
catch {
|
|
6667
|
-
return undefined;
|
|
6668
|
-
}
|
|
6669
|
-
finally {
|
|
6670
|
-
if (db)
|
|
6671
|
-
closeHippoDb(db);
|
|
6672
|
-
}
|
|
6673
|
-
}
|
|
6674
5545
|
/**
|
|
6675
5546
|
* v39: render cross-project inclusions under an explicit header so agents
|
|
6676
5547
|
* (and humans) can tell borrowed context from project memory. Only ever
|
|
@@ -6691,39 +5562,6 @@ export function printContextMarkdown(items, totalTokens, framing = 'observe', op
|
|
|
6691
5562
|
for (const item of items)
|
|
6692
5563
|
console.log(contextLine(item, framing, showStrength, now));
|
|
6693
5564
|
}
|
|
6694
|
-
function autoDetectContext() {
|
|
6695
|
-
// Try git diff --name-only for changed files
|
|
6696
|
-
try {
|
|
6697
|
-
const diff = execSync('git diff --name-only HEAD 2>&1', {
|
|
6698
|
-
encoding: 'utf8',
|
|
6699
|
-
timeout: 3000,
|
|
6700
|
-
windowsHide: true,
|
|
6701
|
-
}).trim();
|
|
6702
|
-
if (diff) {
|
|
6703
|
-
// Extract meaningful terms from file paths
|
|
6704
|
-
const terms = diff
|
|
6705
|
-
.split('\n')
|
|
6706
|
-
.flatMap((f) => f.replace(/[\/\\\.]/g, ' ').split(/\s+/))
|
|
6707
|
-
.filter((t) => t.length > 2 && !['src', 'dist', 'test', 'tests', 'node_modules', 'index'].includes(t))
|
|
6708
|
-
.slice(0, 10);
|
|
6709
|
-
if (terms.length > 0)
|
|
6710
|
-
return terms.join(' ');
|
|
6711
|
-
}
|
|
6712
|
-
// Try branch name
|
|
6713
|
-
const branch = execSync('git branch --show-current 2>&1', {
|
|
6714
|
-
encoding: 'utf8',
|
|
6715
|
-
timeout: 3000,
|
|
6716
|
-
windowsHide: true,
|
|
6717
|
-
}).trim();
|
|
6718
|
-
if (branch && branch !== 'main' && branch !== 'master') {
|
|
6719
|
-
return branch.replace(/[-_\/]/g, ' ');
|
|
6720
|
-
}
|
|
6721
|
-
}
|
|
6722
|
-
catch {
|
|
6723
|
-
// Not a git repo or git not available, fall through
|
|
6724
|
-
}
|
|
6725
|
-
return '';
|
|
6726
|
-
}
|
|
6727
5565
|
// ---------------------------------------------------------------------------
|
|
6728
5566
|
// Embed command
|
|
6729
5567
|
// ---------------------------------------------------------------------------
|
|
@@ -6859,115 +5697,6 @@ async function cmdWatch(command, hippoRoot) {
|
|
|
6859
5697
|
// ---------------------------------------------------------------------------
|
|
6860
5698
|
// Learn command
|
|
6861
5699
|
// ---------------------------------------------------------------------------
|
|
6862
|
-
function learnFromRepo(hippoRoot, repoPath, days, label) {
|
|
6863
|
-
const prefix = label ? `[${label}] ` : '';
|
|
6864
|
-
if (!isGitRepo(repoPath)) {
|
|
6865
|
-
console.log(`${prefix}No git history found (or not a git repository).`);
|
|
6866
|
-
return { added: 0, skipped: 0, lowInfo: 0 };
|
|
6867
|
-
}
|
|
6868
|
-
const gitLog = fetchGitLog(repoPath, days);
|
|
6869
|
-
if (!gitLog.trim()) {
|
|
6870
|
-
console.log(`${prefix}No fix/revert/bug commits found in the specified period.`);
|
|
6871
|
-
return { added: 0, skipped: 0, lowInfo: 0 };
|
|
6872
|
-
}
|
|
6873
|
-
// Same patterns as MCP hippo_learn: config.gitLearnPatterns (whose default
|
|
6874
|
-
// equals extractLessons' built-in list) so a custom list applies everywhere.
|
|
6875
|
-
const config = loadConfig(hippoRoot);
|
|
6876
|
-
const parsedLessons = extractLessons(gitLog, config.gitLearnPatterns);
|
|
6877
|
-
if (parsedLessons.length === 0) {
|
|
6878
|
-
console.log(`${prefix}No fix/revert/bug commits found in the specified period.`);
|
|
6879
|
-
return { added: 0, skipped: 0, lowInfo: 0 };
|
|
6880
|
-
}
|
|
6881
|
-
// DF4: admission gate lives at the write path, not in extractLessons
|
|
6882
|
-
// (a published API surface that only parses). Bare subjects like "fixed
|
|
6883
|
-
// signals" are dropped here, before they ever become a memory.
|
|
6884
|
-
// The gate filters the loop INPUT, so a dropped lesson neither stores nor
|
|
6885
|
-
// invalidates. That is deliberate, and it was argued both ways.
|
|
6886
|
-
//
|
|
6887
|
-
// Round 1 of review called the lost invalidation a P1: a migration subject
|
|
6888
|
-
// too thin to store ("replace webpack with vite") would stop weakening
|
|
6889
|
-
// stale webpack memories. True. So the loop was widened to walk every
|
|
6890
|
-
// parsed lesson with the gate on the write alone.
|
|
6891
|
-
//
|
|
6892
|
-
// Round 2 then found the cure was worse. STORAGE is what makes invalidation
|
|
6893
|
-
// idempotent here: a stored lesson is recognised by its same-text key on
|
|
6894
|
-
// the next scan and short-circuits before invalidating again. A lesson that
|
|
6895
|
-
// invalidates but is never stored has no such record, so every rescan
|
|
6896
|
-
// re-invalidates, and invalidateMatching halves half_life_days each time.
|
|
6897
|
-
// Measured: 7 -> 3 -> 1 over two runs. That is compounding data damage.
|
|
6898
|
-
//
|
|
6899
|
-
// Measured frequency decided it. Across 413 real auto-learn rows in 4
|
|
6900
|
-
// stores, 24 are gated and ZERO of those carry an invalidation target; the
|
|
6901
|
-
// 45 lessons that do carry targets all pass the gate and are unaffected
|
|
6902
|
-
// either way. Both failure modes are empty on real data, so the tie breaks
|
|
6903
|
-
// on which one is benign if it ever fires: not invalidating is a missed
|
|
6904
|
-
// improvement, re-invalidating forever is damage.
|
|
6905
|
-
//
|
|
6906
|
-
// Documented limitation, pinned by test: a migration subject too thin to
|
|
6907
|
-
// store also does not invalidate. Making invalidateMatching idempotent
|
|
6908
|
-
// would allow both, and is backlogged - it is a latent issue for the manual
|
|
6909
|
-
// `hippo invalidate` path too, not just this one.
|
|
6910
|
-
const { kept: lessons, dropped } = partitionLessons(parsedLessons);
|
|
6911
|
-
const lowInfo = dropped.length;
|
|
6912
|
-
let added = 0;
|
|
6913
|
-
let skipped = 0;
|
|
6914
|
-
// AT1 (plan §3 containment): per-lesson refusal must not abort the rest
|
|
6915
|
-
// of the git-log scan. No signature change (added/skipped return shape
|
|
6916
|
-
// used by cmdLearn + cmdSleepCore callers) — counted locally, folded into
|
|
6917
|
-
// the existing summary line.
|
|
6918
|
-
let rejected = 0;
|
|
6919
|
-
const gitLearnTags = ['error', 'git-learned'];
|
|
6920
|
-
const existingForSchema = loadAllEntries(hippoRoot, resolveTenantId({}));
|
|
6921
|
-
const keys = storedTextKeys(existingForSchema);
|
|
6922
|
-
for (const lesson of lessons) {
|
|
6923
|
-
if (keys.has(duplicateKey(lesson))) {
|
|
6924
|
-
skipped++;
|
|
6925
|
-
continue;
|
|
6926
|
-
}
|
|
6927
|
-
const target = extractInvalidationTarget(lesson);
|
|
6928
|
-
if (target) {
|
|
6929
|
-
const invResult = invalidateMatching(hippoRoot, target, resolveTenantId({}));
|
|
6930
|
-
if (invResult.invalidated > 0) {
|
|
6931
|
-
console.log(`${prefix} Invalidated ${invResult.invalidated} memories referencing "${target.from}"`);
|
|
6932
|
-
}
|
|
6933
|
-
}
|
|
6934
|
-
const schemaFitVal = computeSchemaFit(lesson, gitLearnTags, existingForSchema);
|
|
6935
|
-
const entry = createMemory(lesson, {
|
|
6936
|
-
layer: Layer.Episodic,
|
|
6937
|
-
tags: [...gitLearnTags],
|
|
6938
|
-
source: 'git-learn',
|
|
6939
|
-
confidence: 'observed',
|
|
6940
|
-
schema_fit: schemaFitVal,
|
|
6941
|
-
tenantId: resolveTenantId({}),
|
|
6942
|
-
baseHalfLifeDays: config.defaultHalfLifeDays,
|
|
6943
|
-
});
|
|
6944
|
-
// Auto-tag with path context from the repo being learned
|
|
6945
|
-
const learnPathTags = extractPathTags(repoPath);
|
|
6946
|
-
for (const pt of learnPathTags) {
|
|
6947
|
-
if (!entry.tags.includes(pt))
|
|
6948
|
-
entry.tags.push(pt);
|
|
6949
|
-
}
|
|
6950
|
-
try {
|
|
6951
|
-
writeEntry(hippoRoot, entry);
|
|
6952
|
-
}
|
|
6953
|
-
catch (err) {
|
|
6954
|
-
if (err instanceof RejectedValueError) {
|
|
6955
|
-
rejected++;
|
|
6956
|
-
continue;
|
|
6957
|
-
}
|
|
6958
|
-
throw err;
|
|
6959
|
-
}
|
|
6960
|
-
updateStats(hippoRoot, { remembered: 1 });
|
|
6961
|
-
keys.add(duplicateKey(lesson));
|
|
6962
|
-
void embedMemory(hippoRoot, entry);
|
|
6963
|
-
added++;
|
|
6964
|
-
}
|
|
6965
|
-
console.log(`${prefix}${added} new lessons added, ${skipped} duplicates skipped` +
|
|
6966
|
-
(rejected > 0 ? `, ${rejected} rejected value(s) skipped` : '') +
|
|
6967
|
-
(lowInfo > 0 ? `, ${lowInfo} low-information subject(s) dropped` : '') +
|
|
6968
|
-
'.');
|
|
6969
|
-
return { added, skipped, lowInfo };
|
|
6970
|
-
}
|
|
6971
5700
|
function cmdLearn(hippoRoot, flags) {
|
|
6972
5701
|
requireInit(hippoRoot);
|
|
6973
5702
|
if (!flags['git']) {
|
|
@@ -6997,6 +5726,10 @@ function cmdLearn(hippoRoot, flags) {
|
|
|
6997
5726
|
// ---------------------------------------------------------------------------
|
|
6998
5727
|
// Import command
|
|
6999
5728
|
// ---------------------------------------------------------------------------
|
|
5729
|
+
function warnRedacted(count) {
|
|
5730
|
+
if (count)
|
|
5731
|
+
console.error(`Warning: secret-shaped text was redacted from ${count} imported ${count === 1 ? 'entry' : 'entries'} before storing`);
|
|
5732
|
+
}
|
|
7000
5733
|
function cmdImport(hippoRoot, args, flags) {
|
|
7001
5734
|
const useGlobal = Boolean(flags['global']);
|
|
7002
5735
|
const dryRun = Boolean(flags['dry-run']);
|
|
@@ -7077,6 +5810,7 @@ function cmdImport(hippoRoot, args, flags) {
|
|
|
7077
5810
|
if ((vaultResult.rejected ?? 0) > 0) {
|
|
7078
5811
|
console.log(` Rejected (tombstoned): ${vaultResult.rejected}`);
|
|
7079
5812
|
}
|
|
5813
|
+
warnRedacted(vaultResult.redacted);
|
|
7080
5814
|
console.log(` ${dryRun ? 'Would archive: ' : 'Archived (removed): '}${vaultResult.archived ?? 0}`);
|
|
7081
5815
|
console.log(` Store: ${hippoRoot}`);
|
|
7082
5816
|
// Batch producer, same contract as the single-file import below: vault rows
|
|
@@ -7148,6 +5882,7 @@ function cmdImport(hippoRoot, args, flags) {
|
|
|
7148
5882
|
if ((result.rejected ?? 0) > 0) {
|
|
7149
5883
|
console.log(` Rejected (tombstoned): ${result.rejected}`);
|
|
7150
5884
|
}
|
|
5885
|
+
warnRedacted(result.redacted);
|
|
7151
5886
|
if (dryRun) {
|
|
7152
5887
|
console.log('\n (dry run - nothing written)');
|
|
7153
5888
|
if (result.entries.length > 0) {
|
|
@@ -7206,223 +5941,6 @@ function cmdSync(hippoRoot, flags = {}) {
|
|
|
7206
5941
|
// ---------------------------------------------------------------------------
|
|
7207
5942
|
// Hook install/uninstall
|
|
7208
5943
|
// ---------------------------------------------------------------------------
|
|
7209
|
-
const HOOK_MARKERS = {
|
|
7210
|
-
start: '<!-- hippo:start -->',
|
|
7211
|
-
end: '<!-- hippo:end -->',
|
|
7212
|
-
};
|
|
7213
|
-
const HOOKS = {
|
|
7214
|
-
'claude-code': {
|
|
7215
|
-
file: 'CLAUDE.md',
|
|
7216
|
-
description: 'Claude Code',
|
|
7217
|
-
content: `
|
|
7218
|
-
## Project Memory (Hippo)
|
|
7219
|
-
|
|
7220
|
-
Pinned rules and recent writes auto-inject at every prompt via the installed
|
|
7221
|
-
UserPromptSubmit hook; never re-run that part manually. At the START of a
|
|
7222
|
-
task (not per prompt), additionally load task-specific context: git-aware
|
|
7223
|
-
recall over the full store that per-prompt injection does not cover. Also
|
|
7224
|
-
run it if the hook is not installed:
|
|
7225
|
-
\`\`\`bash
|
|
7226
|
-
hippo context --auto --budget 1500
|
|
7227
|
-
\`\`\`
|
|
7228
|
-
|
|
7229
|
-
When you find out why something failed, record it right then, while you
|
|
7230
|
-
work, never as a closing step:
|
|
7231
|
-
\`\`\`bash
|
|
7232
|
-
hippo remember "<what went wrong and why>" --error
|
|
7233
|
-
\`\`\`
|
|
7234
|
-
|
|
7235
|
-
The installed hooks store failed tool calls and capture the session when it
|
|
7236
|
-
ends, so there is nothing to run before you finish.
|
|
7237
|
-
`.trim(),
|
|
7238
|
-
},
|
|
7239
|
-
'codex': {
|
|
7240
|
-
file: 'AGENTS.md',
|
|
7241
|
-
description: 'OpenAI Codex',
|
|
7242
|
-
content: `
|
|
7243
|
-
## Project Memory (Hippo)
|
|
7244
|
-
|
|
7245
|
-
At the start of every task, run:
|
|
7246
|
-
\`\`\`bash
|
|
7247
|
-
hippo context --auto --budget 1500
|
|
7248
|
-
\`\`\`
|
|
7249
|
-
Read the output before writing any code.
|
|
7250
|
-
|
|
7251
|
-
On errors or unexpected behaviour, record it right then, while you work,
|
|
7252
|
-
never as a closing step:
|
|
7253
|
-
\`\`\`bash
|
|
7254
|
-
hippo remember "<description of what went wrong>" --error
|
|
7255
|
-
\`\`\`
|
|
7256
|
-
|
|
7257
|
-
When you learn something that should outlive this session (a decision and
|
|
7258
|
-
its reason, a user preference, a lesson), record it right then, while you
|
|
7259
|
-
work, never as a closing step. Leave out secrets and personal details:
|
|
7260
|
-
\`\`\`bash
|
|
7261
|
-
hippo remember "<what you learned and why>"
|
|
7262
|
-
\`\`\`
|
|
7263
|
-
|
|
7264
|
-
When Hippo's Codex wrapper is installed, session-end capture runs automatically.
|
|
7265
|
-
If the wrapper is not installed, capture a brief summary manually:
|
|
7266
|
-
\`\`\`bash
|
|
7267
|
-
hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
|
|
7268
|
-
\`\`\`
|
|
7269
|
-
`.trim(),
|
|
7270
|
-
},
|
|
7271
|
-
'cursor': {
|
|
7272
|
-
file: 'AGENTS.md',
|
|
7273
|
-
description: 'Cursor',
|
|
7274
|
-
content: `
|
|
7275
|
-
## Project Memory (Hippo)
|
|
7276
|
-
|
|
7277
|
-
At the start of every task, run:
|
|
7278
|
-
\`\`\`bash
|
|
7279
|
-
hippo context --auto --budget 1500
|
|
7280
|
-
\`\`\`
|
|
7281
|
-
Read the output before writing any code.
|
|
7282
|
-
|
|
7283
|
-
On errors or unexpected behaviour, record it right then, while you work,
|
|
7284
|
-
never as a closing step:
|
|
7285
|
-
\`\`\`bash
|
|
7286
|
-
hippo remember "<description of what went wrong>" --error
|
|
7287
|
-
\`\`\`
|
|
7288
|
-
|
|
7289
|
-
When you learn something that should outlive this session (a decision and
|
|
7290
|
-
its reason, a user preference, a lesson), record it right then, while you
|
|
7291
|
-
work, never as a closing step. Leave out secrets and personal details:
|
|
7292
|
-
\`\`\`bash
|
|
7293
|
-
hippo remember "<what you learned and why>"
|
|
7294
|
-
\`\`\`
|
|
7295
|
-
|
|
7296
|
-
When ending a session, capture a brief summary:
|
|
7297
|
-
\`\`\`bash
|
|
7298
|
-
hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
|
|
7299
|
-
\`\`\`
|
|
7300
|
-
`.trim(),
|
|
7301
|
-
},
|
|
7302
|
-
'openclaw': {
|
|
7303
|
-
file: 'AGENTS.md',
|
|
7304
|
-
description: 'OpenClaw',
|
|
7305
|
-
content: `
|
|
7306
|
-
## Project Memory (Hippo)
|
|
7307
|
-
|
|
7308
|
-
At the start of every session, run:
|
|
7309
|
-
\`\`\`bash
|
|
7310
|
-
hippo context --auto --budget 1500
|
|
7311
|
-
\`\`\`
|
|
7312
|
-
Read the output before writing any code.
|
|
7313
|
-
|
|
7314
|
-
On errors or unexpected behaviour, record it right then, while you work,
|
|
7315
|
-
never as a closing step:
|
|
7316
|
-
\`\`\`bash
|
|
7317
|
-
hippo remember "<description of what went wrong>" --error
|
|
7318
|
-
\`\`\`
|
|
7319
|
-
|
|
7320
|
-
When you learn something that should outlive this session (a decision and
|
|
7321
|
-
its reason, a user preference, a lesson), record it right then, while you
|
|
7322
|
-
work, never as a closing step. Leave out secrets and personal details:
|
|
7323
|
-
\`\`\`bash
|
|
7324
|
-
hippo remember "<what you learned and why>"
|
|
7325
|
-
\`\`\`
|
|
7326
|
-
|
|
7327
|
-
When ending a session, capture a brief summary:
|
|
7328
|
-
\`\`\`bash
|
|
7329
|
-
hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
|
|
7330
|
-
\`\`\`
|
|
7331
|
-
`.trim(),
|
|
7332
|
-
},
|
|
7333
|
-
'opencode': {
|
|
7334
|
-
file: 'AGENTS.md',
|
|
7335
|
-
description: 'OpenCode',
|
|
7336
|
-
content: `
|
|
7337
|
-
## Project Memory (Hippo)
|
|
7338
|
-
|
|
7339
|
-
At the start of every task, run:
|
|
7340
|
-
\`\`\`bash
|
|
7341
|
-
hippo context --auto --budget 1500
|
|
7342
|
-
\`\`\`
|
|
7343
|
-
Read the output before writing any code.
|
|
7344
|
-
|
|
7345
|
-
On errors or unexpected behaviour, record it right then, while you work,
|
|
7346
|
-
never as a closing step:
|
|
7347
|
-
\`\`\`bash
|
|
7348
|
-
hippo remember "<description of what went wrong>" --error
|
|
7349
|
-
\`\`\`
|
|
7350
|
-
|
|
7351
|
-
When you learn something that should outlive this session (a decision and
|
|
7352
|
-
its reason, a user preference, a lesson), record it right then, while you
|
|
7353
|
-
work, never as a closing step. Leave out secrets and personal details:
|
|
7354
|
-
\`\`\`bash
|
|
7355
|
-
hippo remember "<what you learned and why>"
|
|
7356
|
-
\`\`\`
|
|
7357
|
-
|
|
7358
|
-
When stuck or repeating yourself, check if this happened before:
|
|
7359
|
-
\`\`\`bash
|
|
7360
|
-
hippo recall "<what's going wrong>" --budget 2000
|
|
7361
|
-
\`\`\`
|
|
7362
|
-
|
|
7363
|
-
When ending a session, capture a brief summary:
|
|
7364
|
-
\`\`\`bash
|
|
7365
|
-
hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
|
|
7366
|
-
\`\`\`
|
|
7367
|
-
`.trim(),
|
|
7368
|
-
},
|
|
7369
|
-
'pi': {
|
|
7370
|
-
file: 'AGENTS.md',
|
|
7371
|
-
description: 'Pi',
|
|
7372
|
-
content: `
|
|
7373
|
-
## Project Memory (Hippo)
|
|
7374
|
-
|
|
7375
|
-
At the start of every session, run:
|
|
7376
|
-
\`\`\`bash
|
|
7377
|
-
hippo context --auto --budget 1500
|
|
7378
|
-
\`\`\`
|
|
7379
|
-
Read the output before writing any code.
|
|
7380
|
-
|
|
7381
|
-
On errors or unexpected behaviour, record it right then, while you work,
|
|
7382
|
-
never as a closing step:
|
|
7383
|
-
\`\`\`bash
|
|
7384
|
-
hippo remember "<description of what went wrong>" --error
|
|
7385
|
-
\`\`\`
|
|
7386
|
-
|
|
7387
|
-
When you learn something that should outlive this session (a decision and
|
|
7388
|
-
its reason, a user preference, a lesson), record it right then, while you
|
|
7389
|
-
work, never as a closing step. Leave out secrets and personal details:
|
|
7390
|
-
\`\`\`bash
|
|
7391
|
-
hippo remember "<what you learned and why>"
|
|
7392
|
-
\`\`\`
|
|
7393
|
-
|
|
7394
|
-
When ending a session, capture a brief summary:
|
|
7395
|
-
\`\`\`bash
|
|
7396
|
-
hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
|
|
7397
|
-
\`\`\`
|
|
7398
|
-
|
|
7399
|
-
For full integration, copy the hippo-memory Pi extension to \`~/.pi/agent/extensions/hippo-memory/\`.
|
|
7400
|
-
`.trim(),
|
|
7401
|
-
},
|
|
7402
|
-
};
|
|
7403
|
-
// sha256 of each trimmed block an earlier hippo wrote, so init refreshes only blocks nobody edited. Add the old hash when a block changes.
|
|
7404
|
-
const SHIPPED_HOOK_HASHES = new Map([
|
|
7405
|
-
['c04e48f2896a4fee9ae98f8f832e2d26a3910269df3beb5bcd6baee3cd9db68e', 'claude-code'],
|
|
7406
|
-
['e6b12bd8983c032e5ca8e95a97aeff4178a5a05026d10acad5b2e1b25d5656dd', 'claude-code'],
|
|
7407
|
-
['4c64e11d3e5be68fa547c9248d7553feb645a02f7cf13ba02f7275e1854baf44', 'claude-code'],
|
|
7408
|
-
['293bd319bbc86225a0ee027490a3322a0336257f5832f7fade65e4ffb2530654', 'claude-code'],
|
|
7409
|
-
['15abcece9712279fb4721f7a8f0ba117457400278977beb5cf5b5d7ba49f7b1a', 'codex'],
|
|
7410
|
-
['0c81a6b2c21473313001f624b80ea870e661aecbfda9bfe8503febc0d5f34533', 'codex'],
|
|
7411
|
-
['88e45358aba4f17912f113221c991dc758275991335d1daa4aa1974a69c46769', 'codex'],
|
|
7412
|
-
['e61632fe177450a06541c148a9a4f9182530d8df667806927a99792825903298', 'codex'],
|
|
7413
|
-
['a1415ecda9b2f8f317c233738e4a5ac16e6b2cc385a017c0c8ecfbfacbcab6a3', 'cursor'],
|
|
7414
|
-
['a38c428bbdfc14ec50f6f7b9183785170a4eae1ce9cde60257cca6efc7206b3a', 'cursor'],
|
|
7415
|
-
['0ec9f556abfd55e94f9e6fb47ece0fc5acb841977d144b35a2371e03645d8636', 'cursor'],
|
|
7416
|
-
['40524c3bd5a2eb04036567cc761451961d950995768bccd93a9900b0f75eafea', 'openclaw'],
|
|
7417
|
-
['7b3518e8c0feaa7b8b454cde7743f7598ad14cd9979e1680d0954484e2464aae', 'openclaw'],
|
|
7418
|
-
['1137dcf04568caf011e41db77bc55324faee88bc29c3a5fcc98ab687cd952a16', 'openclaw'],
|
|
7419
|
-
['4601c67c31f41cd5b1324cfccdb1afc66872b7fb0bc1e7c5789ecabb1f6bd942', 'opencode'],
|
|
7420
|
-
['90d9e21d8d1ecbe99a0fc7b7f2d9f8af7b5315a6b4b0203df4f7a9bdc0699b98', 'opencode'],
|
|
7421
|
-
['ca4e00284f1397ed2f2fcc53210c27f63b90edf6b37fd66dad5ee58b94ea3eee', 'opencode'],
|
|
7422
|
-
['8b8f5986d7f7ed15f06e68720d8913c3cab23d94366b411935ca2bbaa334553b', 'pi'],
|
|
7423
|
-
['37767b355e18beac726b05b9e2b898dab8c6135fd7b98f3aa52edc734d5dd283', 'pi'],
|
|
7424
|
-
['6e85a5cccb3cfeaa9a080713754936db730f96376f94cc9a9888a746149c7268', 'pi'],
|
|
7425
|
-
]);
|
|
7426
5944
|
function cmdHook(args, flags) {
|
|
7427
5945
|
const subcommand = args[0];
|
|
7428
5946
|
const target = args[1];
|
|
@@ -8001,14 +6519,6 @@ function cmdDrillDown(hippoRoot, summaryId, flags) {
|
|
|
8001
6519
|
// ---------------------------------------------------------------------------
|
|
8002
6520
|
// Auth subcommands (A5 stub auth)
|
|
8003
6521
|
// ---------------------------------------------------------------------------
|
|
8004
|
-
function resolveAuthRoot(hippoRoot, flags) {
|
|
8005
|
-
if (flags['global']) {
|
|
8006
|
-
initGlobal();
|
|
8007
|
-
return getGlobalRoot();
|
|
8008
|
-
}
|
|
8009
|
-
requireInit(hippoRoot);
|
|
8010
|
-
return hippoRoot;
|
|
8011
|
-
}
|
|
8012
6522
|
function cmdAuthCreate(hippoRoot, flags) {
|
|
8013
6523
|
const root = resolveAuthRoot(hippoRoot, flags);
|
|
8014
6524
|
const tenantFlag = typeof flags['tenant'] === 'string' ? flags['tenant'] : undefined;
|
|
@@ -8088,53 +6598,31 @@ function cmdAuthList(hippoRoot, flags) {
|
|
|
8088
6598
|
}
|
|
8089
6599
|
function cmdAuthRevoke(hippoRoot, keyId, flags) {
|
|
8090
6600
|
const root = resolveAuthRoot(hippoRoot, flags);
|
|
8091
|
-
|
|
6601
|
+
// The local CLI owns every tenant, so the revoke runs in the key's own tenant.
|
|
8092
6602
|
const db = openHippoDb(root);
|
|
8093
|
-
let
|
|
8094
|
-
let alreadyRevoked = false;
|
|
8095
|
-
let revokedAt = null;
|
|
8096
|
-
let keyTenantId = null;
|
|
6603
|
+
let keyTenant;
|
|
8097
6604
|
try {
|
|
8098
|
-
|
|
8099
|
-
|
|
8100
|
-
|
|
8101
|
-
// process.exit() — the finally already handles it on every path.
|
|
8102
|
-
console.error(`Unknown key_id: ${keyId}`);
|
|
8103
|
-
process.exit(1);
|
|
8104
|
-
}
|
|
8105
|
-
exists = true;
|
|
8106
|
-
keyTenantId = row.tenant_id;
|
|
8107
|
-
if (row.revoked_at) {
|
|
8108
|
-
alreadyRevoked = true;
|
|
8109
|
-
revokedAt = row.revoked_at;
|
|
8110
|
-
}
|
|
8111
|
-
else {
|
|
8112
|
-
revokeApiKey(db, keyId);
|
|
8113
|
-
const updated = db.prepare(`SELECT revoked_at FROM api_keys WHERE key_id = ?`).get(keyId);
|
|
8114
|
-
revokedAt = updated?.revoked_at ?? null;
|
|
8115
|
-
}
|
|
8116
|
-
// M1: emit auth_revoke audit event. Skip on no-op revoke (already revoked)
|
|
8117
|
-
// so re-running the command doesn't pad the audit log with duplicates.
|
|
8118
|
-
if (!alreadyRevoked && keyTenantId) {
|
|
8119
|
-
try {
|
|
8120
|
-
appendAuditEvent(db, {
|
|
8121
|
-
tenantId: keyTenantId,
|
|
8122
|
-
actor: 'cli',
|
|
8123
|
-
op: 'auth_revoke',
|
|
8124
|
-
targetId: keyId,
|
|
8125
|
-
});
|
|
8126
|
-
}
|
|
8127
|
-
catch {
|
|
8128
|
-
// Audit must not crash a successful revoke.
|
|
8129
|
-
}
|
|
8130
|
-
}
|
|
6605
|
+
// SAFETY: row's shape matches the single tenant_id column in the SELECT.
|
|
6606
|
+
const row = db.prepare(`SELECT tenant_id FROM api_keys WHERE key_id = ?`).get(keyId);
|
|
6607
|
+
keyTenant = row?.tenant_id;
|
|
8131
6608
|
}
|
|
8132
6609
|
finally {
|
|
8133
6610
|
closeHippoDb(db);
|
|
8134
6611
|
}
|
|
8135
|
-
if (
|
|
8136
|
-
|
|
8137
|
-
|
|
6612
|
+
if (keyTenant === undefined) {
|
|
6613
|
+
console.error(`Unknown key_id: ${keyId}`);
|
|
6614
|
+
process.exit(1);
|
|
6615
|
+
}
|
|
6616
|
+
const ctx = { hippoRoot: root, tenantId: keyTenant, actor: api.adminActor('cli') };
|
|
6617
|
+
let revokedAt;
|
|
6618
|
+
try {
|
|
6619
|
+
revokedAt = api.authRevoke(ctx, keyId).revokedAt;
|
|
6620
|
+
}
|
|
6621
|
+
catch (err) {
|
|
6622
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
6623
|
+
process.exit(1);
|
|
6624
|
+
}
|
|
6625
|
+
if (flags['json']) {
|
|
8138
6626
|
console.log(JSON.stringify({ keyId, revokedAt }));
|
|
8139
6627
|
return;
|
|
8140
6628
|
}
|
|
@@ -9488,6 +7976,8 @@ async function main(command, args, flags, hippoRoot) {
|
|
|
9488
7976
|
});
|
|
9489
7977
|
console.log(`Remembered [${result.id}] (via ${info.url})`);
|
|
9490
7978
|
console.log(` Kind: ${result.kind} | Tenant: ${result.tenantId}`);
|
|
7979
|
+
for (const w of result.warnings ?? [])
|
|
7980
|
+
console.error(`Warning: ${w}`);
|
|
9491
7981
|
});
|
|
9492
7982
|
if (remembered)
|
|
9493
7983
|
break;
|
|
@@ -9564,7 +8054,7 @@ async function main(command, args, flags, hippoRoot) {
|
|
|
9564
8054
|
await cmdRefine(hippoRoot, flags);
|
|
9565
8055
|
break;
|
|
9566
8056
|
case 'sleep':
|
|
9567
|
-
await cmdSleep(hippoRoot, flags);
|
|
8057
|
+
await (await import('./cli/sleep.js')).cmdSleep(hippoRoot, flags);
|
|
9568
8058
|
break;
|
|
9569
8059
|
case 'last-sleep':
|
|
9570
8060
|
cmdLastSleep(flags);
|
|
@@ -9578,11 +8068,13 @@ async function main(command, args, flags, hippoRoot) {
|
|
|
9578
8068
|
case 'pre-compact': {
|
|
9579
8069
|
// Bounded wait, not a TTY guard: an idle non-TTY pipe must not hang.
|
|
9580
8070
|
const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
|
|
9581
|
-
|
|
9582
|
-
|
|
9583
|
-
|
|
9584
|
-
|
|
9585
|
-
|
|
8071
|
+
await runHookWithStores(async () => {
|
|
8072
|
+
resetHookInjection(hippoRoot, stdinText, null);
|
|
8073
|
+
await cmdPreCompact(hookStoreRoot(hippoRoot), {
|
|
8074
|
+
stdinText,
|
|
8075
|
+
stdinTimedOut,
|
|
8076
|
+
logFile: typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined,
|
|
8077
|
+
});
|
|
9586
8078
|
});
|
|
9587
8079
|
break;
|
|
9588
8080
|
}
|
|
@@ -9591,7 +8083,7 @@ async function main(command, args, flags, hippoRoot) {
|
|
|
9591
8083
|
const { text } = await readStdinBounded();
|
|
9592
8084
|
const logFlag = flags['log-file'];
|
|
9593
8085
|
const store = hookStoreRoot(hippoRoot);
|
|
9594
|
-
const line = cmdPostCompact(store, {
|
|
8086
|
+
const line = await runHookWithStores(() => cmdPostCompact(store, {
|
|
9595
8087
|
stdinText: text,
|
|
9596
8088
|
logFile: logFlag === true || logFlag === false || Array.isArray(logFlag) ? undefined : logFlag,
|
|
9597
8089
|
// Passed in, since capture.ts importing the sync would close an import cycle.
|
|
@@ -9603,8 +8095,8 @@ async function main(command, args, flags, hippoRoot) {
|
|
|
9603
8095
|
for (const warning of report.warnings)
|
|
9604
8096
|
log(`agent memories: ${warning}`);
|
|
9605
8097
|
},
|
|
9606
|
-
});
|
|
9607
|
-
if (line !== null)
|
|
8098
|
+
}));
|
|
8099
|
+
if (line !== null && line !== undefined)
|
|
9608
8100
|
console.log(line);
|
|
9609
8101
|
break;
|
|
9610
8102
|
}
|
|
@@ -9617,7 +8109,8 @@ async function main(command, args, flags, hippoRoot) {
|
|
|
9617
8109
|
const payload = (text ?? '').trim();
|
|
9618
8110
|
if (isInitialized(root) && payload) {
|
|
9619
8111
|
// SAFETY: JSON.parse returns a JSON value by definition.
|
|
9620
|
-
|
|
8112
|
+
const failure = JSON.parse(payload);
|
|
8113
|
+
await runHookWithStores(() => captureToolFailure(root, resolveTenantId({}), failure));
|
|
9621
8114
|
}
|
|
9622
8115
|
}
|
|
9623
8116
|
catch {
|
|
@@ -9627,8 +8120,10 @@ async function main(command, args, flags, hippoRoot) {
|
|
|
9627
8120
|
}
|
|
9628
8121
|
case 'compact-resume': {
|
|
9629
8122
|
const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
|
|
9630
|
-
|
|
9631
|
-
|
|
8123
|
+
await runHookWithStores(() => {
|
|
8124
|
+
resetHookInjection(hippoRoot, stdinText, 'compact');
|
|
8125
|
+
cmdCompactResume(hookStoreRoot(hippoRoot), stdinText, stdinTimedOut);
|
|
8126
|
+
});
|
|
9632
8127
|
break;
|
|
9633
8128
|
}
|
|
9634
8129
|
case 'codex-run':
|
|
@@ -9903,7 +8398,7 @@ async function main(command, args, flags, hippoRoot) {
|
|
|
9903
8398
|
// Bounded, not a TTY guard (DF1 T2, docs/plans/2026-08-23-df1-snapshot-lifecycle.md):
|
|
9904
8399
|
// the hot stdin path and a manual run share this one command.
|
|
9905
8400
|
const { text: stdinText } = await readStdinBounded();
|
|
9906
|
-
await cmdContext(hookStoreRoot(hippoRoot), args, flags, stdinText);
|
|
8401
|
+
await runHookWithStores(() => cmdContext(hookStoreRoot(hippoRoot), args, flags, stdinText));
|
|
9907
8402
|
break;
|
|
9908
8403
|
}
|
|
9909
8404
|
case 'hook':
|