hippo-memory 1.52.7 → 1.52.8

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/dashboard.js CHANGED
@@ -5,8 +5,10 @@
5
5
  * Usage: hippo dashboard [--port 3333]
6
6
  */
7
7
  import * as http from 'http';
8
+ import * as os from 'os';
8
9
  import * as path from 'path';
9
10
  import * as fs from 'fs';
11
+ import { extractPathTags } from './path-context.js';
10
12
  import { loadAllEntries, listCards, listMemoryConflicts, readEntry, writeEntry } from './store.js';
11
13
  import { calculateStrength, confidenceFacets } from './memory.js';
12
14
  import { loadConfig } from './config.js';
@@ -110,6 +112,8 @@ function buildDashboardData(hippoRoot) {
110
112
  defaultHalfLifeDays: config.defaultHalfLifeDays,
111
113
  defaultBudget: config.defaultBudget,
112
114
  embeddingsEnabled: config.embeddings.enabled,
115
+ // Every memory captured in the home folder carries these, so they name no project.
116
+ homePathTags: extractPathTags(os.homedir()),
113
117
  },
114
118
  };
115
119
  }
@@ -172,6 +172,7 @@ function runHippo(args, cwd) {
172
172
  encoding: 'utf8',
173
173
  timeout: 30_000,
174
174
  stdio: ['pipe', 'pipe', 'pipe'],
175
+ windowsHide: true,
175
176
  });
176
177
  // `encoding: 'utf8'` above selects the ExecFileSyncOptionsWithStringEncoding
177
178
  // overload, so `result` is always a `string` here — no runtime check needed.
@@ -61,12 +61,9 @@ function loadByIdsChunked(root, tenantId, ids) {
61
61
  }
62
62
  return out;
63
63
  }
