hippo-memory 1.67.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.
- package/README.md +1 -1
- package/dist/agent-memories/copilot.d.ts +3 -1
- package/dist/agent-memories/copilot.js +2 -2
- package/dist/api/auth.d.ts +14 -18
- package/dist/api/auth.js +93 -60
- package/dist/api/context-select.d.ts +10 -0
- package/dist/api/context-select.js +16 -18
- package/dist/api/context.js +101 -60
- package/dist/api/forget.d.ts +2 -2
- package/dist/api/forget.js +11 -0
- package/dist/api/outcome.d.ts +4 -4
- package/dist/api/outcome.js +29 -6
- package/dist/api/promote.d.ts +3 -3
- package/dist/api/promote.js +45 -90
- package/dist/api/remember.d.ts +3 -2
- package/dist/api/remember.js +38 -11
- package/dist/api/types.d.ts +9 -0
- package/dist/auth.d.ts +19 -0
- package/dist/auth.js +14 -6
- package/dist/capture/compact.js +21 -2
- package/dist/capture/copilot-transcript.d.ts +3 -0
- package/dist/capture/copilot-transcript.js +8 -1
- package/dist/capture/session-worker.d.ts +13 -0
- package/dist/capture/session-worker.js +194 -0
- package/dist/capture-contract.d.ts +3 -1
- package/dist/capture-contract.js +36 -0
- package/dist/cli/session-hooks.js +90 -22
- package/dist/cli/setup.js +70 -24
- package/dist/cli/usage.js +4 -1
- package/dist/cli.js +1 -1
- package/dist/connectors/github/ingest.js +2 -1
- package/dist/connectors/slack/deletion.js +2 -1
- package/dist/connectors/slack/ingest.js +2 -1
- package/dist/embeddings.d.ts +10 -10
- package/dist/embeddings.js +103 -18
- package/dist/env.d.ts +2 -0
- package/dist/env.js +2 -0
- package/dist/hooks/copilot.d.ts +50 -8
- package/dist/hooks/copilot.js +116 -30
- package/dist/hooks/json-hooks.d.ts +2 -0
- package/dist/hooks/json-hooks.js +11 -2
- package/dist/hooks/shared.d.ts +7 -1
- package/dist/hooks/shared.js +18 -3
- package/dist/mcp/memory-tools.d.ts +2 -2
- package/dist/mcp/memory-tools.js +9 -8
- package/dist/mcp/request.js +3 -3
- package/dist/memory.d.ts +2 -0
- package/dist/memory.js +7 -0
- package/dist/prompt-recall.d.ts +5 -0
- package/dist/prompt-recall.js +13 -0
- package/dist/raw-archive.d.ts +2 -2
- package/dist/raw-archive.js +5 -3
- package/dist/recall-scope.d.ts +2 -5
- package/dist/recall-scope.js +5 -6
- package/dist/server/routes/admin.js +2 -2
- package/dist/server/routes/memories.js +9 -6
- package/dist/server-detect.js +3 -2
- package/dist/server.d.ts +20 -4
- package/dist/server.js +23 -11
- package/dist/store/audit-event.d.ts +1 -1
- package/dist/store/audit-event.js +1 -1
- package/dist/store/entry-writes-group.d.ts +9 -0
- package/dist/store/entry-writes-group.js +157 -0
- package/dist/store/handoffs.d.ts +5 -2
- package/dist/store/handoffs.js +35 -5
- package/dist/store/key-writes.d.ts +9 -0
- package/dist/store/key-writes.js +30 -0
- package/dist/store/open.d.ts +2 -0
- package/dist/store/open.js +10 -0
- package/dist/store/search-rows.js +3 -9
- package/dist/store/sessions.d.ts +5 -0
- package/dist/store/sessions.js +4 -1
- package/dist/store/vector-writes.d.ts +6 -0
- package/dist/store/vector-writes.js +59 -0
- package/dist/store-port.d.ts +143 -1
- package/dist/store-port.js +48 -12
- 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
|
@@ -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
|
package/dist/capture-contract.js
CHANGED
|
@@ -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
|
|
@@ -21,11 +21,15 @@ import { cmdPreCompact, cmdPostCompact } from '../capture/compact.js';
|
|
|
21
21
|
import { transcriptWorkingState } from '../capture/working-state.js';
|
|
22
22
|
import { collectHandoffEvidence } from '../handoff-evidence.js';
|
|
23
23
|
import { resolveLastSessionTranscript } from '../capture/transcript.js';
|
|
24
|
-
import { copilotTranscriptFor } from '../capture/copilot-transcript.js';
|
|
24
|
+
import { copilotTranscriptFor, SESSION_ID_RE } from '../capture/copilot-transcript.js';
|
|
25
|
+
import { loadTurnPosition, runSessionWorker, saveTurnPosition, turnsAfter } from '../capture/session-worker.js';
|
|
26
|
+
import { isStringValue, readVscodeStop } from '../capture-contract.js';
|
|
27
|
+
import { loadConfig } from '../config.js';
|
|
28
|
+
import { countCreatedSinceLastSleep } from '../store/index-and-stats.js';
|
|
25
29
|
import { truncateCodePointSafe } from '../transcript-tail.js';
|
|
26
30
|
import { COMPACTION_DB_WAIT_MS } from '../compaction-record.js';
|
|
27
31
|
import { COMPACT_RESUME_EVENT_CONTENT_CAP, COMPACT_RESUME_MAX_AGE_MS, compactResumeText } from '../context-render.js';
|
|
28
|
-
import { readHookStdin } from '../stdin.js';
|
|
32
|
+
import { normaliseHookPayload, readHookStdin, readStdinBounded } from '../stdin.js';
|
|
29
33
|
import { resolveTenantId } from '../tenant.js';
|
|
30
34
|
import { errorMessage, log } from '../log.js';
|
|
31
35
|
import { withLedgerDb } from '../ledger-db.js';
|
|
@@ -143,11 +147,16 @@ function restoreCompactSnapshot(hippoRoot, payloadSessionId) {
|
|
|
143
147
|
*/
|
|
144
148
|
export async function cmdSessionEnd(hippoRoot, flags) {
|
|
145
149
|
const runtime = hookRuntime(flags);
|
|
150
|
+
const turn = flags['turn'] === true;
|
|
146
151
|
// Copilot's hook command carries no path, since one quoted into it would need escaping for each shell; the log goes where the hook table used to point.
|
|
147
152
|
const logFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : runtime === 'copilot' ? resolveJsonHookPaths('copilot').logFile : null;
|
|
148
153
|
// Bounded read: extracts transcript_path + session_id for the detached worker's argv.
|
|
149
154
|
let sessionId = null;
|
|
150
|
-
const
|
|
155
|
+
const raw = await readStdinBounded();
|
|
156
|
+
// Read before normalising: the Copilot CLI's agentStop shares this hook line, and camelCase keys would pass as VS Code's.
|
|
157
|
+
if (turn && !isVscodeStopPayload(raw))
|
|
158
|
+
return;
|
|
159
|
+
const stdinText = normaliseHookPayload(raw.text);
|
|
151
160
|
// Before the spawn, since the worker finds its store from the folder it inherits.
|
|
152
161
|
const root = payloadCwdRoot(hippoRoot, stdinText, runtime);
|
|
153
162
|
try {
|
|
@@ -172,6 +181,8 @@ export async function cmdSessionEnd(hippoRoot, flags) {
|
|
|
172
181
|
workerArgs.push('--transcript', transcriptPath);
|
|
173
182
|
if (sessionId)
|
|
174
183
|
workerArgs.push('--session-id', sessionId);
|
|
184
|
+
if (turn)
|
|
185
|
+
workerArgs.push('--turn');
|
|
175
186
|
try {
|
|
176
187
|
const child = spawn(process.execPath, workerArgs, {
|
|
177
188
|
detached: true,
|
|
@@ -194,7 +205,16 @@ export async function cmdSessionEnd(hippoRoot, flags) {
|
|
|
194
205
|
return;
|
|
195
206
|
}
|
|
196
207
|
}
|
|
208
|
+
/** A VS Code Stop payload with a session id that can name the lock file; anything else makes turn mode a silent no-op. */
|
|
209
|
+
function isVscodeStopPayload(raw) {
|
|
210
|
+
const receipt = readVscodeStop(raw.text, raw.timedOut);
|
|
211
|
+
return receipt.status === 'received' && receipt.input.sessionId !== null && SESSION_ID_RE.test(receipt.input.sessionId);
|
|
212
|
+
}
|
|
197
213
|
export async function cmdSessionEndWorker(hippoRoot, flags) {
|
|
214
|
+
const sessionId = flags['session-id'];
|
|
215
|
+
await runSessionWorker(isStringValue(sessionId) ? sessionId : null, flags['turn'] === true ? 'turn' : 'full', (mode) => sessionEndWork(hippoRoot, flags, mode));
|
|
216
|
+
}
|
|
217
|
+
async function sessionEndWork(hippoRoot, flags, mode) {
|
|
198
218
|
const transcriptPath = typeof flags['transcript'] === 'string' ? flags['transcript'] : undefined;
|
|
199
219
|
const closeLogFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : null;
|
|
200
220
|
const closeSessionId = typeof flags['session-id'] === 'string' ? flags['session-id'] : null;
|
|
@@ -211,11 +231,18 @@ export async function cmdSessionEndWorker(hippoRoot, flags) {
|
|
|
211
231
|
flushRereadLog();
|
|
212
232
|
return;
|
|
213
233
|
}
|
|
214
|
-
|
|
234
|
+
if (mode === 'turn' && closeSessionId)
|
|
235
|
+
await sleepIfDue(hippoRoot, flags, closeLogFile, transcriptPath, closeSessionId);
|
|
236
|
+
else
|
|
237
|
+
await sleepProjectStore(hippoRoot, flags, closeLogFile, transcriptPath);
|
|
215
238
|
flushRereadLog();
|
|
216
239
|
const digestLog = (message) => appendSessionEndCloseLog(closeLogFile, message);
|
|
217
240
|
const scan = transcriptPath ? readSessionScan(transcriptPath, digestLog) : null;
|
|
218
|
-
captureEndedSession(hippoRoot, store, flags, transcriptPath,
|
|
241
|
+
const capture = (turns) => captureEndedSession(hippoRoot, store, flags, transcriptPath, turns);
|
|
242
|
+
if (mode === 'turn')
|
|
243
|
+
captureNewTurns(transcriptPath, closeSessionId, scan, capture, digestLog);
|
|
244
|
+
else
|
|
245
|
+
capture(scan?.turns);
|
|
219
246
|
recordSessionDigest(hippoRoot, scan, {
|
|
220
247
|
key: closeSessionId || path.basename(transcriptPath ?? '', '.jsonl'),
|
|
221
248
|
tenantId: resolveTenantId({}),
|
|
@@ -225,7 +252,12 @@ export async function cmdSessionEndWorker(hippoRoot, flags) {
|
|
|
225
252
|
// session-end may never fire (crash, kill -9) the freshness bound in loadFreshActiveTaskSnapshot is the backstop.
|
|
226
253
|
// The handoff is written first, while the snapshot writeSessionEndHandoff reads is still active.
|
|
227
254
|
if (closeSessionId)
|
|
228
|
-
writeEndHandoff(store, closeSessionId, transcriptPath, closeLogFile);
|
|
255
|
+
writeEndHandoff(store, closeSessionId, transcriptPath, closeLogFile, mode === 'turn');
|
|
256
|
+
if (mode === 'turn') {
|
|
257
|
+
// The chat goes on after a reply, so its snapshot stays for the next compaction to restore.
|
|
258
|
+
appendSessionEndCloseLog(closeLogFile, 'skip snapshot close: turn mode');
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
229
261
|
try {
|
|
230
262
|
if (closeSessionId) {
|
|
231
263
|
const closed = closeTaskSnapshotsForSession(store, resolveTenantId({}), closeSessionId);
|
|
@@ -255,32 +287,68 @@ async function sleepProjectStore(hippoRoot, flags, closeLogFile, transcriptPath)
|
|
|
255
287
|
logSessionEndImport(closeLogFile, transcriptPath);
|
|
256
288
|
}
|
|
257
289
|
}
|
|
258
|
-
|
|
290
|
+
/** A close after every reply sleeps only at the MCP server's auto-sleep threshold; each line starts the log afresh, as a sleep does. */
|
|
291
|
+
async function sleepIfDue(hippoRoot, flags, closeLogFile, transcriptPath, sessionId) {
|
|
292
|
+
if (!isInitialized(hippoRoot))
|
|
293
|
+
return sleepProjectStore(hippoRoot, flags, closeLogFile, transcriptPath);
|
|
294
|
+
const prefix = `turn close, session ${sessionId}`;
|
|
295
|
+
try {
|
|
296
|
+
const { enabled, threshold } = loadConfig(hippoRoot).autoSleep;
|
|
297
|
+
const count = enabled ? countCreatedSinceLastSleep(hippoRoot, resolveTenantId({})) : 0;
|
|
298
|
+
if (!enabled || count < threshold) {
|
|
299
|
+
const why = enabled ? `${count} new memories, threshold ${threshold}` : 'auto-sleep is off';
|
|
300
|
+
appendSessionEndCloseLog(closeLogFile, `${prefix}: skip sleep, ${why}`, { startFresh: true });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
await sleepProjectStore(hippoRoot, flags, closeLogFile, transcriptPath);
|
|
304
|
+
appendSessionEndCloseLog(closeLogFile, `${prefix}: ran sleep at ${count} new memories (threshold ${threshold})`);
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
appendSessionEndCloseLog(closeLogFile, `${prefix}: sleep check failed: ${errorMessage(err)}`, { startFresh: true });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** Captures only the turns after this session's cursor, then moves the cursor, so each reply is extracted once. */
|
|
311
|
+
function captureNewTurns(transcriptPath, sessionId, scan, capture, log) {
|
|
312
|
+
if (!transcriptPath || !sessionId || !scan) {
|
|
313
|
+
log('skip capture: no readable transcript for this session');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const fresh = turnsAfter(scan.turns, loadTurnPosition(sessionId, transcriptPath));
|
|
317
|
+
if (fresh.length === 0) {
|
|
318
|
+
log('skip capture: no new turns since the last reply');
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (capture(fresh))
|
|
322
|
+
saveTurnPosition(sessionId, transcriptPath, scan.turns, log);
|
|
323
|
+
}
|
|
324
|
+
/** True when capture ran to the end, so a turn close may move its cursor past these turns. */
|
|
325
|
+
function captureEndedSession(hippoRoot, store, flags, transcriptPath, turns) {
|
|
259
326
|
try {
|
|
260
327
|
const logFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined;
|
|
261
328
|
// With no stdin of its own, capture would read this as a manual run and scan every project.
|
|
262
329
|
if (!transcriptPath) {
|
|
263
330
|
appendSessionEndCloseLog(logFile ?? null, 'skip capture: no transcript for this session');
|
|
331
|
+
return false;
|
|
264
332
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
333
|
+
cmdCapture(store, {
|
|
334
|
+
source: 'last-session',
|
|
335
|
+
transcriptPath,
|
|
336
|
+
logFile,
|
|
337
|
+
dryRun: false,
|
|
338
|
+
global: false,
|
|
339
|
+
tenantId: resolveTenantId({}),
|
|
340
|
+
// In the global store, rows would otherwise read as user-global and show up in every project.
|
|
341
|
+
originProject: store === hippoRoot ? undefined : resolveProjectIdentity(process.cwd()),
|
|
342
|
+
sessionTurns: turns,
|
|
343
|
+
});
|
|
344
|
+
return true;
|
|
278
345
|
}
|
|
279
346
|
catch {
|
|
280
347
|
// Same treatment — the failure line is already in the log.
|
|
348
|
+
return false;
|
|
281
349
|
}
|
|
282
350
|
}
|
|
283
|
-
function writeEndHandoff(store, closeSessionId, transcriptPath, closeLogFile) {
|
|
351
|
+
function writeEndHandoff(store, closeSessionId, transcriptPath, closeLogFile, inPlace = false) {
|
|
284
352
|
try {
|
|
285
353
|
const tenantId = resolveTenantId({});
|
|
286
354
|
const ownSnapshot = loadActiveTaskSnapshot(store, tenantId)?.session_id === closeSessionId;
|
|
@@ -293,7 +361,7 @@ function writeEndHandoff(store, closeSessionId, transcriptPath, closeLogFile) {
|
|
|
293
361
|
}
|
|
294
362
|
else {
|
|
295
363
|
const evidence = collectHandoffEvidence(process.cwd(), 'unknown');
|
|
296
|
-
const handoff = writeSessionEndHandoff(store, tenantId, closeSessionId, evidence, derived);
|
|
364
|
+
const handoff = writeSessionEndHandoff(store, tenantId, closeSessionId, evidence, derived, undefined, { inPlace });
|
|
297
365
|
appendSessionEndCloseLog(closeLogFile, handoff ? `wrote handoff for session ${closeSessionId}` : `skip: kept the existing handoff for session ${closeSessionId}`);
|
|
298
366
|
}
|
|
299
367
|
}
|
package/dist/cli/setup.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import * as fs from 'fs';
|
|
4
4
|
import { execFileSync } from 'child_process';
|
|
5
|
-
import { installJsonHooks, uninstallJsonHooks, checkUninstallable, resolveJsonHookPaths } from '../hooks/json-hooks.js';
|
|
5
|
+
import { installJsonHooks, uninstallJsonHooks, checkUninstallable, copilotHookEvents, resolveJsonHookPaths } from '../hooks/json-hooks.js';
|
|
6
6
|
import { detectInstalledTools } from '../hooks/shared.js';
|
|
7
7
|
import { ensureCodexWrapperInstalled, installCodexWrapper, detectRealCodexPath, uninstallCodexWrapper, } from '../hooks/codex-wrapper.js';
|
|
8
8
|
import { installOpencodePlugin, uninstallOpencodePlugin, resolveOpencodePluginPath } from '../hooks/opencode.js';
|
|
9
|
-
import { copilotMcpSnippet, copilotPaths, installCopilot, isMcpFailure, uninstallCopilot } from '../hooks/copilot.js';
|
|
9
|
+
import { COPILOT_CLI_MCP, VSCODE_MCP, copilotMcpSnippet, copilotPaths, installCopilot, isCopilotCliPresent, isMcpFailure, uninstallCopilot, } from '../hooks/copilot.js';
|
|
10
10
|
import { isInitialized } from '../store/open.js';
|
|
11
11
|
import { currentMachine, importUserMemories } from '../agent-memories/sync.js';
|
|
12
12
|
import { getGlobalRoot } from '../shared.js';
|
|
@@ -42,7 +42,7 @@ function hookList() {
|
|
|
42
42
|
for (const [name, hook] of Object.entries(HOOKS)) {
|
|
43
43
|
console.log(` ${name.padEnd(15)} -> ${hook.file} (${hook.description})`);
|
|
44
44
|
}
|
|
45
|
-
console.log(` ${'copilot'.padEnd(15)} -> $COPILOT_HOME or ~/.copilot (GitHub Copilot: hooks, MCP server and instructions)`);
|
|
45
|
+
console.log(` ${'copilot'.padEnd(15)} -> $COPILOT_HOME or ~/.copilot, and each VS Code User folder (GitHub Copilot: hooks, MCP server and instructions)`);
|
|
46
46
|
console.log('\nUsage: hippo hook install <name>');
|
|
47
47
|
console.log(' hippo hook uninstall <name>');
|
|
48
48
|
}
|
|
@@ -155,11 +155,12 @@ function installOpencodeHook() {
|
|
|
155
155
|
console.log(`WARNING: opencode.json is unparseable; legacy hooks block could not be auto-removed. Fix the file manually.`);
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
|
+
const addByHand = (host) => `add this under "${host.key}" by hand: ${copilotMcpSnippet(host)}`;
|
|
158
159
|
const MCP_MERGE_LINES = {
|
|
159
160
|
added: (file) => `Added the "hippo" MCP server -> ${file}`,
|
|
160
161
|
present: (file) => `The "hippo" MCP server is already in ${file}`,
|
|
161
162
|
'user-owned': (file) => `Left the "hippo" MCP server in ${file} as it is: hippo did not write it`,
|
|
162
|
-
unreadable: (file) => `WARNING: ${file} has comments or is not a JSON object hippo can merge into, so it was left unchanged;
|
|
163
|
+
unreadable: (file, host) => `WARNING: ${file} has comments or is not a JSON object hippo can merge into, so it was left unchanged; ${addByHand(host)}`,
|
|
163
164
|
};
|
|
164
165
|
const mcpFailureLine = (file, failure, fix) => `WARNING: hippo could not use ${file} (${failure.failed}), so it was left unchanged; ${fix}`;
|
|
165
166
|
const unclosedLine = (file) => `WARNING: ${file} has ${HOOK_MARKERS.start} with no ${HOOK_MARKERS.end}, so hippo left it unchanged; fix the markers by hand`;
|
|
@@ -175,32 +176,74 @@ const INSTRUCTIONS_REMOVE_LINES = {
|
|
|
175
176
|
kept: (file) => `Left the hippo block in ${file}: it is not the Copilot text hippo wrote. Delete it by hand if no agent needs it.`,
|
|
176
177
|
unclosed: unclosedLine,
|
|
177
178
|
};
|
|
179
|
+
const VSCODE_INSTRUCTIONS_INSTALL_LINES = {
|
|
180
|
+
written: (file) => `Wrote the hippo instructions file -> ${file}`,
|
|
181
|
+
present: (file) => `The hippo instructions file is already at ${file}`,
|
|
182
|
+
kept: (file) => `Kept ${file} as it is: it is not the text hippo writes, so it was edited or is someone else's`,
|
|
183
|
+
};
|
|
184
|
+
const VSCODE_INSTRUCTIONS_REMOVE_LINES = {
|
|
185
|
+
removed: (file) => `Removed the hippo instructions file ${file}`,
|
|
186
|
+
absent: () => '',
|
|
187
|
+
kept: (file) => `Left ${file}: it is not the text hippo wrote. Delete it by hand if no agent needs it.`,
|
|
188
|
+
};
|
|
189
|
+
const VSCODE_HOOKS_LINE = 'VS Code: hooks need VS Code 1.109.3 or later with chat.useHooks on (the default); older versions get the MCP server and the instructions file only.';
|
|
190
|
+
function mcpInstallLine(file, mcp, host) {
|
|
191
|
+
return isMcpFailure(mcp) ? mcpFailureLine(file, mcp, addByHand(host)) : MCP_MERGE_LINES[mcp](file, host);
|
|
192
|
+
}
|
|
193
|
+
function printVscodeInstall(result, indent) {
|
|
194
|
+
const { paths } = result;
|
|
195
|
+
console.log(`${indent}${mcpInstallLine(paths.mcpConfig, result.mcp, VSCODE_MCP)}`);
|
|
196
|
+
console.log(`${indent}${VSCODE_INSTRUCTIONS_INSTALL_LINES[result.instructions](paths.instructions)}`);
|
|
197
|
+
if (paths.profiles.length > 0) {
|
|
198
|
+
const names = paths.profiles.map((dir) => path.basename(dir)).join(', ');
|
|
199
|
+
console.log(`${indent}Found VS Code profiles (${names}) under ${path.join(paths.userDir, 'profiles')}: hippo set up the default profile only; copy the "hippo" server and prompts/hippo.instructions.md into a profile to use hippo there.`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
178
202
|
function printCopilotInstall(result, indent) {
|
|
179
|
-
const { paths
|
|
180
|
-
const hooks = result.hooks ?
|
|
203
|
+
const { paths } = result;
|
|
204
|
+
const hooks = result.hooks ? `Installed hippo's Copilot hooks (${copilotHookEvents().join(', ')}) ->` : "hippo's Copilot hooks are already in";
|
|
181
205
|
console.log(`${indent}${hooks} ${paths.hooks}`);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
206
|
+
if (result.mcp === null || result.instructions === null) {
|
|
207
|
+
console.log(`${indent}No Copilot CLI files in ${path.dirname(paths.mcpConfig)}, so hippo skipped mcp-config.json and copilot-instructions.md, which only the Copilot CLI reads`);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
console.log(`${indent}${mcpInstallLine(paths.mcpConfig, result.mcp, COPILOT_CLI_MCP)}`);
|
|
211
|
+
console.log(`${indent}${INSTRUCTIONS_INSTALL_LINES[result.instructions](paths.instructions)}`);
|
|
212
|
+
}
|
|
213
|
+
for (const vscode of result.vscode)
|
|
214
|
+
printVscodeInstall(vscode, indent);
|
|
215
|
+
if (result.vscode.length > 0)
|
|
216
|
+
console.log(`${indent}${VSCODE_HOOKS_LINE}`);
|
|
185
217
|
console.log(`${indent}VS Code Copilot runs new hooks in a new chat session.`);
|
|
186
218
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (result.hooks)
|
|
190
|
-
console.log(`Removed hippo's Copilot hooks file ${paths.hooks}`);
|
|
219
|
+
/** The lines for one MCP config file; empty when hippo's server was not there. */
|
|
220
|
+
function mcpRemoveLines(file, mcp) {
|
|
191
221
|
if (isMcpFailure(mcp))
|
|
192
|
-
|
|
222
|
+
return [mcpFailureLine(file, mcp, 'remove the "hippo" server by hand if hippo added it')];
|
|
193
223
|
if (mcp === 'removed')
|
|
194
|
-
|
|
224
|
+
return [`Removed the "hippo" MCP server from ${file}`];
|
|
195
225
|
if (mcp === 'user-owned')
|
|
196
|
-
|
|
226
|
+
return [`Left the "hippo" MCP server in ${file}: hippo did not write it`];
|
|
197
227
|
if (mcp === 'unreadable')
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
228
|
+
return [`WARNING: ${file} has comments or is not a JSON object hippo can edit, so it was left unchanged; remove the "hippo" server by hand if hippo added it`];
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
function printCopilotUninstall(result) {
|
|
232
|
+
const { paths, mcp } = result;
|
|
233
|
+
const lines = [];
|
|
234
|
+
if (result.hooks)
|
|
235
|
+
lines.push(`Removed hippo's Copilot hooks file ${paths.hooks}`);
|
|
236
|
+
lines.push(...mcpRemoveLines(paths.mcpConfig, mcp));
|
|
237
|
+
lines.push(INSTRUCTIONS_REMOVE_LINES[result.instructions](paths.instructions));
|
|
238
|
+
for (const vscode of result.vscode) {
|
|
239
|
+
lines.push(...mcpRemoveLines(vscode.paths.mcpConfig, vscode.mcp));
|
|
240
|
+
lines.push(VSCODE_INSTRUCTIONS_REMOVE_LINES[vscode.instructions](vscode.paths.instructions));
|
|
241
|
+
}
|
|
242
|
+
const printed = lines.filter((line) => line !== '');
|
|
243
|
+
for (const line of printed)
|
|
244
|
+
console.log(line);
|
|
245
|
+
if (printed.length === 0)
|
|
246
|
+
console.log('No hippo Copilot hooks, MCP server or instructions found.');
|
|
204
247
|
}
|
|
205
248
|
function hookUninstall(target) {
|
|
206
249
|
if (target === 'copilot')
|
|
@@ -330,8 +373,11 @@ function setupCopilot(dryRun) {
|
|
|
330
373
|
const indent = ` ${'copilot'.padEnd(14)} `;
|
|
331
374
|
if (!dryRun)
|
|
332
375
|
return printCopilotInstall(installCopilot(), indent);
|
|
333
|
-
const { hooks, mcpConfig, instructions } = copilotPaths();
|
|
334
|
-
|
|
376
|
+
const { hooks, mcpConfig, instructions, vscode } = copilotPaths();
|
|
377
|
+
const cliFiles = isCopilotCliPresent() ? `, the MCP server in ${mcpConfig} and the hippo block in ${instructions}` : '';
|
|
378
|
+
console.log(`[dry-run] would install hooks in ${hooks}${cliFiles}`);
|
|
379
|
+
for (const dir of vscode)
|
|
380
|
+
console.log(`[dry-run] would add the MCP server to ${dir.mcpConfig} and write ${dir.instructions}`);
|
|
335
381
|
}
|
|
336
382
|
function setupJsonTool(tool, dryRun) {
|
|
337
383
|
if (tool.name === 'copilot')
|
package/dist/cli/usage.js
CHANGED
|
@@ -258,7 +258,10 @@ export const VERB_USAGE = {
|
|
|
258
258
|
--log-file <path> Tee the worker's output to a log file (paired with 'hippo last-sleep')
|
|
259
259
|
--runtime copilot The payload came from a Copilot hook: use the store of its cwd and find
|
|
260
260
|
the Copilot CLI session log by session id; with no --log-file, log to
|
|
261
|
-
~/.hippo/logs/copilot-sleep.log
|
|
261
|
+
~/.hippo/logs/copilot-sleep.log
|
|
262
|
+
--turn VS Code Stop hook, after each reply: capture only the new turns, sleep
|
|
263
|
+
only at the auto-sleep threshold, and keep the session's snapshot; does
|
|
264
|
+
nothing for any other payload`],
|
|
262
265
|
'pre-compact': [`
|
|
263
266
|
pre-compact PreCompact hook: record the compaction, save a working-state snapshot, and
|
|
264
267
|
ask the summariser to end with a "Memories for hippo" list
|
package/dist/cli.js
CHANGED
|
@@ -57,7 +57,7 @@ export const BOOLEAN_FLAGS = new Set([
|
|
|
57
57
|
'no-learn', 'no-mmr', 'no-propagate', 'no-schedule', 'no-share', 'no-summarize-older',
|
|
58
58
|
'observed', 'open', 'physics', 'pin', 'pinned-only', 'reject-loser', 'rerank-utility',
|
|
59
59
|
'reset-physics', 'save-baseline', 'show-cases', 'stats', 'stdin',
|
|
60
|
-
'strict', 'suite', 'value-aware', 'verified', 'version', 'why',
|
|
60
|
+
'strict', 'suite', 'turn', 'value-aware', 'verified', 'version', 'why',
|
|
61
61
|
]);
|
|
62
62
|
// Every flag some command reads. Anything else is a typo that no command would act on.
|
|
63
63
|
export const KNOWN_FLAGS = new Set([
|
|
@@ -132,7 +132,8 @@ function markKeySeenWithoutMemory(hippoRoot, idempotencyKey, input) {
|
|
|
132
132
|
}
|
|
133
133
|
function rememberWithEventLog(ctx, input, idempotencyKey, opts) {
|
|
134
134
|
// No `|| 'connector:github'` fallback (see rememberWithEventLog in slack/ingest.ts for rationale).
|
|
135
|
-
const result = remember(ctx,
|
|
135
|
+
const result = remember({ ...ctx, store: undefined }, // the event log row commits with the memory on hippo.db's own handle, never through a store
|
|
136
|
+
{
|
|
136
137
|
...opts,
|
|
137
138
|
untrusted: true,
|
|
138
139
|
afterWrite: (innerDb, memoryId) => {
|
|
@@ -44,7 +44,8 @@ export function handleMessageDeleted(ctx, input) {
|
|
|
44
44
|
// Archive + event-log mark commit together via afterArchive. The hook
|
|
45
45
|
// receives the same db handle the archive is using, so the INSERT lives
|
|
46
46
|
// inside the SAVEPOINT.
|
|
47
|
-
archiveRaw(ctx,
|
|
47
|
+
archiveRaw({ ...ctx, store: undefined }, // afterArchive runs on hippo.db's own handle, never through a store
|
|
48
|
+
memoryId, `source_deleted:slack:${input.teamId}:${input.channelId}:${input.deletedTs}`, {
|
|
48
49
|
afterArchive: (sameDb, archivedId) => {
|
|
49
50
|
markEventSeen(sameDb, input.eventId, archivedId);
|
|
50
51
|
},
|
|
@@ -63,7 +63,8 @@ export function ingestMessage(ctx, input) {
|
|
|
63
63
|
function rememberWithEventLog(ctx, eventId, opts) {
|
|
64
64
|
// No `|| 'connector:slack'` fallback: the caller always builds ctx with the connector subject, and with
|
|
65
65
|
// an object-shaped Context.actor an OR-fallback would never fire anyway.
|
|
66
|
-
const result = remember(ctx,
|
|
66
|
+
const result = remember({ ...ctx, store: undefined }, // the event log row commits with the memory on hippo.db's own handle, never through a store
|
|
67
|
+
{
|
|
67
68
|
...opts,
|
|
68
69
|
untrusted: true,
|
|
69
70
|
afterWrite: (innerDb, memoryId) => {
|