hippo-memory 1.68.0 → 1.69.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.
Files changed (70) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-memories/copilot.d.ts +3 -1
  3. package/dist/agent-memories/copilot.js +2 -2
  4. package/dist/api/context-select.d.ts +10 -0
  5. package/dist/api/context-select.js +16 -18
  6. package/dist/api/context.js +101 -60
  7. package/dist/api/forget.d.ts +2 -2
  8. package/dist/api/forget.js +11 -0
  9. package/dist/api/outcome.d.ts +4 -4
  10. package/dist/api/outcome.js +29 -6
  11. package/dist/api/promote.d.ts +3 -3
  12. package/dist/api/promote.js +45 -90
  13. package/dist/api/remember.d.ts +3 -2
  14. package/dist/api/remember.js +38 -11
  15. package/dist/capture/compact.js +21 -2
  16. package/dist/capture/copilot-transcript.d.ts +3 -0
  17. package/dist/capture/copilot-transcript.js +8 -1
  18. package/dist/capture/session-worker.d.ts +13 -0
  19. package/dist/capture/session-worker.js +194 -0
  20. package/dist/capture-contract.d.ts +3 -1
  21. package/dist/capture-contract.js +36 -0
  22. package/dist/cli/session-hooks.js +90 -22
  23. package/dist/cli/setup.js +70 -24
  24. package/dist/cli/usage.js +4 -1
  25. package/dist/cli.js +1 -1
  26. package/dist/connectors/github/ingest.js +2 -1
  27. package/dist/connectors/slack/deletion.js +2 -1
  28. package/dist/connectors/slack/ingest.js +2 -1
  29. package/dist/env.d.ts +2 -0
  30. package/dist/env.js +2 -0
  31. package/dist/hooks/copilot.d.ts +50 -8
  32. package/dist/hooks/copilot.js +116 -30
  33. package/dist/hooks/json-hooks.d.ts +2 -0
  34. package/dist/hooks/json-hooks.js +11 -2
  35. package/dist/hooks/shared.d.ts +7 -1
  36. package/dist/hooks/shared.js +18 -3
  37. package/dist/mcp/memory-tools.d.ts +2 -2
  38. package/dist/mcp/memory-tools.js +9 -8
  39. package/dist/mcp/request.js +3 -3
  40. package/dist/memory.d.ts +2 -0
  41. package/dist/memory.js +7 -0
  42. package/dist/prompt-recall.d.ts +5 -0
  43. package/dist/prompt-recall.js +13 -0
  44. package/dist/raw-archive.d.ts +2 -2
  45. package/dist/raw-archive.js +5 -3
  46. package/dist/recall-scope.d.ts +2 -5
  47. package/dist/recall-scope.js +5 -6
  48. package/dist/server/routes/memories.js +9 -6
  49. package/dist/server-detect.js +3 -2
  50. package/dist/server.d.ts +14 -1
  51. package/dist/server.js +16 -6
  52. package/dist/store/audit-event.d.ts +1 -1
  53. package/dist/store/audit-event.js +1 -1
  54. package/dist/store/entry-writes-group.d.ts +9 -0
  55. package/dist/store/entry-writes-group.js +157 -0
  56. package/dist/store/handoffs.d.ts +5 -2
  57. package/dist/store/handoffs.js +35 -5
  58. package/dist/store/open.d.ts +2 -0
  59. package/dist/store/open.js +10 -0
  60. package/dist/store/search-rows.js +3 -9
  61. package/dist/store/sessions.d.ts +5 -0
  62. package/dist/store/sessions.js +4 -1
  63. package/dist/store-port.d.ts +71 -0
  64. package/dist/store-port.js +23 -11
  65. package/dist/version.d.ts +1 -1
  66. package/dist/version.js +1 -1
  67. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  68. package/extensions/openclaw-plugin/package.json +1 -1
  69. package/openclaw.plugin.json +1 -1
  70. package/package.json +1 -1
@@ -1,14 +1,12 @@
1
1
  // Promote to the global store, supersede with a successor, and archive raw memories.
2
- import { openHippoDb, closeHippoDb, isSqliteBusy } from '../db.js';
2
+ import { openHippoDb, closeHippoDb } from '../db.js';
3
3
  import { ConflictError, NotFoundError } from '../api-errors.js';
4
- import { auditRejectionRefusal } from '../store/audit-event.js';
5
4
  import { stampOriginProject } from '../store/entry-row.js';
6
- import { removeEntryMirrors } from '../store/mirrors.js';
7
- import { writeEntryDbOnly, writeEntryMirrors } from '../store/entry-writes.js';
5
+ import { writeEntryMirrors } from '../store/entry-writes.js';
6
+ import { cleanArchivedMirrors, commitSupersede } from '../store/entry-writes-group.js';
8
7
  import { readEntry } from '../store/entry-reads.js';
9
8
  import { updateStatsUnlessBusy } from '../store/index-and-stats.js';
10
- import { markSummaryDirtyInTx } from '../summary-dirty.js';
11
- import { RejectedValueError } from '../rejection.js';
9
+ import { requireGroup } from '../store-port.js';
12
10
  import { log } from '../log.js';
13
11
  import { createSuccessor } from '../memory.js';
14
12
  import { appendAuditEvent } from '../audit.js';
@@ -16,7 +14,7 @@ import { promoteToGlobal } from '../shared.js';
16
14
  import { archiveRawMemory } from '../raw-archive.js';
17
15
  import { loadConfig } from '../config.js';