64
- /**
65
- * Traverse one store's graph from the seeds present in it and accumulate new graph hits
66
- * into `hitsByOrigin`. Mutates `seenMemoryIds` so a memory is surfaced at most once across
67
- * stores. Pure reads.
68
- */
69
- function produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds, hitsByOrigin, opts) {
64
+ /** Traverse one store's graph from its seeds into `hitsByOrigin`. Pure reads; mutates `seenMemoryIds`
65
+ * and `seenContent` so a memory, or a share/promote copy of it, surfaces at most once across stores. */
66
+ function produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds, seenContent, hitsByOrigin, opts) {
70
67
  const { hops, maxNeighbors, tenantId, includeSuperseded, asOfDate, recallScope } = opts;
71
68
  // Seeds = graph entities (in THIS store) whose source memory is a base result.
72
69
  const seedEntities = loadEntitiesByMemoryId(root, tenantId, baseResults.map((r) => r.entry.id));
@@ -147,6 +144,8 @@ function produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds,
147
144
  continue; // not found / wrong tenant / already in base
148
145
  if (seenMemoryIds.has(mem.id))
149
146
  continue; // another reached entity already added it
147
+ if (seenContent.has(mem.content))
148
+ continue; // share/promote copy: same text, another id
150
149
  const via = reached.get(ent.id);
151
150
  // A node reached as the `to` endpoint of a `supersedes` edge IS the superseded
152
151
  // (older) version — the graph is the authoritative signal (the memory mirror's
@@ -175,6 +174,7 @@ function produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds,
175
174
  const origin = originMemByEntityId.get(ent.id) ?? baseResults[0].entry.id;
176
175
  const originScore = baseScoreByMemId.get(origin) ?? baseResults[baseResults.length - 1].score;
177
176
  seenMemoryIds.add(mem.id);
177
+ seenContent.add(mem.content);
178
178
  const hit = {
179
179
  entry: mem,
180
180
  score: originScore * (1 - HOP_DISCOUNT * via.hops),
@@ -209,11 +209,12 @@ export function graphExpandRecall(baseResults, opts) {
209
209
  const recallScope = opts.recallScope ?? {};
210
210
  const baseScoreByMemId = new Map(baseResults.map((r) => [r.entry.id, r.score]));
211
211
  const seenMemoryIds = new Set(baseResults.map((r) => r.entry.id));
212
+ const seenContent = new Set(baseResults.map((r) => r.entry.content));
212
213
  const hitsByOrigin = new Map();
213
214
  // Expand against each distinct store the seeds may live in (local + global).
214
215
  const roots = globalRoot && globalRoot !== hippoRoot ? [hippoRoot, globalRoot] : [hippoRoot];
215
216
  for (const root of roots) {
216
- produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds, hitsByOrigin, {
217
+ produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds, seenContent, hitsByOrigin, {
217
218
  hops, maxNeighbors, tenantId, includeSuperseded, asOfDate, recallScope,
218
219
  });
219
220
  }
package/dist/handoff.d.ts CHANGED
@@ -12,6 +12,8 @@ export interface HandoffEvidence {
12
12
  gitRef?: string | null;
13
13
  dirtyTree?: boolean | null;
14
14
  testStatus?: 'pass' | 'fail' | 'unknown' | null;
15
+ /** 'transcript' when hippo read the handoff off the session's transcript at session end; a later exit may replace it. */
16
+ derivedFrom?: 'transcript';
15
17
  }
16
18
  /** Narrows an unvalidated value (e.g. CLI input or event content) to a HandoffOutcome. */
17
19
  export declare function isHandoffOutcome(v: string | boolean | string[] | null | undefined): v is HandoffOutcome;
package/dist/hooks.d.ts CHANGED
@@ -10,8 +10,8 @@
10
10
  * sequence, writing both outputs to the log file. The parent returns in
11
11
  * <100ms so the TUI teardown can't kill the child before it finishes.
12
12
  * - SessionStart: `hippo last-sleep --path <path>` - prints the log
13
- * written by the previous session's detached worker and then clears it,
14
- * so the user actually sees what was consolidated.
13
+ * written by the previous session's detached worker to stderr, which
14
+ * keeps it out of the model's context, and then clears it.
15
15
  * Earlier Claude Code forms are detected and migrated automatically:
16
16
  * - < 0.20.2: `Stop` hook firing `hippo sleep` on every assistant turn.
17
17
  * - < 0.21.0: bare `hippo sleep` in SessionEnd, no `--log-file`.
@@ -60,7 +60,7 @@ export interface CodexWrapperMetadata {
60
60
  installedAt: string;
61
61
  }
62
62
  export interface EnsureCodexWrapperResult {
63
- status: 'installed' | 'already-installed' | 'not-found';
63
+ status: 'installed' | 'already-installed' | 'not-found' | 'source-checkout';
64
64
  metadataPath?: string;
65
65
  realCodexPath?: string;
66
66
  commandPath?: string;
@@ -172,7 +172,7 @@ export declare function isCodexWrapperInstalled(): boolean;
172
172
  * doing it from postinstall or routine commands is a consent violation and
173
173
  * reads as binary hijacking to security scanners (issue #133).
174
174
  */
175
- export declare function repairCodexWrapperIfInstalled(): EnsureCodexWrapperResult;
175
+ export declare function repairCodexWrapperIfInstalled(hippoCliPath?: string): EnsureCodexWrapperResult;
176
176
  export declare function resolveCodexSessionTranscript(options: CodexSessionTranscriptOptions): string | null;
177
177
  export declare function resolveJsonHookPaths(target: JsonHookTarget): JsonHookPaths;
178
178
  export declare function installJsonHooks(target: JsonHookTarget): InstallResult;
package/dist/hooks.js CHANGED
@@ -10,8 +10,8 @@
10
10
  * sequence, writing both outputs to the log file. The parent returns in
11
11
  * <100ms so the TUI teardown can't kill the child before it finishes.
12
12
  * - SessionStart: `hippo last-sleep --path <path>` - prints the log
13
- * written by the previous session's detached worker and then clears it,
14
- * so the user actually sees what was consolidated.
13
+ * written by the previous session's detached worker to stderr, which
14
+ * keeps it out of the model's context, and then clears it.
15
15
  * Earlier Claude Code forms are detected and migrated automatically:
16
16
  * - < 0.20.2: `Stop` hook firing `hippo sleep` on every assistant turn.
17
17
  * - < 0.21.0: bare `hippo sleep` in SessionEnd, no `--log-file`.
@@ -412,10 +412,16 @@ export function isCodexWrapperInstalled() {
412
412
  * doing it from postinstall or routine commands is a consent violation and
413
413
  * reads as binary hijacking to security scanners (issue #133).
414
414
  */
415
- export function repairCodexWrapperIfInstalled() {
415
+ export function repairCodexWrapperIfInstalled(hippoCliPath = resolveHippoCliPath()) {
416
416
  if (readCodexWrapperMetadata() === null) {
417
417
  return { status: 'not-found' };
418
418
  }
419
+ // A checkout's own `npm install` or CLI would point the user's launcher at a folder that may be deleted.
420
+ // SHORTCUT: any node_modules copy (npx cache, a project dependency) still repairs; record the opted-in CLI path in the metadata to close that.
421
+ const packageDir = path.dirname(path.dirname(hippoCliPath));
422
+ if (path.basename(path.dirname(packageDir)).toLowerCase() !== 'node_modules') {
423
+ return { status: 'source-checkout' };
424
+ }
419
425
  return ensureCodexWrapperInstalled();
420
426
  }
421
427
  function collectFiles(dir) {
@@ -989,9 +989,9 @@ async function executeTool(name, args, ctx) {
989
989
  // Auto-detect query from git
990
990
  let query = '';
991
991
  try {
992
- const branch = execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', { encoding: 'utf-8' }).trim();
993
- const diff = execSync('git diff --cached --stat 2>/dev/null', { encoding: 'utf-8' }).trim();
994
- const log = execSync('git log -1 --pretty=format:"%s" 2>/dev/null', { encoding: 'utf-8' }).trim();
992
+ const branch = execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', { encoding: 'utf-8', windowsHide: true }).trim();
993
+ const diff = execSync('git diff --cached --stat 2>/dev/null', { encoding: 'utf-8', windowsHide: true }).trim();
994
+ const log = execSync('git log -1 --pretty=format:"%s" 2>/dev/null', { encoding: 'utf-8', windowsHide: true }).trim();
995
995
  query = [branch, log, diff].filter(Boolean).join(' ');
996
996
  }
997
997
  catch { /* not a git repo */ }
@@ -39,7 +39,7 @@ const SECRET_PATTERNS = [
39
39
  // sk-... (OpenAI/Anthropic style) and sk_<vendor>_... shapes. Both require
40
40
  // a key-ish noun somewhere in the content (co-occurrence guard) so prose
41
41
  // like "the sk- prefix identifies API keys" without an actual long token
42
- // does not flag, but a stored key ("2chain prod API key sk_keith_...")
42
+ // does not flag, but a stored key ("prod API key sk_live_...")
43
43
  // does.
44
44
  { name: 'sk-style-key', re: /\bsk-[A-Za-z0-9_-]{20,}\b/ },
45
45
  { name: 'sk-underscore-key', re: /\bsk_[A-Za-z0-9]+_[A-Za-z0-9_]{6,}\b/ },
@@ -51,7 +51,7 @@ const SECRET_PATTERNS = [
51
51
  // rollout worked well" were verified false positives that would silently
52
52
  // hide real code-lesson memories from ambient context (post-merge
53
53
  // adversarial review, 2026-07-02).
54
- { name: 'secret-assignment', re: /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*['"]?(?=[A-Za-z0-9_\-+/=]*\d)[A-Za-z0-9_\-+/=]{12,}/i },
54
+ { name: 'secret-assignment', re: /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*['"]?(?=[A-Za-z0-9_\-+/]*\d)[A-Za-z0-9_\-+/=]{12,}/i },
55
55
  ];
56
56
  const KEYISH_CONTEXT_RE = /key|token|secret|credential|bearer|auth|password/i;
57
57
  const CO_OCCURRENCE_GUARDED = new Set(['sk-style-key', 'sk-underscore-key']);
@@ -59,7 +59,7 @@ const CO_OCCURRENCE_GUARDED = new Set(['sk-style-key', 'sk-underscore-key']);
59
59
  const STRICT_ONLY_PATTERNS = [
60
60
  /\bbearer\s+[A-Za-z0-9._~+/-]{16,}=*/gi,
61
61
  /\bauthorization["']?\s*[:=]\s*["']?basic\s+[A-Za-z0-9+/]{8,}={0,2}/gi,
62
- /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g,
62
+ /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g,
63
63
  ];
64
64
  /**
65
65
  * Scan a memory's tags + content for secret material.
package/dist/shared.d.ts CHANGED
@@ -157,7 +157,7 @@ export declare function autoShare(localRoot: string, options?: {
157
157
  }): MemoryEntry[];
158
158
  /**
159
159
  * Copy all global memories into the local store.
160
- * Skips entries that already exist locally (by ID or by near-identical content).
160
+ * Skips entries that already exist locally, by ID or by text (promote and share copy under a new ID).
161
161
  * Returns the count of newly copied entries.
162
162
  */
163
163
  export declare function syncGlobalToLocal(localRoot: string, globalRoot: string, opts?: {
package/dist/shared.js CHANGED
@@ -457,7 +457,7 @@ export function autoShare(localRoot, options = {}) {
457
457
  }
458
458
  /**
459
459
  * Copy all global memories into the local store.
460
- * Skips entries that already exist locally (by ID or by near-identical content).
460
+ * Skips entries that already exist locally, by ID or by text (promote and share copy under a new ID).
461
461
  * Returns the count of newly copied entries.
462
462
  */
463
463
  export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
@@ -468,6 +468,8 @@ export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
468
468
  // the local-root context provides one.
469
469
  const globalEntries = loadAllEntries(globalRoot);
470
470
  const localIndex = loadIndex(localRoot);
471
+ const textKey = (e) => `${e.tenantId}\n${e.content}`;
472
+ const localText = new Set(loadAllEntries(localRoot).map(textKey));
471
473
  // v39 (codex P1-4): syncing down must not re-import what ambient context
472
474
  // excludes - other-project rows are skipped by default and secret rows
473
475
  // are never copied. origin_project is preserved on the copy (writeEntry
@@ -484,6 +486,8 @@ export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
484
486
  // Skip if already present by ID
485
487
  if (localIndex.entries[entry.id])
486
488
  continue;
489
+ if (localText.has(textKey(entry)))
490
+ continue;
487
491
  if (detectSecret(entry).flagged)
488
492
  continue;
489
493
  if (!opts.includeCrossProject &&
@@ -499,6 +503,7 @@ export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
499
503
  }
500
504
  throw err;
501
505
  }
506
+ localText.add(textKey(entry));
502
507
  count++;
503
508
  }
504
509
  if (rejected > 0) {
package/dist/store.d.ts CHANGED
@@ -652,12 +652,10 @@ export declare function loadLatestHandoff(hippoRoot: string, tenantId: string, s
652
652
  export declare function loadHandoffById(hippoRoot: string, tenantId: string, id: number): SessionHandoff | null;
653
653
  /** Stamp the outcome on a session's newest handoff, only if it has none yet. Returns rows changed. */
654
654
  export declare function stampHandoffOutcome(hippoRoot: string, tenantId: string, sessionId: string, outcome: HandoffOutcome): number;
655
- /**
656
- * Auto-write a handoff at session-end from the session's active snapshot (DF1 T3).
655
+ /** Auto-write a handoff at session-end (DF1 T3) from the session's active snapshot, else from `derived`, its transcript state.
657
656
  * @param evidence best-effort git state; outcome comes from the newest session_complete event.
658
- * @returns null unless the snapshot belongs to sessionId and no newer handoff already covers it.
659
- */
660
- export declare function writeSessionEndHandoff(hippoRoot: string, tenantId: string, sessionId: string, evidence: HandoffEvidence | null): SessionHandoff | null;
657
+ * @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript. */
658
+ export declare function writeSessionEndHandoff(hippoRoot: string, tenantId: string, sessionId: string, evidence: HandoffEvidence | null, derived?: Pick<TaskSnapshot, 'task' | 'summary' | 'next_step'> | null): SessionHandoff | null;
661
659
  export declare function transitionCard(db: DatabaseSyncLike, tenantId: string, cardId: string, from: CardStatus[], to: CardStatus, extra?: {
662
660
  setSql?: string;
663
661
  whereSql?: string;
package/dist/store.js CHANGED
@@ -16,6 +16,7 @@ import { isRecallBoostAblated } from './ablation.js';
16
16
  import { rarestPromptTerms, RAREST_TERM_COUNT } from './prompt-recall.js';
17
17
  import { appendAuditEvent } from './audit.js';
18
18
  import { resolveTenantId } from './tenant.js';
19
+ import { redactSecretsStrict } from './secret-detect.js';
19
20
  import { deriveOriginProject, originFromSource, findHippoStoreDir, realpathOrResolve } from './project-identity.js';
20
21
  import { checkRejectionGuard, RejectedValueError, rejectionDigest, normalizeValueForRejection, insertRejectedValue, findRejectedValue, } from './rejection.js';
21
22
  // AT1 (plan §5): resolveConflict's kind-aware loser removal needs
@@ -2146,7 +2147,7 @@ export function saveActiveTaskSnapshot(hippoRoot, tenantId, snapshot) {
2146
2147
  const result = db.prepare(`
2147
2148
  INSERT INTO task_snapshots(task, summary, next_step, status, source, session_id, scope, tenant_id, created_at, updated_at)
2148
2149
  VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)
2149
- `).run(snapshot.task, snapshot.summary, snapshot.next_step, snapshot.source ?? 'cli', snapshot.session_id ?? null, snapshot.scope ?? null, tenantId, now, now);
2150
+ `).run(redactSecretsStrict(snapshot.task), redactSecretsStrict(snapshot.summary), redactSecretsStrict(snapshot.next_step), snapshot.source ?? 'cli', snapshot.session_id ?? null, snapshot.scope ?? null, tenantId, now, now);
2150
2151
  db.exec('COMMIT');
2151
2152
  const id = Number(result.lastInsertRowid ?? 0);
2152
2153
  // SAFETY: row's shape matches the ten columns named in the SELECT above.
@@ -2904,20 +2905,28 @@ export function stampHandoffOutcome(hippoRoot, tenantId, sessionId, outcome) {
2904
2905
  closeHippoDb(db);
2905
2906
  }
2906
2907
  }
2907
- /**
2908
- * Auto-write a handoff at session-end from the session's active snapshot (DF1 T3).
2908
+ /** Auto-write a handoff at session-end (DF1 T3) from the session's active snapshot, else from `derived`, its transcript state.
2909
2909
  * @param evidence best-effort git state; outcome comes from the newest session_complete event.
2910
- * @returns null unless the snapshot belongs to sessionId and no newer handoff already covers it.
2911
- */
2912
- export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence) {
2910
+ * @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript. */
2911
+ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence, derived = null) {
2913
2912
  assertTenantId('writeSessionEndHandoff', tenantId);
2914
- const snapshot = loadActiveTaskSnapshot(hippoRoot, tenantId);
2915
- if (!snapshot || snapshot.session_id !== sessionId)
2916
- return null;
2913
+ const active = loadActiveTaskSnapshot(hippoRoot, tenantId);
2917
2914
  const existing = loadLatestHandoff(hippoRoot, tenantId, sessionId);
2918
- // Strict '>': a same-millisecond tie must not swallow the session's only write (test 6e).
2919
- if (existing && existing.updatedAt > snapshot.updated_at)
2920
- return null;
2915
+ let snapshot;
2916
+ let handoffEvidence = evidence;
2917
+ if (active && active.session_id === sessionId) {
2918
+ // Strict '>': a same-millisecond tie must not swallow the session's only write (test 6e).
2919
+ if (existing && existing.updatedAt > active.updated_at)
2920
+ return null;
2921
+ snapshot = active;
2922
+ }
2923
+ else {
2924
+ // Only an earlier transcript read gives way; `hippo handoff create` and unmarked older handoffs keep winning.
2925
+ if (!derived || (existing && existing.evidence?.derivedFrom !== 'transcript'))
2926
+ return null;
2927
+ snapshot = { ...derived, scope: null };
2928
+ handoffEvidence = { ...evidence, derivedFrom: 'transcript' };
2929
+ }
2921
2930
  const db = openHippoDb(hippoRoot);
2922
2931
  let outcome = null;
2923
2932
  try {
@@ -2947,7 +2956,7 @@ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence)
2947
2956
  nextAction: snapshot.next_step,
2948
2957
  artifacts: carryForward ? existing.artifacts : [],
2949
2958
  scope: snapshot.scope,
2950
- evidence,
2959
+ evidence: handoffEvidence,
2951
2960
  outcome,
2952
2961
  constraints: carryForward ? existing.constraints : undefined,
2953
2962
  targetRuntime: carryForward ? existing.targetRuntime : undefined,
package/dist/version.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export declare const PACKAGE_VERSION = "1.52.7";
19
+ export declare const PACKAGE_VERSION = "1.52.8";
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export declare function compareSemver(a: string, b: string): number;
22
22
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export const PACKAGE_VERSION = '1.52.7';
19
+ export const PACKAGE_VERSION = '1.52.8';
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export function compareSemver(a, b) {
22
22
  const parse = (v) => {