hippo-memory 0.37.0 → 0.38.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/dist/src/cli.js CHANGED
@@ -41,6 +41,8 @@ import { loadPhysicsState, resetAllPhysicsState } from './physics-state.js';
41
41
  import { computeSystemEnergy, vecNorm } from './physics.js';
42
42
  import { loadConfig } from './config.js';
43
43
  import { openHippoDb, closeHippoDb } from './db.js';
44
+ import { getActiveGoalsWithDb, MAX_FINAL_MULTIPLIER, pushGoal, getActiveGoals, completeGoal, suspendGoal, resumeGoal } from './goals.js';
45
+ import { rowToGoal } from './goals.js';
44
46
  import { captureError, extractLessons, deduplicateLesson, runWatched, fetchGitLog, isGitRepo, } from './autolearn.js';
45
47
  import { extractInvalidationTarget, invalidateMatching } from './invalidation.js';
46
48
  import { extractPathTags } from './path-context.js';
@@ -820,6 +822,142 @@ async function cmdRecall(hippoRoot, query, flags) {
820
822
  .map((r) => (r.entry.tags?.includes(goalTag) ? { ...r, score: r.score * 1.5 } : r))
821
823
  .sort((a, b) => b.score - a.score);
822
824
  }
825
+ // dlPFC depth (B3, v0.38). When HIPPO_SESSION_ID is set (env or
826
+ // --session-id flag) and the (tenant, session) has active goals, boost
827
+ // memories whose tags overlap any active goal's name. Final multiplier is
828
+ // hard-capped at MAX_FINAL_MULTIPLIER (3.0x). Each boosted (memory, goal)
829
+ // pair is logged into goal_recall_log for outcome propagation.
830
+ //
831
+ // Runs AFTER the explicit `--goal <tag>` block so an explicit flag always
832
+ // wins: if the user passed `--goal X`, this block is skipped entirely
833
+ // (gated on `goalTag === ''`).
834
+ //
835
+ // db-handle note (plan-eng-review fix #5): the surrounding cmdRecall path
836
+ // does NOT keep an open db handle in scope at this point — earlier search
837
+ // helpers (loadSearchEntries, hybridSearch, ...) each open and close their
838
+ // own short-lived handles. Reusing isn't practical here; we open a fresh
839
+ // short-lived handle for this block, mirroring the existing CLI pattern
840
+ // (e.g. emitCliAudit). Closed in `finally`.
841
+ const sessionId = (flags['session-id'] !== undefined
842
+ ? String(flags['session-id'])
843
+ : process.env.HIPPO_SESSION_ID ?? '').trim();
844
+ if (sessionId && goalTag === '') {
845
+ // Use the same tenant as the recall path — see cmdRecall:778.
846
+ const tenantIdForGoals = tenantId;
847
+ const dbForGoals = openHippoDb(hippoRoot);
848
+ try {
849
+ const active = getActiveGoalsWithDb(dbForGoals, {
850
+ sessionId,
851
+ tenantId: tenantIdForGoals,
852
+ });
853
+ if (active.length > 0) {
854
+ const goalsByTag = new Map(active.map((g) => [g.goalName, g]));
855
+ // Task 7: load retrieval_policy rows for active goals so per-policy
856
+ // multipliers can compose onto the base goal-tag boost. The composed
857
+ // result is hard-capped at MAX_FINAL_MULTIPLIER (3.0x) BEFORE applying
858
+ // to score — even an `errorPriority: 9.0` policy cannot exceed 3.0x.
859
+ const policiesByGoalId = new Map();
860
+ for (const g of active) {
861
+ if (!g.retrievalPolicyId)
862
+ continue;
863
+ const row = dbForGoals.prepare(`
864
+ SELECT id, goal_id, policy_type, weight_schema_fit, weight_recency, weight_outcome, error_priority
865
+ FROM retrieval_policy WHERE id = ?
866
+ `).get(g.retrievalPolicyId);
867
+ if (row) {
868
+ policiesByGoalId.set(g.id, {
869
+ id: row.id,
870
+ goalId: row.goal_id,
871
+ policyType: row.policy_type,
872
+ weightSchemaFit: row.weight_schema_fit,
873
+ weightRecency: row.weight_recency,
874
+ weightOutcome: row.weight_outcome,
875
+ errorPriority: row.error_priority,
876
+ });
877
+ }
878
+ }
879
+ results = results
880
+ .map((r) => {
881
+ const tags = r.entry.tags ?? [];
882
+ const matches = tags.filter((t) => goalsByTag.has(t));
883
+ if (matches.length === 0)
884
+ return r;
885
+ // Base 2.0x for first match, +0.5x per additional, capped at 3.0x.
886
+ let multiplier = Math.min(2.0 + 0.5 * (matches.length - 1), MAX_FINAL_MULTIPLIER);
887
+ // Compose per-policy multipliers per matched tag.
888
+ for (const tag of matches) {
889
+ const goal = goalsByTag.get(tag);
890
+ const policy = policiesByGoalId.get(goal.id);
891
+ if (!policy)
892
+ continue;
893
+ if (policy.policyType === 'error-prioritized' && tags.includes('error')) {
894
+ multiplier *= policy.errorPriority;
895
+ }
896
+ else if (policy.policyType === 'schema-fit-biased') {
897
+ // Linearly weight schema_fit in [0,1] up to (weightSchemaFit)x.
898
+ // Default 1.0 is a no-op.
899
+ multiplier *=
900
+ 1.0 +
901
+ Math.max(0, policy.weightSchemaFit - 1.0) *
902
+ (r.entry.schema_fit ?? 0.5);
903
+ }
904
+ else if (policy.policyType === 'recency-first') {
905
+ multiplier *= policy.weightRecency;
906
+ }
907
+ else if (policy.policyType === 'hybrid') {
908
+ multiplier *= policy.weightOutcome;
909
+ }
910
+ }
911
+ // Hard cap AFTER all composition.
912
+ multiplier = Math.min(multiplier, MAX_FINAL_MULTIPLIER);
913
+ return {
914
+ ...r,
915
+ score: r.score * multiplier,
916
+ _goalMatches: matches,
917
+ };
918
+ })
919
+ .sort((a, b) => b.score - a.score);
920
+ // Filter to local memories only — global memory IDs aren't in this
921
+ // DB's memories table, so the FK on goal_recall_log.memory_id would
922
+ // fail. dlPFC depth's outcome propagation is session-scoped to local;
923
+ // boost on ranking still applies to global results, just no log row
924
+ // -> no propagation.
925
+ const topKIds = results.slice(0, limit).map((r) => r.entry.id);
926
+ const localIds = new Set();
927
+ if (topKIds.length > 0) {
928
+ const placeholders = topKIds.map(() => '?').join(',');
929
+ const localRows = dbForGoals.prepare(`SELECT id FROM memories WHERE id IN (${placeholders})`).all(...topKIds);
930
+ for (const row of localRows)
931
+ localIds.add(row.id);
932
+ }
933
+ // Log top-K boosted recalls. INSERT OR IGNORE because
934
+ // UNIQUE(memory_id, goal_id) means a re-recall during the same goal
935
+ // life is a no-op for outcome attribution.
936
+ const recalledAt = new Date().toISOString();
937
+ const insertLog = dbForGoals.prepare(`
938
+ INSERT OR IGNORE INTO goal_recall_log
939
+ (goal_id, memory_id, tenant_id, session_id, recalled_at, score)
940
+ VALUES (?, ?, ?, ?, ?, ?)
941
+ `);
942
+ for (const r of results.slice(0, limit)) {
943
+ if (!localIds.has(r.entry.id))
944
+ continue; // global -> skip log insert
945
+ const matches = r._goalMatches;
946
+ if (!matches || matches.length === 0)
947
+ continue;
948
+ for (const tag of matches) {
949
+ const goal = goalsByTag.get(tag);
950
+ if (!goal)
951
+ continue;
952
+ insertLog.run(goal.id, r.entry.id, tenantIdForGoals, sessionId, recalledAt, r.score);
953
+ }
954
+ }
955
+ }
956
+ }
957
+ finally {
958
+ closeHippoDb(dbForGoals);
959
+ }
960
+ }
823
961
  // Pineal salience MVP (RESEARCH.md §"AI Pineal Gland — Intuition and Awareness