18
16
  import { selectMemoryReach } from '../store/tenant-lookup.js';
19
- import { canTouchScope } from '../recall-scope.js';
17
+ import { canTouchScope, personalScopeOf } from '../recall-scope.js';
20
18
  export function promote(ctx, id) {
21
19
  // Tenant scope: promoteToGlobal reads the entry from the local root via
22
20
  // readEntry without a tenant filter, so a Bearer for tenant A could
@@ -49,11 +47,28 @@ export function promote(ctx, id) {
49
47
  return { ok: true, sourceId: id, globalId: globalEntry.id };
50
48
  }
51
49
  export function supersede(ctx, oldId, newContent) {
52
- const old = readSupersedable(ctx, oldId);
53
- const newEntry = createSuccessor(old, newContent, {
50
+ const reply = ctx.store ? supersedeThroughStore(ctx, ctx.store, oldId, newContent) : supersedeOnHippoDb(ctx, oldId, newContent);
51
+ // SAFETY: a C typed with a store gets the promise its path returns; a wide C is typed as the union, which a caller has to await anyway.
52
+ return reply;
53
+ }
54
+ async function supersedeThroughStore(ctx, store, oldId, newContent) {
55
+ const entryWrites = requireGroup(store, 'entryWrites');
56
+ const [old] = await store.entriesByIds([oldId], ctx.tenantId);
57
+ const newEntry = createSuccessor(assertSupersedable(ctx, oldId, old ?? null), newContent, {
54
58
  tenantId: ctx.tenantId,
55
59
  baseHalfLifeDays: loadConfig(ctx.hippoRoot).defaultHalfLifeDays,
56
60
  });
61
+ // The store writes origin_project as given, so the served folder's fallback is stamped here.
62
+ const successor = stampOriginProject(ctx.hippoRoot, newEntry);
63
+ await entryWrites.supersede({ tenantId: ctx.tenantId, actor: ctx.actor.subject, ownScope: personalScopeOf(ctx.actor), oldId, successor });
64
+ return { ok: true, oldId, newId: successor.id };
65
+ }
66
+ function supersedeOnHippoDb(ctx, oldId, newContent) {
67
+ const old = assertSupersedable(ctx, oldId, readEntry(ctx.hippoRoot, oldId, ctx.tenantId));
68
+ const successor = stampOriginProject(ctx.hippoRoot, createSuccessor(old, newContent, {
69
+ tenantId: ctx.tenantId,
70
+ baseHalfLifeDays: loadConfig(ctx.hippoRoot).defaultHalfLifeDays,
71
+ }));
57
72
  // Race-safe transition: open a fresh db handle, BEGIN IMMEDIATE, run all
58
73
  // three steps (CAS on old + writeEntryDbOnly(new) + supersede audit row)
59
74
  // inside the same transaction. Two concurrent supersedes: exactly one CAS
@@ -62,14 +77,14 @@ export function supersede(ctx, oldId, newContent) {
62
77
  // the old.superseded_by pointer.
63
78
  const db = openHippoDb(ctx.hippoRoot);
64
79
  try {
65
- commitSupersede(db, ctx, oldId, old, newEntry);
80
+ commitSupersede(db, { tenantId: ctx.tenantId, actor: ctx.actor.subject, ownScope: personalScopeOf(ctx.actor), oldId, successor });
66
81
  // Mirrors after COMMIT, while the db handle is still open. Same
67
82
  // invariant as the original writeEntry: a mirror failure leaves disk
68
83
  // MISSING the markdown for the new memory (rebuildIndex rewrites every
69
84
  // markdown mirror from the DB) but DOES NOT desync the DB or
70
85
  // roll back the supersede. Logged + swallowed, non-fatal.
71
86
  try {
72
- writeEntryMirrors(ctx.hippoRoot, newEntry);
87
+ writeEntryMirrors(ctx.hippoRoot, successor);
73
88
  }
74
89
  catch (mirrorErr) {
75
90
  log.error(`supersede: mirror write failed (non-fatal, will self-heal): ${mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)}`);
@@ -78,11 +93,10 @@ export function supersede(ctx, oldId, newContent) {
78
93
  finally {
79
94
  closeHippoDb(db);
80
95
  }
81
- return { ok: true, oldId, newId: newEntry.id };
96
+ return { ok: true, oldId, newId: successor.id };
82
97
  }
83
98
  /** The tenant-scoped row to supersede; another tenant's id, or someone else's personal row, reads as not found. */
84
- function readSupersedable(ctx, oldId) {
85
- const old = readEntry(ctx.hippoRoot, oldId, ctx.tenantId);
99
+ function assertSupersedable(ctx, oldId, old) {
86
100
  if (!old || !canTouchScope(ctx.actor, old.scope ?? null)) {
87
101
  throw new NotFoundError(`Memory not found: ${oldId}`);
88
102
  }
@@ -92,50 +106,22 @@ function readSupersedable(ctx, oldId) {
92
106
  }
93
107
  return old;
94
108
  }
95
- /** CAS on the old row, the successor's insert and the 'supersede' audit row, in one BEGIN IMMEDIATE transaction. */
96
- function commitSupersede(db, ctx, oldId, old, newEntry) {
97
- db.exec('BEGIN IMMEDIATE');
98
- try {
99
- // The tenant filter repeats readEntry's check at no cost, closing an ownership change between read and update.
100
- const result = db.prepare(`
101
- UPDATE memories
102
- SET superseded_by = ?
103
- WHERE id = ? AND tenant_id = ? AND superseded_by IS NULL
104
- `).run(newEntry.id, oldId, ctx.tenantId);
105
- if ((result.changes ?? 0) === 0) {
106
- db.exec('ROLLBACK');
107
- throw new ConflictError(`Memory ${oldId} already superseded by another writer`);
108
- }
109
- // After the CAS guard, so a lost race throws before the old parent is marked for rebuild.
110
- if (old.dag_parent_id) {
111
- markSummaryDirtyInTx(db, old.dag_parent_id, ctx.tenantId, ctx.actor.subject);
112
- }
113
- // Emits its own 'remember' audit row inside the same transaction.
114
- writeEntryDbOnly(db, stampOriginProject(ctx.hippoRoot, newEntry), { actor: ctx.actor.subject });
115
- appendAuditEvent(db, {
116
- tenantId: ctx.tenantId,
117
- actor: ctx.actor.subject,
118
- op: 'supersede',
119
- targetId: oldId,
120
- metadata: { newId: newEntry.id },
121
- });
122
- db.exec('COMMIT');
123
- }
124
- catch (err) {
125
- try {
126
- db.exec('ROLLBACK');
127
- }
128
- catch { /* already rolled back */ }
129
- // The refusal audit lands after ROLLBACK, in a fresh implicit transaction the aborted one cannot undo.
130
- if (err instanceof RejectedValueError) {
131
- auditRejectionRefusal(db, err, ctx.actor.subject);
132
- }
133
- throw err;
134
- }
135
- }
136
109
  export function archiveRaw(ctx, id, reason, opts = {}) {
110
+ const reply = ctx.store ? archiveRawThroughStore(ctx, ctx.store, id, reason, opts) : archiveRawOnHippoDb(ctx, id, reason, opts);
111
+ // SAFETY: a C typed with a store gets the promise its path returns; a wide C is typed as the union, which a caller has to await anyway.
112
+ return reply;
113
+ }
114
+ /** afterArchive writes on hippo.db's own handle, and only connectors send it, so a store refuses it. */
115
+ async function archiveRawThroughStore(ctx, store, id, reason, opts) {
116
+ const entryWrites = requireGroup(store, 'entryWrites');
117
+ if (opts.afterArchive)
118
+ throw new Error('afterArchive runs on hippo.db only, never through a store');
119
+ const archivedAt = await entryWrites.archiveRaw({ tenantId: ctx.tenantId, actor: ctx.actor.subject, ownScope: personalScopeOf(ctx.actor), id, reason });
120
+ return { ok: true, archivedAt };
121
+ }
122
+ function archiveRawOnHippoDb(ctx, id, reason, opts) {
137
123
  const db = openHippoDb(ctx.hippoRoot);
138
- let mirrorOk = false;
124
+ let archivedAt;
139
125
  try {
140
126
  // Tenant scope: archiveRawMemory looks up the row by id alone, so a
141
127
  // Bearer for tenant A could archive tenant B's raw row without this
@@ -146,40 +132,12 @@ export function archiveRaw(ctx, id, reason, opts = {}) {
146
132
  if (reach?.tenantId !== ctx.tenantId || !canTouchScope(ctx.actor, reach.scope)) {
147
133
  throw new NotFoundError(`memory not found: ${id}`);
148
134
  }
149
- archiveRawMemory(db, id, {
135
+ archivedAt = archiveRawMemory(db, id, {
150
136
  reason,
151
137
  who: ctx.actor.subject,
152
138
  afterArchive: opts.afterArchive,
153
139
  });
154
- // archiveRawMemory deletes the memories row but leaves any legacy markdown
155
- // mirror in <root>/{buffer,episodic,semantic}/<id>.md untouched. If we left
156
- // the mirror in place, a subsequent initStore() on an empty memories table
157
- // would silently re-import the row via bootstrapLegacyStore — defeating the
158
- // archive (and the GDPR right-to-be-forgotten promise on raw rows). Mirror
159
- // forget() at src/store.ts:1046, which uses the same removeEntryMirrors call.
160
- // The DB transaction has already committed; if filesystem unlink fails here
161
- // we log and continue. The mirror reaper in openHippoDb will catch it on
162
- // next DB open: raw_archive.mirror_cleaned_at stays NULL until every layer
163
- // mirror for this id is gone, so the reaper genuinely retries.
164
- try {
165
- removeEntryMirrors(ctx.hippoRoot, id);
166
- mirrorOk = true;
167
- }
168
- catch (mirrorErr) {
169
- log.error(`archiveRaw: mirror cleanup failed for ${id} (will retry via reaper on next openHippoDb): ${mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)}`);
170
- }
171
- if (mirrorOk) {
172
- // Stamp mirror_cleaned_at now so the next openHippoDb reaper SELECT
173
- // returns empty for this row. NULL stays untouched on failure -> retry.
174
- try {
175
- db.prepare(`UPDATE raw_archive SET mirror_cleaned_at = ? WHERE memory_id = ?`).run(new Date().toISOString(), id);
176
- }
177
- catch (err) {
178
- if (!isSqliteBusy(err))
179
- throw err;
180
- log.warnThenDebug('archive-mirror-stamp-busy', `archived ${id}; the store was busy, so the mirror reaper will re-check it on the next open`);
181
- }
182
- }
140
+ cleanArchivedMirrors(db, ctx.hippoRoot, id);
183
141
  }
184
142
  finally {
185
143
  closeHippoDb(db);
@@ -187,9 +145,6 @@ export function archiveRaw(ctx, id, reason, opts = {}) {
187
145
  // Counted here rather than in the CLI: the HTTP archive route calls this too,
188
146
  // so a routed archive would otherwise never reach the forgotten counter.
189
147
  updateStatsUnlessBusy(ctx.hippoRoot, { forgotten: 1 }, `removed ${id}`);
190
- // archiveRawMemory does not return the archive_at timestamp it wrote. We
191
- // emit a fresh ISO timestamp here for the API response. Within a millisecond
192
- // of the actual write, fine for a server response shape.
193
- return { ok: true, archivedAt: new Date().toISOString() };
148
+ return { ok: true, archivedAt };
194
149
  }
195
150
  //# sourceMappingURL=promote.js.map
@@ -1,6 +1,6 @@
1
1
  import type { DatabaseSyncLike } from '../db.js';
2
2
  import { type MemoryKind } from '../memory.js';
3
- import type { Context } from './types.js';
3
+ import type { Context, StoreReply } from './types.js';
4
4
  export interface RememberOpts {
5
5
  content: string;
6
6
  kind?: MemoryKind;
@@ -38,5 +38,6 @@ export interface RememberResult {
38
38
  /** Set only when the content held secret material: untrusted text had it redacted, typed text was stored as sent. */
39
39
  warnings?: string[];
40
40
  }
41
- export declare function remember(ctx: Context, opts: RememberOpts): RememberResult;
41
+ /** Store one memory. With `ctx.store`, its entryWrites group writes the row and its remember row; hippo.db is opened only when there is no store. */
42
+ export declare function remember<C extends Context>(ctx: C, opts: RememberOpts): StoreReply<C, RememberResult>;
42
43
  //# sourceMappingURL=remember.d.ts.map
@@ -1,5 +1,7 @@
1
1
  // Write path: remember stores one memory after secret vetting and, for untrusted content, the instruction check.
2
2
  import { writeEntry } from '../store/entry-writes.js';
3
+ import { stampOriginProject } from '../store/entry-row.js';
4
+ import { requireGroup } from '../store-port.js';
3
5
  import { detectInstruction } from '../instruction-detect.js';
4
6
  import { quarantineScopeFor, recordQuarantine } from '../quarantine.js';
5
7
  import { createMemory } from '../memory.js';
@@ -24,7 +26,7 @@ function rememberScope(ctx, opts) {
24
26
  }
25
27
  return own;
26
28
  }
27
- export function remember(ctx, opts) {
29
+ function prepareRemember(ctx, opts) {
28
30
  if (opts.project)
29
31
  assertCallerProject(opts.project);
30
32
  const requestedScope = rememberScope(ctx, opts);
@@ -39,6 +41,39 @@ export function remember(ctx, opts) {
39
41
  tenantId: ctx.tenantId,
40
42
  baseHalfLifeDays: loadConfig(ctx.hippoRoot).defaultHalfLifeDays,
41
43
  });
44
+ // Aliases only widen what a reader matches; a row has one origin. A personal row's '' follows its owner into every project.
45
+ const origin = opts.personal ? '' : opts.project?.name;
46
+ const stamped = origin !== undefined ? { ...entry, origin_project: origin } : entry;
47
+ return { entry: stamped, requestedScope, detection, warnings: vetted.warnings };
48
+ }
49
+ function rememberResult(ctx, prepared) {
50
+ const { entry, detection, warnings } = prepared;
51
+ const result = { id: entry.id, kind: entry.kind, tenantId: ctx.tenantId };
52
+ if (detection.flagged)
53
+ result.quarantined = { reason: detection.reason ?? 'unknown' };
54
+ if (warnings.length > 0)
55
+ result.warnings = [...warnings];
56
+ return result;
57
+ }
58
+ /** Store one memory. With `ctx.store`, its entryWrites group writes the row and its remember row; hippo.db is opened only when there is no store. */
59
+ export function remember(ctx, opts) {
60
+ const reply = ctx.store ? rememberThroughStore(ctx, ctx.store, opts) : rememberOnHippoDb(ctx, opts);
61
+ // SAFETY: a C typed with a store gets the promise its path returns; a wide C is typed as the union, which a caller has to await anyway.
62
+ return reply;
63
+ }
64
+ /** afterWrite and the quarantine row write on hippo.db's own handle, and only connectors send them, so a store refuses both. */
65
+ async function rememberThroughStore(ctx, store, opts) {
66
+ const entryWrites = requireGroup(store, 'entryWrites');
67
+ if (opts.afterWrite || opts.untrusted)
68
+ throw new Error('afterWrite and untrusted content are written to hippo.db only, never through a store');
69
+ const prepared = prepareRemember(ctx, opts);
70
+ // The store writes origin_project as given, so the served folder's fallback is stamped here.
71
+ await entryWrites.writeEntry({ entry: stampOriginProject(ctx.hippoRoot, prepared.entry), actor: ctx.actor.subject });
72
+ return rememberResult(ctx, prepared);
73
+ }
74
+ function rememberOnHippoDb(ctx, opts) {
75
+ const prepared = prepareRemember(ctx, opts);
76
+ const { requestedScope, detection } = prepared;
42
77
  // writeEntry threads ctx.actor.subject into its internal audit hook, so exactly
43
78
  // one 'remember' event lands in the log with the supplied actor.
44
79
  const afterWrite = detection.flagged
@@ -53,15 +88,7 @@ export function remember(ctx, opts) {
53
88
  opts.afterWrite?.(db, memoryId);
54
89
  }
55
90
  : opts.afterWrite;
56
- // Aliases only widen what a reader matches; a row has one origin. A personal row's '' follows its owner into every project.
57
- const origin = opts.personal ? '' : opts.project?.name;
58
- const stamped = origin !== undefined ? { ...entry, origin_project: origin } : entry;
59
- writeEntry(ctx.hippoRoot, stamped, { actor: ctx.actor.subject, afterWrite });
60
- const result = { id: entry.id, kind: entry.kind, tenantId: ctx.tenantId };
61
- if (detection.flagged)
62
- result.quarantined = { reason: detection.reason ?? 'unknown' };
63
- if (vetted.warnings.length > 0)
64
- result.warnings = vetted.warnings;
65
- return result;
91
+ writeEntry(ctx.hippoRoot, prepared.entry, { actor: ctx.actor.subject, afterWrite });
92
+ return rememberResult(ctx, prepared);
66
93
  }
67
94
  //# sourceMappingURL=remember.js.map
@@ -4,10 +4,11 @@ import { isInitialized } from '../store/open.js';
4
4
  import { saveActiveTaskSnapshot, loadActiveTaskSnapshot } from '../store/sessions.js';
5
5
  import { PRE_COMPACT_INSTRUCTION, parsePostCompactPayload, postCompactLine, recordCompactionStart, recordSnapshotSaved, saveCompaction, replayCompactionsAt, COMPACTION_DB_WAIT_MS, } from '../compaction-record.js';
6
6
  import { resolveTenantId } from '../tenant.js';
7
- import { defaultPreCompactLogPath } from '../hooks/shared.js';
7
+ import { defaultPreCompactLogPath, vscodeUserHooksFile } from '../hooks/shared.js';
8
8
  import { readClaudeCodePreCompact } from '../capture-contract.js';
9
9
  import { errorMessage } from '../log.js';
10
10
  import { resolveLastSessionTranscript } from './transcript.js';
11
+ import { isVscodeTranscript } from './copilot-transcript.js';
11
12
  import { mergeWorkingState, transcriptWorkingState } from './working-state.js';
12
13
  // ---------------------------------------------------------------------------
13
14
  // `hippo pre-compact` — PreCompact hook producer
@@ -58,6 +59,14 @@ function printPreCompactInstruction(logFile) {
58
59
  appendPreCompactLog(logFile, `instruction not printed: ${errorMessage(err)}`);
59
60
  }
60
61
  }
62
+ /** The Copilot CLI accepts both preCompact and PreCompact, so hippo.json's pair can run twice for one compaction. */
63
+ const TWIN_FIRE_MS = 10_000;
64
+ function snapshotJustSaved(hippoRoot, sessionId) {
65
+ if (sessionId === null || sessionId === '')
66
+ return false;
67
+ const active = loadActiveTaskSnapshot(hippoRoot, resolveTenantId({}));
68
+ return active?.session_id === sessionId && active.source === 'pre-compact' && Date.now() - Date.parse(active.updated_at) < TWIN_FIRE_MS;
69
+ }
61
70
  /** Runs the PreCompact producer: records the compaction, asks the summariser for memories, saves a working-state snapshot. Never extracts memories itself; SessionEnd capture owns that. */
62
71
  function runPreCompact(hippoRoot, options, logFile) {
63
72
  const { stdinText, stdinTimedOut = false, runtime = 'claude-code' } = options;
@@ -74,10 +83,20 @@ function runPreCompact(hippoRoot, options, logFile) {
74
83
  return;
75
84
  }
76
85
  const { sessionId, transcriptPath: payloadTranscriptPath, cwd: payloadCwd, trigger: payloadTrigger } = receipt.input;
86
+ // With chat.useClaudeHooks on, VS Code runs Claude Code's hooks too; it never sends PostCompact to close a record, nor reads the summariser text.
87
+ const vscode = runtime === 'claude-code' && isVscodeTranscript(payloadTranscriptPath);
88
+ if (vscode && fs.existsSync(vscodeUserHooksFile())) {
89
+ appendPreCompactLog(logFile, 'skip: VS Code payload, hippo.json runs pre-compact for this chat');
90
+ return;
91
+ }
92
+ if (runtime === 'copilot' && snapshotJustSaved(hippoRoot, sessionId)) {
93
+ appendPreCompactLog(logFile, `skip: snapshot for session ${sessionId} saved under ${TWIN_FIRE_MS / 1000} s ago, by the other preCompact entry`);
94
+ return;
95
+ }
77
96
  // The record is the "something saved before every compaction", so it lands even when no snapshot is derivable below.
78
97
  // Copilot has no PostCompact hook to close a record, so its compactions get the snapshot alone.
79
98
  let recordId = null;
80
- if (runtime === 'claude-code' && sessionId !== null && sessionId !== '') {
99
+ if (runtime === 'claude-code' && !vscode && sessionId !== null && sessionId !== '') {
81
100
  recordId = recordCompactionStart(hippoRoot, { sessionId, trigger: payloadTrigger, cwd: payloadCwd, transcriptPath: payloadTranscriptPath }, (message) => appendPreCompactLog(logFile, message));
82
101
  printPreCompactInstruction(logFile);
83
102
  }
@@ -8,8 +8,11 @@ export interface CopilotTurn {
8
8
  role: 'user' | 'assistant';
9
9
  text: string;
10
10
  }
11
+ export declare const SESSION_ID_RE: RegExp;
11
12
  /** The Copilot CLI log for a session, `<copilotHome>/session-state/<id>/events.jsonl`; null for an unsafe id or a missing file. */
12
13
  export declare function copilotTranscriptFor(sessionId: string): string | null;
14
+ /** True for VS Code's own chat log, `<workspaceStorage>/<id>/github.copilot-chat/transcripts/<session id>.jsonl`, in either slash and any case. */
15
+ export declare function isVscodeTranscript(file: string | null): boolean;
13
16
  /** The turn one Copilot event-log line carries: the text of a human user.message or of an assistant.message; null for any other line, a sub-agent's included. */
14
17
  export declare function copilotTurn(line: CopilotEventLine): CopilotTurn | null;
15
18
  //# sourceMappingURL=copilot-transcript.d.ts.map
@@ -5,7 +5,7 @@ import * as path from 'path';
5
5
  import { copilotHomeDir } from '../hooks/shared.js';
6
6
  import { isObjectLike, isStringValue } from '../capture-contract.js';
7
7
  // A session id becomes a folder name, so anything beyond word characters and hyphens could climb out of session-state.
8
- const SESSION_ID_RE = /^[\w-]+$/;
8
+ export const SESSION_ID_RE = /^[\w-]+$/;
9
9
  // user.message sources that mark text hippo did not get from the human: a hidden skill injection or another agent's prompt.
10
10
  const INJECTED_SOURCE_PREFIXES = ['skill-', 'agent-'];
11
11
  /** The Copilot CLI log for a session, `<copilotHome>/session-state/<id>/events.jsonl`; null for an unsafe id or a missing file. */
@@ -15,6 +15,13 @@ export function copilotTranscriptFor(sessionId) {
15
15
  const file = path.join(copilotHomeDir(), 'session-state', sessionId, 'events.jsonl');
16
16
  return fs.existsSync(file) ? file : null;
17
17
  }
18
+ /** True for VS Code's own chat log, `<workspaceStorage>/<id>/github.copilot-chat/transcripts/<session id>.jsonl`, in either slash and any case. */
19
+ export function isVscodeTranscript(file) {
20
+ if (!file)
21
+ return false;
22
+ const parts = file.toLowerCase().split(/[\\/]/);
23
+ return parts.length >= 3 && parts[parts.length - 2] === 'transcripts' && parts[parts.length - 3] === 'github.copilot-chat';
24
+ }
18
25
  function isSubagentLine(line, data) {
19
26
  if (isStringValue(line.agentId) && line.agentId !== '')
20
27
  return true;
@@ -0,0 +1,13 @@
1
+ import type { SessionTurn } from './transcript.js';
2
+ /** A turn close runs after each reply; a full close runs once, when the session ends. */
3
+ export type WorkerMode = 'turn' | 'full';
4
+ /** Runs `work` unless a live worker holds this session, which then runs the request after its own so the last reply is never dropped. */
5
+ export declare function runSessionWorker(sessionId: string | null, mode: WorkerMode, work: (mode: WorkerMode) => Promise<void>): Promise<void>;
6
+ /** The cursor position after `turns`: how many there were and a hash of the last. */
7
+ export declare function turnPosition(turns: readonly SessionTurn[]): string;
8
+ /** The turns after `position`; all of them when it is null or its last turn is no longer in view. */
9
+ export declare function turnsAfter(turns: readonly SessionTurn[], position: string | null): readonly SessionTurn[];
10
+ /** The saved position for this session's transcript; null when none was saved, it names another file, or it is unreadable. */
11
+ export declare function loadTurnPosition(sessionId: string, transcriptPath: string): string | null;
12
+ export declare function saveTurnPosition(sessionId: string, transcriptPath: string, turns: readonly SessionTurn[], log: (message: string) => void): void;
13
+ //# sourceMappingURL=session-worker.d.ts.map
@@ -0,0 +1,194 @@
1
+ // One session-end worker per session at a time, plus how far turn capture has read that session's transcript.
2
+ // Two fast replies would otherwise start two workers that both read the dedupe keys before either writes.
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import { createHash } from 'node:crypto';
6
+ import { homeDir } from '../hooks/shared.js';
7
+ import { errorMessage } from '../log.js';
8
+ import { isObjectLike, isStringValue } from '../capture-contract.js';
9
+ import { SESSION_ID_RE } from './copilot-transcript.js';
10
+ /** Null for a missing id or one that could climb out of the sessions folder. */
11
+ function sessionStateFiles(sessionId) {
12
+ if (sessionId === null || !SESSION_ID_RE.test(sessionId))
13
+ return null;
14
+ const base = path.join(homeDir(), '.hippo', 'sessions', sessionId);
15
+ return { lock: `${base}.lock`, queued: `${base}.queued`, cursor: `${base}.cursor.json` };
16
+ }
17
+ function pidAlive(pid) {
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ }
22
+ catch (err) {
23
+ // EPERM means the process exists but belongs to someone else.
24
+ return err instanceof Error && 'code' in err && err.code === 'EPERM';
25
+ }
26
+ }
27
+ /** Windows reuses pids, so past this a lock is stale whatever process it names; a worker rarely waits more than minutes on a busy store. */
28
+ const STALE_LOCK_MS = 15 * 60 * 1000;
29
+ /** A dead holder frees its session at once, and any holder at STALE_LOCK_MS. */
30
+ function holderAlive(lock) {
31
+ let text;
32
+ let mtimeMs;
33
+ try {
34
+ text = fs.readFileSync(lock, 'utf8');
35
+ mtimeMs = fs.statSync(lock).mtimeMs;
36
+ }
37
+ catch (err) {
38
+ // Gone means released; any other read error is taken as held, so two workers never run on a guess.
39
+ return !(err instanceof Error && 'code' in err && err.code === 'ENOENT');
40
+ }
41
+ const held = /^(\d+):(\d+)$/.exec(text.trim());
42
+ if (Date.now() - (held ? Number(held[2]) : mtimeMs) > STALE_LOCK_MS)
43
+ return false;
44
+ // Empty or torn: its taker is between the create and the write, so it is held.
45
+ if (!held)
46
+ return true;
47
+ const pid = Number(held[1]);
48
+ return pid !== process.pid && pidAlive(pid);
49
+ }
50
+ // SHORTCUT: two takers racing one stale-lock cleanup can both run, as every worker did before the lock; an atomic link-based take if that shows up.
51
+ function takeSessionLock(lock) {
52
+ for (let attempt = 0; attempt < 2; attempt++) {
53
+ try {
54
+ fs.writeFileSync(lock, `${process.pid}:${Date.now()}`, { flag: 'wx' });
55
+ return 'taken';
56
+ }
57
+ catch (err) {
58
+ if (!(err instanceof Error && 'code' in err && err.code === 'EEXIST'))
59
+ return 'unusable';
60
+ if (holderAlive(lock))
61
+ return 'held';
62
+ }
63
+ try {
64
+ fs.rmSync(lock, { force: true });
65
+ }
66
+ catch {
67
+ return 'unusable';
68
+ }
69
+ }
70
+ return 'held';
71
+ }
72
+ function releaseSessionLock(lock) {
73
+ try {
74
+ fs.rmSync(lock, { force: true });
75
+ }
76
+ catch {
77
+ // A lock left behind names this pid, which is dead once the worker exits, so the next taker clears it.
78
+ }
79
+ }
80
+ /** False when the sessions folder cannot be written, so the caller runs unlocked as before. */
81
+ function queueRun(files, mode) {
82
+ try {
83
+ fs.mkdirSync(path.dirname(files.queued), { recursive: true });
84
+ // A full close outranks a turn close: it overwrites a queued turn, and a turn never replaces a queued full close.
85
+ if (mode === 'full')
86
+ fs.writeFileSync(files.queued, 'full');
87
+ else
88
+ fs.writeFileSync(files.queued, 'turn', { flag: 'wx' });
89
+ return true;
90
+ }
91
+ catch (err) {
92
+ return err instanceof Error && 'code' in err && err.code === 'EEXIST';
93
+ }
94
+ }
95
+ /** Takes the queued request by renaming it, so one queued while this reads it lands in a fresh file. */
96
+ function takeQueued(queued) {
97
+ const taken = `${queued}.${process.pid}`;
98
+ try {
99
+ fs.renameSync(queued, taken);
100
+ }
101
+ catch {
102
+ // Nothing queued, or a rename refused for a moment; the request stays queued for the next round.
103
+ return null;
104
+ }
105
+ try {
106
+ return fs.readFileSync(taken, 'utf8') === 'full' ? 'full' : 'turn';
107
+ }
108
+ finally {
109
+ fs.rmSync(taken, { force: true });
110
+ }
111
+ }
112
+ /** Runs `work` unless a live worker holds this session, which then runs the request after its own so the last reply is never dropped. */
113
+ export async function runSessionWorker(sessionId, mode, work) {
114
+ const files = sessionStateFiles(sessionId);
115
+ if (files === null || !queueRun(files, mode))
116
+ return work(mode);
117
+ for (let take = takeSessionLock(files.lock); take !== 'held'; take = takeSessionLock(files.lock)) {
118
+ if (take === 'unusable')
119
+ return work(mode);
120
+ let ran = false;
121
+ try {
122
+ for (let next = takeQueued(files.queued); next !== null; next = takeQueued(files.queued)) {
123
+ ran = true;
124
+ await work(next);
125
+ }
126
+ }
127
+ finally {
128
+ releaseSessionLock(files.lock);
129
+ }
130
+ // A request queued after the last take found the lock still held and left, so it runs here.
131
+ if (!ran || !fs.existsSync(files.queued))
132
+ return;
133
+ }
134
+ }
135
+ function turnKey(turn) {
136
+ return createHash('sha256').update(`${turn.role}\n${turn.text}`).digest('hex').slice(0, 16);
137
+ }
138
+ /** The cursor position after `turns`: how many there were and a hash of the last. */
139
+ export function turnPosition(turns) {
140
+ return turns.length === 0 ? '0:' : `${turns.length}:${turnKey(turns[turns.length - 1])}`;
141
+ }
142
+ /** The turns after `position`; all of them when it is null or its last turn is no longer in view. */
143
+ export function turnsAfter(turns, position) {
144
+ if (position === null)
145
+ return turns;
146
+ const [countText, key] = position.split(':');
147
+ const count = Number(countText);
148
+ if (!Number.isInteger(count) || count <= 0)
149
+ return turns;
150
+ if (count <= turns.length && turnKey(turns[count - 1]) === key)
151
+ return turns.slice(count);
152
+ // A transcript too big to read whole is read from its tail, so the count shifts; the last turn's text still marks the place.
153
+ for (let i = turns.length - 1; i >= 0; i--) {
154
+ if (turnKey(turns[i]) === key)
155
+ return turns.slice(i + 1);
156
+ }
157
+ return turns;
158
+ }
159
+ /** The saved position for this session's transcript; null when none was saved, it names another file, or it is unreadable. */
160
+ export function loadTurnPosition(sessionId, transcriptPath) {
161
+ const file = sessionStateFiles(sessionId)?.cursor;
162
+ if (!file || !fs.existsSync(file))
163
+ return null;
164
+ try {
165
+ const cursor = JSON.parse(fs.readFileSync(file, 'utf8'));
166
+ if (!isObjectLike(cursor) || !('source' in cursor) || cursor.source !== transcriptPath)
167
+ return null;
168
+ return 'position' in cursor && isStringValue(cursor.position) ? cursor.position : null;
169
+ }
170
+ catch {
171
+ // A torn cursor means the next capture reads every turn, which the dedupe keys absorb.
172
+ return null;
173
+ }
174
+ }
175
+ // SHORTCUT: one small cursor file per chat is never pruned; prune by age at sleep if the folder grows.
176
+ export function saveTurnPosition(sessionId, transcriptPath, turns, log) {
177
+ const file = sessionStateFiles(sessionId)?.cursor;
178
+ if (!file)
179
+ return;
180
+ const cursor = {
181
+ runtime: 'vscode', sessionId, source: transcriptPath, position: turnPosition(turns), updatedAt: new Date().toISOString(),
182
+ };
183
+ const tmp = `${file}.${process.pid}.tmp`;
184
+ try {
185
+ fs.mkdirSync(path.dirname(file), { recursive: true });
186
+ fs.writeFileSync(tmp, JSON.stringify(cursor));
187
+ fs.renameSync(tmp, file);
188
+ }
189
+ catch (err) {
190
+ // Windows refuses the rename (EPERM) while another process has the cursor open; the next reply re-reads these turns, which the dedupe keys absorb.
191
+ log(`cursor not saved: ${errorMessage(err)}`);
192
+ }
193
+ }
194
+ //# sourceMappingURL=session-worker.js.map
@@ -1,5 +1,5 @@
1
1
  /** A host lifecycle moment hippo can capture from. */
2
- export type CaptureEvent = 'prompt' | 'tool-failure' | 'pre-compact' | 'post-compact' | 'session-start' | 'session-end' | 'reset';
2
+ export type CaptureEvent = 'prompt' | 'tool-failure' | 'pre-compact' | 'post-compact' | 'session-start' | 'session-end' | 'turn-end' | 'reset';
3
3
  /** What hippo read from one host payload, before anything is written. */
4
4
  export interface CaptureInput {
5
5
  readonly runtime: string;
@@ -46,4 +46,6 @@ export declare function isObjectLike<T>(value: T): value is T & object;
46
46
  export type HookRuntime = 'claude-code' | 'copilot';
47
47
  /** Reads a Claude Code PreCompact payload, or a Copilot one after stdin.ts mapped it to the same snake_case keys; only an empty stdin counts as a manual run. */
48
48
  export declare function readClaudeCodePreCompact(stdinText: string | undefined, timedOut: boolean, runtime?: HookRuntime): CaptureReceipt;
49
+ /** Reads VS Code's per-reply Stop payload. Pass the raw stdin: stdin.ts would give a Copilot CLI agentStop the same snake_case keys. */
50
+ export declare function readVscodeStop(stdinText: string | undefined, timedOut: boolean): CaptureReceipt;
49
51
  //# sourceMappingURL=capture-contract.d.ts.map
@@ -46,4 +46,40 @@ export function readClaudeCodePreCompact(stdinText, timedOut, runtime = 'claude-
46
46
  },
47
47
  };
48
48
  }
49
+ /** Reads VS Code's per-reply Stop payload. Pass the raw stdin: stdin.ts would give a Copilot CLI agentStop the same snake_case keys. */
50
+ export function readVscodeStop(stdinText, timedOut) {
51
+ if (!stdinText || stdinText.trim() === '') {
52
+ return timedOut
53
+ ? { status: 'unavailable', reason: 'no Stop payload arrived before the stdin wait window closed' }
54
+ : { status: 'skipped', reason: 'no payload: turn capture runs only from a VS Code Stop hook' };
55
+ }
56
+ let payload;
57
+ try {
58
+ payload = JSON.parse(stdinText.trim());
59
+ }
60
+ catch {
61
+ // Non-JSON stdin leaves payload undefined, which the shape check rejects.
62
+ }
63
+ if (!isObjectLike(payload) || !('hook_event_name' in payload) || payload.hook_event_name !== 'Stop') {
64
+ return { status: 'skipped', reason: 'not a VS Code Stop payload (no snake_case hook_event_name "Stop")' };
65
+ }
66
+ if (!('transcript_path' in payload) || !isStringValue(payload.transcript_path) || !/\.jsonl$/i.test(payload.transcript_path)) {
67
+ return { status: 'skipped', reason: 'Stop payload names no .jsonl transcript_path' };
68
+ }
69
+ if (!('session_id' in payload) || !isStringValue(payload.session_id) || payload.session_id === '') {
70
+ return { status: 'skipped', reason: 'Stop payload has no session_id' };
71
+ }
72
+ return {
73
+ status: 'received',
74
+ input: {
75
+ runtime: 'vscode',
76
+ event: 'turn-end',
77
+ manual: false,
78
+ sessionId: payload.session_id,
79
+ cwd: 'cwd' in payload && isStringValue(payload.cwd) ? payload.cwd : null,
80
+ transcriptPath: payload.transcript_path,
81
+ trigger: null,
82
+ },
83
+ };
84
+ }
49
85
  //# sourceMappingURL=capture-contract.js.map