824
962
  // Module"). When --salience-threshold T is set (T > 0), memories whose
825
963
  // retrieval_count is below T are downweighted: score *= max(0.5, count / T).
@@ -4030,6 +4168,211 @@ function cmdAuditLog(hippoRoot, args, flags) {
4030
4168
  console.error(`Unknown audit subcommand: ${sub}. Expected: list.`);
4031
4169
  process.exit(1);
4032
4170
  }
4171
+ // ---------------------------------------------------------------------------
4172
+ // `hippo goal <push|list|complete|suspend|resume>` — B3 dlPFC depth (Task 10)
4173
+ // ---------------------------------------------------------------------------
4174
+ const GOAL_POLICY_TYPES = [
4175
+ 'schema-fit-biased',
4176
+ 'error-prioritized',
4177
+ 'recency-first',
4178
+ 'hybrid',
4179
+ ];
4180
+ function sanitizeGoalName(s) {
4181
+ // Strip C0 control chars + DEL to prevent terminal escape injection.
4182
+ return s.replace(/[\x00-\x1f\x7f]/g, '?');
4183
+ }
4184
+ function resolveGoalSession(flags) {
4185
+ const sessionId = (flags['session-id'] !== undefined
4186
+ ? String(flags['session-id'])
4187
+ : process.env.HIPPO_SESSION_ID ?? '').trim();
4188
+ if (!sessionId) {
4189
+ console.error('session id required (set HIPPO_SESSION_ID or pass --session-id)');
4190
+ process.exit(1);
4191
+ }
4192
+ const tenantId = (flags['tenant-id'] !== undefined
4193
+ ? String(flags['tenant-id'])
4194
+ : process.env.HIPPO_TENANT ?? 'default').trim() || 'default';
4195
+ return { sessionId, tenantId };
4196
+ }
4197
+ function cmdGoalPush(hippoRoot, args, flags) {
4198
+ const rawName = args.join(' ').trim();
4199
+ if (!rawName) {
4200
+ console.error('Usage: hippo goal push <name> [--policy <type>] [--success "<condition>"] [--level N] [--parent <goalId>]');
4201
+ process.exit(1);
4202
+ }
4203
+ // Sanitize at WRITE time so corrupt names never enter the DB.
4204
+ const name = sanitizeGoalName(rawName);
4205
+ if (name !== rawName) {
4206
+ console.error('note: stripped control characters from goal name');
4207
+ }
4208
+ const { sessionId, tenantId } = resolveGoalSession(flags);
4209
+ let policy;
4210
+ const policyRaw = flags['policy'];
4211
+ if (policyRaw === true) {
4212
+ console.error('--policy requires a value (e.g., --policy error-prioritized)');
4213
+ process.exit(1);
4214
+ }
4215
+ if (typeof policyRaw === 'string') {
4216
+ if (!GOAL_POLICY_TYPES.includes(policyRaw)) {
4217
+ console.error(`Unknown --policy '${policyRaw}'. Expected one of: ${GOAL_POLICY_TYPES.join(' | ')}.`);
4218
+ process.exit(1);
4219
+ }
4220
+ policy = { policyType: policyRaw };
4221
+ }
4222
+ const successRaw = flags['success'];
4223
+ if (successRaw === true) {
4224
+ console.error('--success requires a value (e.g., --success "<condition>")');
4225
+ process.exit(1);
4226
+ }
4227
+ const successCondition = typeof successRaw === 'string' ? successRaw : undefined;
4228
+ const levelRaw = flags['level'];
4229
+ let level;
4230
+ if (levelRaw === true) {
4231
+ console.error('--level requires a value (e.g., --level 1)');
4232
+ process.exit(1);
4233
+ }
4234
+ if (levelRaw !== undefined) {
4235
+ const parsed = Number(levelRaw);
4236
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 2 || !Number.isInteger(parsed)) {
4237
+ console.error('--level must be an integer in [0, 2]');
4238
+ process.exit(1);
4239
+ }
4240
+ level = parsed;
4241
+ }
4242
+ const parentRaw = flags['parent'];
4243
+ if (parentRaw === true) {
4244
+ console.error('--parent requires a value (e.g., --parent <goalId>)');
4245
+ process.exit(1);
4246
+ }
4247
+ const parentGoalId = typeof parentRaw === 'string' ? parentRaw : undefined;
4248
+ const goal = pushGoal(hippoRoot, {
4249
+ sessionId,
4250
+ tenantId,
4251
+ goalName: name,
4252
+ level,
4253
+ parentGoalId,
4254
+ successCondition,
4255
+ policy,
4256
+ });
4257
+ console.log(goal.id);
4258
+ }
4259
+ function listAllGoals(hippoRoot, sessionId, tenantId) {
4260
+ const db = openHippoDb(hippoRoot);
4261
+ try {
4262
+ const rows = db.prepare(`
4263
+ SELECT id, session_id, tenant_id, goal_name, level, parent_goal_id, status,
4264
+ success_condition, retrieval_policy_id, created_at, completed_at, outcome_score
4265
+ FROM goal_stack
4266
+ WHERE tenant_id = ? AND session_id = ?
4267
+ ORDER BY created_at ASC
4268
+ `).all(tenantId, sessionId);
4269
+ return rows.map(rowToGoal);
4270
+ }
4271
+ finally {
4272
+ closeHippoDb(db);
4273
+ }
4274
+ }
4275
+ function cmdGoalList(hippoRoot, flags) {
4276
+ const { sessionId, tenantId } = resolveGoalSession(flags);
4277
+ const showAll = Boolean(flags['all']);
4278
+ const goals = showAll
4279
+ ? listAllGoals(hippoRoot, sessionId, tenantId)
4280
+ : getActiveGoals(hippoRoot, { sessionId, tenantId });
4281
+ if (goals.length === 0) {
4282
+ console.log('(no goals)');
4283
+ return;
4284
+ }
4285
+ // 4-column table: id, status, goal_name, outcome. Plan calls it a "2-column"
4286
+ // table but the assertion list (id, status, goal_name, outcome) needs four;
4287
+ // tests check for substrings ('active', '0.9', name) so column count is
4288
+ // observably four but not asserted.
4289
+ const rows = goals.map(g => ({
4290
+ id: g.id,
4291
+ status: g.status,
4292
+ name: sanitizeGoalName(g.goalName),
4293
+ outcome: g.outcomeScore !== undefined ? g.outcomeScore.toString() : '-',
4294
+ }));
4295
+ const widths = {
4296
+ id: Math.max(2, ...rows.map(r => r.id.length)),
4297
+ status: Math.max(6, ...rows.map(r => r.status.length)),
4298
+ name: Math.max(4, ...rows.map(r => r.name.length)),
4299
+ outcome: Math.max(7, ...rows.map(r => r.outcome.length)),
4300
+ };
4301
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
4302
+ console.log(`${pad('id', widths.id)} ${pad('status', widths.status)} ${pad('name', widths.name)} ${pad('outcome', widths.outcome)}`);
4303
+ for (const r of rows) {
4304
+ console.log(`${pad(r.id, widths.id)} ${pad(r.status, widths.status)} ${pad(r.name, widths.name)} ${pad(r.outcome, widths.outcome)}`);
4305
+ }
4306
+ }
4307
+ function cmdGoalComplete(hippoRoot, args, flags) {
4308
+ const id = args[0];
4309
+ if (!id) {
4310
+ console.error('Usage: hippo goal complete <id> [--outcome <0..1>]');
4311
+ process.exit(1);
4312
+ }
4313
+ let outcomeScore;
4314
+ const outcomeRaw = flags['outcome'];
4315
+ if (outcomeRaw === true) {
4316
+ console.error('--outcome requires a value (e.g., --outcome 0.9)');
4317
+ process.exit(1);
4318
+ }
4319
+ if (outcomeRaw !== undefined) {
4320
+ const parsed = Number(outcomeRaw);
4321
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
4322
+ console.error('--outcome must be a number in [0, 1]');
4323
+ process.exit(1);
4324
+ }
4325
+ outcomeScore = parsed;
4326
+ }
4327
+ completeGoal(hippoRoot, id, { outcomeScore });
4328
+ console.log('ok');
4329
+ }
4330
+ function cmdGoalSuspend(hippoRoot, args) {
4331
+ const id = args[0];
4332
+ if (!id) {
4333
+ console.error('Usage: hippo goal suspend <id>');
4334
+ process.exit(1);
4335
+ }
4336
+ suspendGoal(hippoRoot, id);
4337
+ console.log('ok');
4338
+ }
4339
+ function cmdGoalResume(hippoRoot, args) {
4340
+ const id = args[0];
4341
+ if (!id) {
4342
+ console.error('Usage: hippo goal resume <id>');
4343
+ process.exit(1);
4344
+ }
4345
+ resumeGoal(hippoRoot, id);
4346
+ console.log('ok');
4347
+ }
4348
+ function cmdGoal(hippoRoot, args, flags) {
4349
+ const sub = args[0];
4350
+ if (!sub) {
4351
+ console.error('Usage: hippo goal <push|list|complete|suspend|resume> [args]');
4352
+ process.exit(1);
4353
+ }
4354
+ const subArgs = args.slice(1);
4355
+ switch (sub) {
4356
+ case 'push':
4357
+ cmdGoalPush(hippoRoot, subArgs, flags);
4358
+ return;
4359
+ case 'list':
4360
+ cmdGoalList(hippoRoot, flags);
4361
+ return;
4362
+ case 'complete':
4363
+ cmdGoalComplete(hippoRoot, subArgs, flags);
4364
+ return;
4365
+ case 'suspend':
4366
+ cmdGoalSuspend(hippoRoot, subArgs);
4367
+ return;
4368
+ case 'resume':
4369
+ cmdGoalResume(hippoRoot, subArgs);
4370
+ return;
4371
+ default:
4372
+ console.error(`Unknown goal subcommand: ${sub}. Expected: push | list | complete | suspend | resume.`);
4373
+ process.exit(1);
4374
+ }
4375
+ }
4033
4376
  function cmdAuth(hippoRoot, args, flags) {
4034
4377
  const sub = args[0];
4035
4378
  if (!sub) {
@@ -4185,6 +4528,12 @@ Commands:
4185
4528
  --goal <tag> dlPFC goal-conditioned recall: memories tagged with
4186
4529
  the goal tag get a 1.5x score boost and results are
4187
4530
  re-sorted. Default off. RESEARCH.md §PFC.dlPFC.
4531
+ --session-id <id> Session identifier for dlPFC goal-stack boost.
4532
+ Defaults to \$HIPPO_SESSION_ID. When set and the
4533
+ (tenant, session) has active goals (see
4534
+ 'hippo goal push'), recall auto-boosts memories
4535
+ whose tags match an active goal name. Boost stacks
4536
+ on top of base BM25 score, capped at 3.0x.
4188
4537
  --salience-threshold <n>
4189
4538
  Pineal salience: down-weight memories whose
4190
4539
  retrieval_count is below n. score *= max(0.5,
@@ -4370,6 +4719,21 @@ Commands:
4370
4719
  dashboard Open web dashboard for memory health
4371
4720
  --port <n> Port to serve on (default: 3333)
4372
4721
  mcp Start MCP server (stdio transport)
4722
+ goal <sub> dlPFC goal stack (B3) — scoped per session
4723
+ goal push <name> Push a new active goal; prints the new goal id
4724
+ --policy <type> schema-fit-biased | error-prioritized |
4725
+ recency-first | hybrid
4726
+ --success "<cond>" Optional success condition text
4727
+ --level <n> Goal level (default: 0)
4728
+ --parent <goalId> Parent goal id (for sub-goals)
4729
+ --session-id <s> Override session (defaults to HIPPO_SESSION_ID)
4730
+ --tenant-id <t> Override tenant (defaults to HIPPO_TENANT)
4731
+ goal list Show active goals as a table
4732
+ --all Include suspended/completed goals
4733
+ goal complete <id> Mark a goal completed
4734
+ --outcome <0..1> Outcome score; >=0.7 boosts, <0.3 decays recalled mems
4735
+ goal suspend <id> Move an active goal to suspended
4736
+ goal resume <id> Move a suspended goal back to active (depth-capped)
4373
4737
  auth <sub> Manage API keys (A5 stub auth)
4374
4738
  auth create Mint a new API key (plaintext shown ONCE)
4375
4739
  --label <s> Optional human label
@@ -4567,6 +4931,9 @@ async function main() {
4567
4931
  case 'auth':
4568
4932
  cmdAuth(hippoRoot, args, flags);
4569
4933
  break;
4934
+ case 'goal':
4935
+ cmdGoal(hippoRoot, args, flags);
4936
+ break;
4570
4937
  case 'slack':
4571
4938
  cmdSlack(hippoRoot, args, flags);
4572
4939
  break;