hippo-memory 1.63.1 → 1.64.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 (275) hide show
  1. package/README.md +13 -5
  2. package/dist/agent-memories/claude-code.js +3 -3
  3. package/dist/ambient-store.d.ts +2 -0
  4. package/dist/ambient-store.js +4 -3
  5. package/dist/api/assemble.d.ts +2 -0
  6. package/dist/api/assemble.js +11 -7
  7. package/dist/api/auth.d.ts +15 -11
  8. package/dist/api/auth.js +116 -22
  9. package/dist/api/context-select.d.ts +5 -3
  10. package/dist/api/context-select.js +6 -11
  11. package/dist/api/context-types.d.ts +2 -1
  12. package/dist/api/context.d.ts +1 -1
  13. package/dist/api/context.js +79 -55
  14. package/dist/api/dormant.d.ts +5 -5
  15. package/dist/api/dormant.js +13 -9
  16. package/dist/api/drill-down.d.ts +2 -0
  17. package/dist/api/drill-down.js +12 -7
  18. package/dist/api/forget.d.ts +1 -1
  19. package/dist/api/forget.js +8 -4
  20. package/dist/api/learn.js +5 -3
  21. package/dist/api/outcome.d.ts +1 -1
  22. package/dist/api/outcome.js +2 -1
  23. package/dist/api/promote.js +8 -5
  24. package/dist/api/recall-types.d.ts +4 -2
  25. package/dist/api/recall.js +51 -36
  26. package/dist/api/remember.d.ts +7 -0
  27. package/dist/api/remember.js +26 -2
  28. package/dist/api/sleep-run.d.ts +28 -0
  29. package/dist/api/sleep-run.js +267 -0
  30. package/dist/api/sleep.d.ts +5 -45
  31. package/dist/api/sleep.js +5 -252
  32. package/dist/api/types.d.ts +5 -0
  33. package/dist/api/types.js +4 -0
  34. package/dist/audit-prune.js +1 -1
  35. package/dist/audit.d.ts +1 -3
  36. package/dist/audit.js +7 -103
  37. package/dist/auth.d.ts +48 -4
  38. package/dist/auth.js +111 -42
  39. package/dist/autolearn.js +2 -2
  40. package/dist/capture/caller-session.d.ts +16 -0
  41. package/dist/capture/caller-session.js +58 -0
  42. package/dist/capture/command.d.ts +19 -1
  43. package/dist/capture/command.js +28 -21
  44. package/dist/capture/compact-resume-caller.d.ts +11 -0
  45. package/dist/capture/compact-resume-caller.js +35 -0
  46. package/dist/capture/compact.d.ts +0 -14
  47. package/dist/capture/compact.js +7 -160
  48. package/dist/capture/compaction-items-caller.d.ts +16 -0
  49. package/dist/capture/compaction-items-caller.js +32 -0
  50. package/dist/capture/extract.d.ts +2 -14
  51. package/dist/capture/extract.js +135 -346
  52. package/dist/capture/failure-caller.d.ts +21 -0
  53. package/dist/capture/failure-caller.js +74 -0
  54. package/dist/capture/failure-reading.d.ts +44 -0
  55. package/dist/capture/failure-reading.js +78 -0
  56. package/dist/capture/pre-compact-caller.d.ts +16 -0
  57. package/dist/capture/pre-compact-caller.js +39 -0
  58. package/dist/capture/session-end-caller.d.ts +21 -0
  59. package/dist/capture/session-end-caller.js +30 -0
  60. package/dist/capture/session-texts.d.ts +14 -0
  61. package/dist/capture/session-texts.js +42 -0
  62. package/dist/capture/transcript.d.ts +6 -1
  63. package/dist/capture/transcript.js +20 -16
  64. package/dist/capture/working-state.d.ts +21 -0
  65. package/dist/capture/working-state.js +154 -0
  66. package/dist/capture-error.d.ts +8 -22
  67. package/dist/capture-error.js +9 -61
  68. package/dist/cli/audit.js +25 -0
  69. package/dist/cli/auth.js +4 -3
  70. package/dist/cli/context.js +13 -158
  71. package/dist/cli/continuity.js +2 -1
  72. package/dist/cli/init.js +5 -3
  73. package/dist/cli/last-sleep.d.ts +6 -0
  74. package/dist/cli/last-sleep.js +68 -0
  75. package/dist/cli/quality-repair-once.d.ts +3 -0
  76. package/dist/cli/quality-repair-once.js +36 -0
  77. package/dist/cli/recall.js +2 -1
  78. package/dist/cli/remember.d.ts +2 -0
  79. package/dist/cli/remember.js +4 -1
  80. package/dist/cli/session-hooks.d.ts +0 -3
  81. package/dist/cli/session-hooks.js +12 -49
  82. package/dist/cli/setup.js +10 -2
  83. package/dist/cli/shared.d.ts +7 -14
  84. package/dist/cli/shared.js +19 -78
  85. package/dist/cli/sleep.js +6 -3
  86. package/dist/cli/usage.js +18 -9
  87. package/dist/cli.d.ts +1 -1
  88. package/dist/cli.js +3 -2
  89. package/dist/client.js +1 -1
  90. package/dist/compaction-record.d.ts +19 -1
  91. package/dist/compaction-record.js +61 -132
  92. package/dist/compaction-spool.d.ts +45 -0
  93. package/dist/compaction-spool.js +454 -0
  94. package/dist/config.d.ts +8 -0
  95. package/dist/config.js +49 -0
  96. package/dist/connectors/github/deletion.d.ts +1 -1
  97. package/dist/connectors/github/deletion.js +5 -17
  98. package/dist/consolidate/conflicts.js +4 -0
  99. package/dist/consolidate/flush-units.d.ts +9 -0
  100. package/dist/consolidate/flush-units.js +63 -0
  101. package/dist/consolidate/llm-passes.js +5 -4
  102. package/dist/consolidate/merge.js +5 -1
  103. package/dist/consolidate/run.d.ts +2 -1
  104. package/dist/consolidate/sleep.d.ts +2 -0
  105. package/dist/consolidate/sleep.js +82 -15
  106. package/dist/context-render.d.ts +14 -0
  107. package/dist/context-render.js +28 -0
  108. package/dist/dag.js +6 -0
  109. package/dist/dashboard-actions.js +3 -2
  110. package/dist/dashboard.js +4 -3
  111. package/dist/db/backup.d.ts +6 -0
  112. package/dist/db/backup.js +29 -0
  113. package/dist/db/busy.d.ts +9 -0
  114. package/dist/db/busy.js +35 -0
  115. package/dist/db/continuity.d.ts +1 -0
  116. package/dist/db/continuity.js +31 -4
  117. package/dist/db/meta.d.ts +2 -0
  118. package/dist/db/meta.js +7 -0
  119. package/dist/db/migrate.js +2 -3
  120. package/dist/db/migrations/index.d.ts +1 -1
  121. package/dist/db/migrations/index.js +5 -1
  122. package/dist/db/migrations/v39.js +2 -7
  123. package/dist/db/migrations/v53.d.ts +3 -0
  124. package/dist/db/migrations/v53.js +16 -0
  125. package/dist/db/migrations/v54.d.ts +3 -0
  126. package/dist/db/migrations/v54.js +25 -0
  127. package/dist/db/open.d.ts +11 -0
  128. package/dist/db/open.js +27 -0
  129. package/dist/db/tables.d.ts +2 -0
  130. package/dist/db/tables.js +6 -2
  131. package/dist/db.d.ts +2 -2
  132. package/dist/db.js +2 -2
  133. package/dist/doctor.d.ts +1 -1
  134. package/dist/doctor.js +31 -15
  135. package/dist/dormant.d.ts +16 -20
  136. package/dist/dormant.js +24 -30
  137. package/dist/entry/json-hooks.d.ts +3 -0
  138. package/dist/entry/json-hooks.js +4 -0
  139. package/dist/entry/project-identity.d.ts +2 -0
  140. package/dist/entry/project-identity.js +3 -0
  141. package/dist/entry/session-text.d.ts +7 -0
  142. package/dist/entry/session-text.js +8 -0
  143. package/dist/extract.js +6 -0
  144. package/dist/failure-log.d.ts +10 -2
  145. package/dist/failure-log.js +19 -2
  146. package/dist/gated-write.js +1 -1
  147. package/dist/graph/delta.d.ts +44 -0
  148. package/dist/graph/delta.js +109 -0
  149. package/dist/graph/types.d.ts +5 -0
  150. package/dist/graph/write.d.ts +20 -20
  151. package/dist/graph/write.js +103 -36
  152. package/dist/graph-extract.d.ts +43 -9
  153. package/dist/graph-extract.js +140 -157
  154. package/dist/graph-recall.d.ts +1 -0
  155. package/dist/graph-recall.js +2 -2
  156. package/dist/graph-view.d.ts +1 -0
  157. package/dist/graph-view.js +25 -2
  158. package/dist/half-life-migration.js +5 -1
  159. package/dist/handoff-evidence.d.ts +5 -0
  160. package/dist/handoff-evidence.js +30 -0
  161. package/dist/home-path.d.ts +5 -0
  162. package/dist/home-path.js +24 -0
  163. package/dist/hooks/json-hooks.d.ts +13 -2
  164. package/dist/hooks/json-hooks.js +246 -117
  165. package/dist/hooks/opencode.js +1 -1
  166. package/dist/hooks/shared.d.ts +2 -0
  167. package/dist/hooks/shared.js +6 -1
  168. package/dist/http-util.d.ts +16 -7
  169. package/dist/http-util.js +65 -20
  170. package/dist/importers/vault.js +2 -0
  171. package/dist/index.d.ts +2 -1
  172. package/dist/index.js +1 -0
  173. package/dist/json.d.ts +2 -0
  174. package/dist/json.js +7 -0
  175. package/dist/ledger-db.d.ts +10 -0
  176. package/dist/ledger-db.js +42 -0
  177. package/dist/mcp/admin-tools.d.ts +2 -2
  178. package/dist/mcp/admin-tools.js +47 -5
  179. package/dist/mcp/memory-tools.js +7 -2
  180. package/dist/mcp/protocol.d.ts +6 -0
  181. package/dist/mcp/protocol.js +4 -2
  182. package/dist/mcp/recall-tools.js +19 -6
  183. package/dist/mcp/request.js +4 -0
  184. package/dist/mcp/session-state.d.ts +4 -0
  185. package/dist/mcp/session-state.js +15 -6
  186. package/dist/mcp/shared-gate.d.ts +4 -0
  187. package/dist/mcp/shared-gate.js +22 -0
  188. package/dist/mcp/tools.js +1 -0
  189. package/dist/memory-quality.d.ts +35 -0
  190. package/dist/memory-quality.js +191 -0
  191. package/dist/memory.d.ts +4 -4
  192. package/dist/physics-state.js +1 -1
  193. package/dist/pilot-arm.d.ts +14 -3
  194. package/dist/pilot-arm.js +32 -10
  195. package/dist/postinstall.js +2 -2
  196. package/dist/project-briefs.js +4 -4
  197. package/dist/project-identity.d.ts +14 -10
  198. package/dist/project-identity.js +29 -11
  199. package/dist/project-merge.d.ts +0 -2
  200. package/dist/project-merge.js +9 -13
  201. package/dist/prompt-hook.d.ts +65 -0
  202. package/dist/prompt-hook.js +213 -0
  203. package/dist/prompt-recall.js +1 -1
  204. package/dist/quality-repair.d.ts +27 -0
  205. package/dist/quality-repair.js +220 -0
  206. package/dist/rate-limit.d.ts +1 -0
  207. package/dist/rate-limit.js +5 -3
  208. package/dist/raw-archive.d.ts +1 -1
  209. package/dist/raw-archive.js +6 -16
  210. package/dist/recall-scope.d.ts +28 -5
  211. package/dist/recall-scope.js +52 -7
  212. package/dist/recall-trace.js +1 -1
  213. package/dist/reject-flow.d.ts +2 -0
  214. package/dist/reject-flow.js +20 -6
  215. package/dist/secret-detect.js +7 -8
  216. package/dist/server/auth.d.ts +8 -6
  217. package/dist/server/auth.js +85 -38
  218. package/dist/server/client-ip.d.ts +2 -0
  219. package/dist/server/client-ip.js +16 -1
  220. package/dist/server/mcp-http.d.ts +9 -3
  221. package/dist/server/mcp-http.js +56 -13
  222. package/dist/server/request.d.ts +1 -1
  223. package/dist/server/request.js +5 -5
  224. package/dist/server/routes/admin.js +12 -6
  225. package/dist/server/routes/memories.js +9 -1
  226. package/dist/server/routes/recall.js +14 -2
  227. package/dist/server/types.d.ts +42 -5
  228. package/dist/server/validation.d.ts +5 -2
  229. package/dist/server/validation.js +19 -2
  230. package/dist/server.d.ts +21 -4
  231. package/dist/server.js +207 -49
  232. package/dist/session-digest.d.ts +0 -1
  233. package/dist/session-digest.js +3 -12
  234. package/dist/session-owners.d.ts +7 -0
  235. package/dist/session-owners.js +54 -0
  236. package/dist/share-scrub.d.ts +3 -0
  237. package/dist/share-scrub.js +8 -0
  238. package/dist/shared.d.ts +1 -0
  239. package/dist/shared.js +26 -18
  240. package/dist/store/candidates.d.ts +14 -3
  241. package/dist/store/candidates.js +48 -26
  242. package/dist/store/conflicts.d.ts +15 -0
  243. package/dist/store/conflicts.js +68 -44
  244. package/dist/store/delete-and-batch.d.ts +25 -1
  245. package/dist/store/delete-and-batch.js +121 -69
  246. package/dist/store/entry-reads.d.ts +5 -6
  247. package/dist/store/entry-reads.js +24 -12
  248. package/dist/store/entry-row.d.ts +7 -10
  249. package/dist/store/entry-row.js +40 -18
  250. package/dist/store/entry-writes.d.ts +3 -3
  251. package/dist/store/entry-writes.js +9 -23
  252. package/dist/store/handoffs.d.ts +9 -6
  253. package/dist/store/handoffs.js +72 -59
  254. package/dist/store/index-and-stats.js +2 -2
  255. package/dist/store/open.d.ts +3 -1
  256. package/dist/store/open.js +3 -3
  257. package/dist/store/search-rows.d.ts +8 -5
  258. package/dist/store/search-rows.js +15 -18
  259. package/dist/store/sessions.d.ts +25 -6
  260. package/dist/store/sessions.js +61 -19
  261. package/dist/store/summaries.d.ts +1 -1
  262. package/dist/store/summaries.js +3 -18
  263. package/dist/store/tenant-lookup.d.ts +7 -3
  264. package/dist/store/tenant-lookup.js +9 -9
  265. package/dist/store-port.d.ts +13 -0
  266. package/dist/store-port.js +20 -0
  267. package/dist/version.d.ts +5 -1
  268. package/dist/version.js +5 -1
  269. package/dist/working-memory.js +1 -1
  270. package/dist/write-budget.d.ts +15 -0
  271. package/dist/write-budget.js +15 -0
  272. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  273. package/extensions/openclaw-plugin/package.json +1 -1
  274. package/openclaw.plugin.json +1 -1
  275. package/package.json +8 -4
@@ -0,0 +1,44 @@
1
+ import type { JsonValue } from '../json.js';
2
+ /** Why a failure was not stored, or `stored`. */
3
+ export type CaptureErrorOutcome = 'stored' | 'duplicate' | 'skipped-interrupt' | 'skipped-routine' | 'skipped-invalid';
4
+ /** Which routine check skipped a failure; the log keeps it so declines can be told apart from empty searches. */
5
+ export type RoutineRule = 'declined' | 'os-permission' | 'no-match' | 'search-tool' | 'quiet-exit';
6
+ /** What {@link lessonFromFailure} read from a payload; `detail` is the finer failure-log key (untruncated, command head). */
7
+ export type FailureReading = {
8
+ text: string;
9
+ detail: string;
10
+ } | {
11
+ skip: 'skipped-routine';
12
+ rule: RoutineRule;
13
+ text: string;
14
+ detail: string;
15
+ } | {
16
+ skip: 'skipped-interrupt' | 'skipped-invalid';
17
+ text: null;
18
+ detail: null;
19
+ };
20
+ /** The longest lesson text a reading gives, so a check of a sent one imports it instead of copying it. */
21
+ export declare const FAILURE_TEXT_MAX_CHARS = 200;
22
+ /** A non-blank string field of the payload, or null. */
23
+ export declare function payloadString(payload: JsonValue, key: 'session_id' | 'tool_name'): string | null;
24
+ /** Normalised form used to spot repeats. The failure log keeps only its hash, so changing it breaks repeat counts. */
25
+ export declare function failureSignature(text: string): string;
26
+ /** The hash the failure log stores for a lesson text or a detail; one function so every writer counts repeats alike. */
27
+ export declare function failureHash(text: string): string;
28
+ /** The memory text for a failure payload, or why it is not stored; a routine skip keeps its text for the log. `scrub` runs before the cap, as a mask can be longer than what it hides. Pure. */
29
+ export declare function lessonFromFailure(payload: JsonValue, scrub?: (text: string) => string): FailureReading;
30
+ /** A failure as it may leave the machine: the capped lesson text and the detail's hash, never the untruncated detail. */
31
+ export interface FailureReport {
32
+ /** The payload's tool name; null when it named none. */
33
+ tool: string | null;
34
+ /** Scrubbed for sharing, then cut to at most 200 chars; null when the payload had no readable error. */
35
+ text: string | null;
36
+ /** Why it is not stored; null for a lesson. */
37
+ skip: Exclude<CaptureErrorOutcome, 'stored' | 'duplicate'> | null;
38
+ rule: RoutineRule | null;
39
+ /** What the local failure log stores as `detail_hash` for the same payload. */
40
+ detail_hash: string | null;
41
+ }
42
+ /** {@link lessonFromFailure} plus the detail's hash, for a writer whose store is elsewhere. Pure. */
43
+ export declare function failureReport(payload: JsonValue): FailureReport;
44
+ //# sourceMappingURL=failure-reading.d.ts.map
@@ -0,0 +1,78 @@
1
+ // What a PostToolUseFailure payload says, read with no store, so a hook with no store sends the same reading the local log keeps.
2
+ import { redactSecretsStrict } from '../secret-detect.js';
3
+ import { scrubForSharing } from '../share-scrub.js';
4
+ import { blockHash } from '../token-ledger.js';
5
+ import { truncateCodePointSafe } from '../transcript-tail.js';
6
+ /** The longest lesson text a reading gives, so a check of a sent one imports it instead of copying it. */
7
+ export const FAILURE_TEXT_MAX_CHARS = 200;
8
+ /** The user, a permission prompt or a hook said no: routine, not a lesson. */
9
+ const DECLINED = /user (?:doesn't|does not) want|denied by (?:the )?user|user (?:rejected|declined|denied)|permission to use|was blocked by (?:a )?hook/i;
10
+ /** The OS or a remote host refused access (EACCES, SSH publickey): routine too, but nobody declined anything. */
11
+ const OS_PERMISSION = /permission denied/i;
12
+ /** Leading `cd` or `pushd` steps say where a command ran, not what it ran. */
13
+ const LEADING_CD = /^\s*(?:(?:cd|pushd)\b[^;&|]*(?:&&|\|\||;)\s*)+/;
14
+ const NO_MATCH = /\bno (?:matches|files|results) found\b/i;
15
+ /** Shell commands whose exit code 1 means "nothing found" or "differs", not an error. */
16
+ const QUIET_EXIT_1 = /^\s*(?:grep|rg|egrep|fgrep|find|test|\[|diff|cmp|git diff|git grep)\b/;
17
+ function isString(v) {
18
+ return v !== undefined && v !== null && v.constructor === String;
19
+ }
20
+ function isObject(v) {
21
+ return v !== undefined && v !== null && !Array.isArray(v) && v.constructor === Object;
22
+ }
23
+ /** A non-blank string field of the payload, or null. */
24
+ export function payloadString(payload, key) {
25
+ if (!isObject(payload))
26
+ return null;
27
+ const value = payload[key];
28
+ return isString(value) && value.trim() !== '' ? value : null;
29
+ }
30
+ /** Normalised form used to spot repeats. The failure log keeps only its hash, so changing it breaks repeat counts. */
31
+ export function failureSignature(text) {
32
+ return text.toLowerCase().replace(/[0-9a-f]{7,}/g, '#').replace(/\d+/g, '#').replace(/\s+/g, ' ').trim();
33
+ }
34
+ /** The hash the failure log stores for a lesson text or a detail; one function so every writer counts repeats alike. */
35
+ export function failureHash(text) {
36
+ return blockHash(failureSignature(text));
37
+ }
38
+ /** The memory text for a failure payload, or why it is not stored; a routine skip keeps its text for the log. `scrub` runs before the cap, as a mask can be longer than what it hides. Pure. */
39
+ export function lessonFromFailure(payload, scrub = (text) => text) {
40
+ if (!isObject(payload))
41
+ return { skip: 'skipped-invalid', text: null, detail: null };
42
+ // SAFETY: isObject narrowed payload to a plain JSON object; the fields read are all optional.
43
+ const p = payload;
44
+ if (p.is_interrupt === true)
45
+ return { skip: 'skipped-interrupt', text: null, detail: null };
46
+ if (!isString(p.error) || p.error.trim().length < 12)
47
+ return { skip: 'skipped-invalid', text: null, detail: null };
48
+ const tool = isString(p.tool_name) ? p.tool_name : 'tool';
49
+ const error = redactSecretsStrict(p.error.replace(/\s+/g, ' ').trim());
50
+ const text = truncateCodePointSafe(scrub(`${tool}: ${error}`), FAILURE_TEXT_MAX_CHARS);
51
+ const command = isObject(p.tool_input) && isString(p.tool_input['command']) ? p.tool_input['command'].replace(LEADING_CD, '') : '';
52
+ const head = command.trim().split(/\s+/).slice(0, 2).join(' ');
53
+ const detail = `${tool}${head ? ` ${head}` : ''}: ${error}`;
54
+ const routine = (rule) => ({ skip: 'skipped-routine', rule, text, detail });
55
+ if (DECLINED.test(error))
56
+ return routine('declined');
57
+ if (OS_PERMISSION.test(error))
58
+ return routine('os-permission');
59
+ if (NO_MATCH.test(error))
60
+ return routine('no-match');
61
+ if (tool === 'Grep' || tool === 'Glob')
62
+ return routine('search-tool');
63
+ if (tool === 'Bash' && QUIET_EXIT_1.test(command) && /exit code 1\b/i.test(error))
64
+ return routine('quiet-exit');
65
+ return { text, detail };
66
+ }
67
+ /** {@link lessonFromFailure} plus the detail's hash, for a writer whose store is elsewhere. Pure. */
68
+ export function failureReport(payload) {
69
+ const reading = lessonFromFailure(payload, scrubForSharing);
70
+ return {
71
+ tool: payloadString(payload, 'tool_name'),
72
+ text: reading.text,
73
+ skip: 'skip' in reading ? reading.skip : null,
74
+ rule: 'rule' in reading ? reading.rule : null,
75
+ detail_hash: reading.detail === null ? null : failureHash(reading.detail),
76
+ };
77
+ }
78
+ //# sourceMappingURL=failure-reading.js.map
@@ -0,0 +1,16 @@
1
+ import type { Context } from '../api/types.js';
2
+ import type { CallerProject } from '../prompt-hook.js';
3
+ import { type WorkingState } from './working-state.js';
4
+ export interface CallerPreCompactRequest {
5
+ readonly sessionId: string;
6
+ readonly project: CallerProject;
7
+ readonly trigger: string | null;
8
+ /** What the caller read off its transcript; null when nothing was derivable. */
9
+ readonly workingState: WorkingState | null;
10
+ }
11
+ export interface CallerHookOutput {
12
+ /** What the hook prints; '' prints nothing. */
13
+ readonly stdout: string;
14
+ }
15
+ export declare function preCompactForCaller(ctx: Context, req: CallerPreCompactRequest): CallerHookOutput;
16
+ //# sourceMappingURL=pre-compact-caller.d.ts.map
@@ -0,0 +1,39 @@
1
+ import { markSnapshotSaved, PRE_COMPACT_INSTRUCTION, startCompaction } from '../compaction-record.js';
2
+ import { errorMessage, log } from '../log.js';
3
+ import { loadActiveTaskSnapshot, saveActiveTaskSnapshot } from '../store/sessions.js';
4
+ import { assertTrigger, bindCaller, callerInHoldout, checkedWorkingState, withCallerDb } from './caller-session.js';
5
+ import { mergeWorkingState } from './working-state.js';
6
+ /** A failed step is logged and the instruction still goes out, as the local hook prints it before any store work. */
7
+ function logged(what, fn) {
8
+ try {
9
+ return fn();
10
+ }
11
+ catch (err) {
12
+ log.warn(`pre-compact: ${what}: ${errorMessage(err)}`);
13
+ return null;
14
+ }
15
+ }
16
+ export function preCompactForCaller(ctx, req) {
17
+ assertTrigger(req.trigger);
18
+ const state = checkedWorkingState(req.workingState);
19
+ const key = bindCaller(ctx, req.sessionId, req.project);
20
+ if (callerInHoldout(ctx, req.sessionId))
21
+ return { stdout: '' };
22
+ const start = { sessionId: req.sessionId, originProject: req.project.name, trigger: req.trigger, cwd: null, transcriptPath: null };
23
+ const recordId = logged('compaction record not started', () => withCallerDb(ctx, (db) => startCompaction(db, ctx.tenantId, start)));
24
+ if (state !== null && saveOwnerSnapshot(ctx, req.sessionId, key, state) && recordId !== null) {
25
+ // Marked under ctx.tenantId, which the environment's tenant may not be.
26
+ logged('compaction record not marked with its snapshot', () => withCallerDb(ctx, (db) => markSnapshotSaved(db, ctx.tenantId, recordId)));
27
+ }
28
+ return { stdout: PRE_COMPACT_INSTRUCTION };
29
+ }
30
+ /** True when the owner's snapshot was saved. */
31
+ function saveOwnerSnapshot(ctx, sessionId, key, state) {
32
+ const existing = logged('snapshot not loaded', () => loadActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId, key));
33
+ const merged = mergeWorkingState(state, existing, sessionId);
34
+ if (merged === null)
35
+ return false;
36
+ const snapshot = { ...merged, source: 'pre-compact', session_id: sessionId };
37
+ return logged('snapshot not saved', () => saveActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId, snapshot, key)) !== null;
38
+ }
39
+ //# sourceMappingURL=pre-compact-caller.js.map
@@ -0,0 +1,21 @@
1
+ import type { Context } from '../api/types.js';
2
+ import type { HandoffEvidence } from '../handoff.js';
3
+ import type { CallerProject } from '../prompt-hook.js';
4
+ import type { WorkingState } from './working-state.js';
5
+ export type CallerEvidence = Pick<HandoffEvidence, 'gitRef' | 'dirtyTree' | 'testStatus'>;
6
+ export interface CallerSessionEndRequest {
7
+ readonly sessionId: string;
8
+ readonly project: CallerProject;
9
+ /** Read off the transcript at session end; the handoff's source when the owner has no snapshot for the session. */
10
+ readonly workingState: WorkingState | null;
11
+ /** The git state the caller saw when the session ended. */
12
+ readonly evidence: CallerEvidence | null;
13
+ }
14
+ export interface CallerSessionEndResult {
15
+ /** False when an earlier handoff already covers the session, as on a retry. */
16
+ readonly handoffWritten: boolean;
17
+ readonly snapshotsClosed: number;
18
+ }
19
+ /** Call on a session's final end only, since it closes the snapshot. A failed close throws so the sender retries, and the retry writes no second handoff. */
20
+ export declare function sessionEndHandoffForCaller(ctx: Context, req: CallerSessionEndRequest): CallerSessionEndResult;
21
+ //# sourceMappingURL=session-end-caller.d.ts.map
@@ -0,0 +1,30 @@
1
+ // SessionEnd for a caller on another machine: the owner's handoff, then the owner's snapshot for the session closed, both under its tenant and project.
2
+ import { BadRequestError } from '../api-errors.js';
3
+ import { writeSessionEndHandoff } from '../store/handoffs.js';
4
+ import { closeTaskSnapshotsForSession } from '../store/sessions.js';
5
+ import { bindCaller, checkedWorkingState } from './caller-session.js';
6
+ const TEST_STATUSES = new Set(['pass', 'fail', 'unknown']);
7
+ /** Only the three fields a caller may send: `derivedFrom` is the handoff writer's to set. */
8
+ function checkedEvidence(evidence) {
9
+ if (evidence === null)
10
+ return null;
11
+ const { gitRef = null, dirtyTree = null, testStatus = null } = evidence;
12
+ // `git rev-parse HEAD` gives 40 hex, or 64 in a SHA-256 repository.
13
+ if (gitRef !== null && !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(gitRef))
14
+ throw new BadRequestError('evidence gitRef: 40 or 64 lowercase hex characters or null');
15
+ if (dirtyTree !== null && dirtyTree !== true && dirtyTree !== false)
16
+ throw new BadRequestError('evidence dirtyTree: true, false or null');
17
+ if (testStatus !== null && !TEST_STATUSES.has(testStatus))
18
+ throw new BadRequestError('evidence testStatus: pass, fail, unknown or null');
19
+ return { gitRef, dirtyTree, testStatus };
20
+ }
21
+ /** Call on a session's final end only, since it closes the snapshot. A failed close throws so the sender retries, and the retry writes no second handoff. */
22
+ export function sessionEndHandoffForCaller(ctx, req) {
23
+ const state = checkedWorkingState(req.workingState);
24
+ const evidence = checkedEvidence(req.evidence);
25
+ const key = bindCaller(ctx, req.sessionId, req.project);
26
+ const handoff = writeSessionEndHandoff(ctx.hippoRoot, ctx.tenantId, req.sessionId, evidence, state, key);
27
+ const snapshotsClosed = closeTaskSnapshotsForSession(ctx.hippoRoot, ctx.tenantId, req.sessionId, 'session-ended', key);
28
+ return { handoffWritten: handoff !== null, snapshotsClosed };
29
+ }
30
+ //# sourceMappingURL=session-end-caller.js.map
@@ -0,0 +1,14 @@
1
+ import type { Context } from '../api/types.js';
2
+ import { type CallerProject } from '../prompt-hook.js';
3
+ import { type CaptureTally } from './command.js';
4
+ export interface SessionCaptureRequest {
5
+ readonly sessionId: string;
6
+ readonly project: CallerProject;
7
+ /** The session's turns, as sessionTail returns them; scrubbed again here, since the caller's scrub is not trusted. */
8
+ readonly texts: readonly string[];
9
+ }
10
+ export type SessionCaptureResult = Readonly<CaptureTally>;
11
+ /** Captures the session's statements into `ctx`'s tenant under the caller's project, skipping each text a row the caller can see already holds.
12
+ * Synchronous from the dedup read to the last write, so no other write lands between them. No console output and no process.exit. */
13
+ export declare function captureSessionTexts(ctx: Context, req: SessionCaptureRequest): SessionCaptureResult;
14
+ //# sourceMappingURL=session-texts.d.ts.map
@@ -0,0 +1,42 @@
1
+ // `hippo capture --last-session` for a caller whose transcript was read on another machine: its project names the rows, never the store's folder.
2
+ import { BadRequestError } from '../api-errors.js';
3
+ import { assertCallerIds } from '../prompt-hook.js';
4
+ import { assertCallerProject, projectNames } from '../project-identity.js';
5
+ import { longestWord, storedTextKeys } from '../same-text.js';
6
+ import { scrubForSharing } from '../share-scrub.js';
7
+ import { loadTextsHoldingWords } from '../store/candidates.js';
8
+ import { captureExtractedItems } from './command.js';
9
+ import { extractFromTexts } from './extract.js';
10
+ const MAX_TEXTS = 30;
11
+ const MAX_TEXT_BYTES = 32 * 1024;
12
+ const MAX_TOTAL_BYTES = 256 * 1024;
13
+ const MAX_ITEMS = 50;
14
+ function assertSessionCapture(req) {
15
+ assertCallerIds(req.sessionId, req.project);
16
+ if (req.sessionId.trim() === '')
17
+ throw new BadRequestError('session id: required');
18
+ // A '' project would read every project's rows as its own and write user-global rows; a rewritten one would split a project.
19
+ assertCallerProject(req.project);
20
+ if (req.texts.length > MAX_TEXTS)
21
+ throw new BadRequestError(`texts: at most ${MAX_TEXTS}`);
22
+ const sizes = req.texts.map((t) => Buffer.byteLength(t, 'utf8'));
23
+ if (sizes.some((n) => n > MAX_TEXT_BYTES))
24
+ throw new BadRequestError(`each text: at most ${MAX_TEXT_BYTES} bytes`);
25
+ if (sizes.reduce((a, b) => a + b, 0) > MAX_TOTAL_BYTES)
26
+ throw new BadRequestError(`texts: at most ${MAX_TOTAL_BYTES} bytes in all`);
27
+ }
28
+ /** Captures the session's statements into `ctx`'s tenant under the caller's project, skipping each text a row the caller can see already holds.
29
+ * Synchronous from the dedup read to the last write, so no other write lands between them. No console output and no process.exit. */
30
+ export function captureSessionTexts(ctx, req) {
31
+ assertSessionCapture(req);
32
+ const extracted = extractFromTexts(req.texts.map(scrubForSharing));
33
+ // The newest items win, since the end of a session states where it landed.
34
+ const items = extracted.slice(-MAX_ITEMS);
35
+ if (items.length === 0)
36
+ return { captured: 0, skipped: 0, rejected: 0 };
37
+ // The project filter is classifyOriginProject's in SQL: the caller's rows and user-global rows block a copy, other projects' rows do not.
38
+ const held = loadTextsHoldingWords(ctx.hippoRoot, ctx.tenantId, items.map((i) => longestWord(i.content)), projectNames(req.project));
39
+ const tally = captureExtractedItems(ctx.hippoRoot, { dryRun: false, tenantId: ctx.tenantId, originProject: req.project, sessionId: req.sessionId, actor: ctx.actor.subject, lean: true }, items, storedTextKeys(held));
40
+ return { ...tally, skipped: tally.skipped + extracted.length - items.length };
41
+ }
42
+ //# sourceMappingURL=session-texts.js.map
@@ -22,6 +22,11 @@ export interface TranscriptRecord extends TranscriptLineFlags {
22
22
  }
23
23
  export declare function collectSessionTurns(jsonl: string, visit?: (record: TranscriptRecord) => void): SessionTurn[];
24
24
  export declare function summariseTranscript(jsonl: string): string;
25
+ /** The last 20 user turns and last 10 replies: session-end is about what was decided near the end, not at the start. */
26
+ export declare function sessionTail(turns: readonly SessionTurn[]): {
27
+ users: string[];
28
+ assistants: string[];
29
+ };
25
30
  export declare function summariseSessionTurns(turns: readonly SessionTurn[]): string;
26
31
  /**
27
32
  * Resolve a transcript path for `--last-session`.
@@ -29,7 +34,7 @@ export declare function summariseSessionTurns(turns: readonly SessionTurn[]): st
29
34
  * Priority, where the first source present is the only one tried:
30
35
  * 1. Explicit `transcriptPath` option (from `--transcript <path>`)
31
36
  * 2. Stdin JSON payload (Claude Code / OpenCode SessionEnd hook shape)
32
- * 3. Most recent `.jsonl` under `~/.claude/projects/<any>/`, only when the caller passes `mayScan` (only the caller knows it is not a hook) and there is no path and no stdin text, because this scan spans every project on the box
37
+ * 3. Most recent `.jsonl` under `<claude config dir>/projects/<any>/` (`~/.claude` unless CLAUDE_CONFIG_DIR is set), only when the caller passes `mayScan` (only the caller knows it is not a hook) and there is no path and no stdin text, because this scan spans every project on the box
33
38
  *
34
39
  * Returns null when nothing resolves, a named transcript or payload whose file is missing included. Never throws.
35
40
  */
@@ -1,12 +1,13 @@
1
- import { envHomeDir } from '../env.js';
1
+ import { envHomeDir, processEnv } from '../env.js';
2
+ import { claudeConfigDir } from '../hooks/shared.js';
2
3
  import * as fs from 'fs';
3
4
  import * as path from 'path';
4
5
  import { isObjectLike, isStringValue } from '../capture-contract.js';
5
6
  /**
6
7
  * Build a compact text summary from a Claude Code / OpenCode JSONL transcript.
7
8
  * Keeps plain user messages and the final chunk of assistant text, drops
8
- * thinking blocks, tool_use, and tool_result noise. Output is fed to the
9
- * existing `extractFromText` pipeline.
9
+ * thinking blocks, tool_use, and tool_result noise. Capture reads the same
10
+ * turns through `sessionTail`, one text per turn.
10
11
  *
11
12
  * Exported for tests.
12
13
  */
@@ -114,23 +115,26 @@ export function collectSessionTurns(jsonl, visit) {
114
115
  export function summariseTranscript(jsonl) {
115
116
  return summariseSessionTurns(collectSessionTurns(jsonl));
116
117
  }
118
+ /** The last 20 user turns and last 10 replies: session-end is about what was decided near the end, not at the start. */
119
+ export function sessionTail(turns) {
120
+ return {
121
+ users: turns.filter((t) => t.role === 'user').map((t) => t.text).slice(-20),
122
+ assistants: turns.filter((t) => t.role === 'assistant').map((t) => t.text).slice(-10),
123
+ };
124
+ }
117
125
  export function summariseSessionTurns(turns) {
118
- const userMessages = turns.filter((t) => t.role === 'user').map((t) => t.text);
119
- const assistantTexts = turns.filter((t) => t.role === 'assistant').map((t) => t.text);
120
- if (userMessages.length === 0 && assistantTexts.length === 0)
126
+ const { users: tailUsers, assistants: tailAssistants } = sessionTail(turns);
127
+ if (tailUsers.length === 0 && tailAssistants.length === 0)
121
128
  return '';
122
- // Keep the tail: last ~20 user turns and last ~10 assistant replies.
123
- // Session-end is about what was decided near the end, not at the start.
124
- const tailUsers = userMessages.slice(-20);
125
- const tailAssistants = assistantTexts.slice(-10);
126
129
  return [
127
130
  '# Session Summary',
128
131
  '',
129
132
  '## User Messages',
130
- ...tailUsers.map((m) => `- ${m.replace(/\s+/g, ' ').slice(0, 500)}`),
133
+ ...tailUsers.map((m) => `- ${m}`),
131
134
  '',
132
135
  '## Assistant Responses',
133
- ...tailAssistants.map((t) => t.slice(0, 2000)),
136
+ // A blank line between replies keeps capture from joining two of them; each user turn opens its own list item.
137
+ tailAssistants.join('\n\n'),
134
138
  ].join('\n');
135
139
  }
136
140
  /**
@@ -139,7 +143,7 @@ export function summariseSessionTurns(turns) {
139
143
  * Priority, where the first source present is the only one tried:
140
144
  * 1. Explicit `transcriptPath` option (from `--transcript <path>`)
141
145
  * 2. Stdin JSON payload (Claude Code / OpenCode SessionEnd hook shape)
142
- * 3. Most recent `.jsonl` under `~/.claude/projects/<any>/`, only when the caller passes `mayScan` (only the caller knows it is not a hook) and there is no path and no stdin text, because this scan spans every project on the box
146
+ * 3. Most recent `.jsonl` under `<claude config dir>/projects/<any>/` (`~/.claude` unless CLAUDE_CONFIG_DIR is set), only when the caller passes `mayScan` (only the caller knows it is not a hook) and there is no path and no stdin text, because this scan spans every project on the box
143
147
  *
144
148
  * Returns null when nothing resolves, a named transcript or payload whose file is missing included. Never throws.
145
149
  */
@@ -162,10 +166,10 @@ export function resolveLastSessionTranscript(explicit, stdinText, opts) {
162
166
  }
163
167
  if (!opts.mayScan)
164
168
  return null;
165
- const home = envHomeDir();
166
- if (!home)
169
+ // With no home set, os.homedir() would send the scan into the real profile's transcripts.
170
+ if (!processEnv().CLAUDE_CONFIG_DIR && !envHomeDir())
167
171
  return null;
168
- const projectsDir = path.join(home, '.claude', 'projects');
172
+ const projectsDir = path.join(claudeConfigDir(), 'projects');
169
173
  if (!fs.existsSync(projectsDir))
170
174
  return null;
171
175
  let newest = null;
@@ -0,0 +1,21 @@
1
+ import type { TaskSnapshot } from '../store/rows.js';
2
+ export declare const PRE_COMPACT_TASK_CAP = 200;
3
+ export declare const PRE_COMPACT_SUMMARY_CAP = 2000;
4
+ export declare const PRE_COMPACT_NEXT_STEP_CAP = 500;
5
+ /** The longest each field of {@link transcriptWorkingState} can be, so a check of a sent working state imports these instead of copying them. */
6
+ export declare const WORKING_STATE_CAPS: {
7
+ readonly task: 200;
8
+ readonly summary: number;
9
+ readonly next_step: 500;
10
+ };
11
+ export type WorkingState = Pick<TaskSnapshot, 'task' | 'summary' | 'next_step'>;
12
+ /** Cuts each field to {@link WORKING_STATE_CAPS}, for after a scrub, as a mask can run longer than what it hides. */
13
+ export declare function fitWorkingState(state: WorkingState): WorkingState;
14
+ /** Each empty field falls back to `existing`'s, since a tool-heavy tail derives no task; null when all are empty, so blanks never replace a snapshot.
15
+ * Never across sessions: another session's task saved under this id would pass compact-resume's session check. */
16
+ export declare function mergeWorkingState(derived: WorkingState, existing: TaskSnapshot | null, sessionId: string | null): WorkingState | null;
17
+ /** Keeps the LAST maxChars behind a trim marker, aligned to a nearby line start: the summary runs oldest first, so a head cap would drop the newest working state. */
18
+ export declare function truncateKeepNewest(text: string, maxChars: number): string;
19
+ /** A session's task, summary and next step from its transcript tail, secrets scrubbed and capped, '' where none; null with a logged reason when nothing is derivable. */
20
+ export declare function transcriptWorkingState(transcriptPath: string, log: (message: string) => void): Pick<TaskSnapshot, 'task' | 'summary' | 'next_step'> | null;
21
+ //# sourceMappingURL=working-state.d.ts.map
@@ -0,0 +1,154 @@
1
+ // A session's working state read from its transcript tail, with no store in the imports, so a hook with no store can derive it.
2
+ import * as fs from 'fs';
3
+ import { maskEmails, redactSecretsStrict } from '../secret-detect.js';
4
+ import { isObjectLike, isStringValue } from '../capture-contract.js';
5
+ import { errorMessage } from '../log.js';
6
+ import { PRE_COMPACT_TAIL_BYTES, readTranscriptTail, truncateCodePointSafe } from '../transcript-tail.js';
7
+ import { humanUserText, summariseTranscript } from './transcript.js';
8
+ export const PRE_COMPACT_TASK_CAP = 200;
9
+ export const PRE_COMPACT_SUMMARY_CAP = 2000;
10
+ export const PRE_COMPACT_NEXT_STEP_CAP = 500;
11
+ const TRIM_MARKER = '[...earlier turns trimmed]\n';
12
+ /** The longest each field of {@link transcriptWorkingState} can be, so a check of a sent working state imports these instead of copying them. */
13
+ export const WORKING_STATE_CAPS = {
14
+ task: PRE_COMPACT_TASK_CAP,
15
+ summary: PRE_COMPACT_SUMMARY_CAP + TRIM_MARKER.length,
16
+ next_step: PRE_COMPACT_NEXT_STEP_CAP,
17
+ };
18
+ /** Cuts each field to {@link WORKING_STATE_CAPS}, for after a scrub, as a mask can run longer than what it hides. */
19
+ export function fitWorkingState(state) {
20
+ return {
21
+ task: truncateCodePointSafe(state.task, WORKING_STATE_CAPS.task),
22
+ // Only past the cap, since re-trimming a summary that fits would drop its head again; the cut keeps the newest turns.
23
+ summary: state.summary.length > WORKING_STATE_CAPS.summary ? truncateKeepNewest(state.summary, PRE_COMPACT_SUMMARY_CAP) : state.summary,
24
+ next_step: truncateCodePointSafe(state.next_step, WORKING_STATE_CAPS.next_step),
25
+ };
26
+ }
27
+ /** Each empty field falls back to `existing`'s, since a tool-heavy tail derives no task; null when all are empty, so blanks never replace a snapshot.
28
+ * Never across sessions: another session's task saved under this id would pass compact-resume's session check. */
29
+ export function mergeWorkingState(derived, existing, sessionId) {
30
+ const fallback = existing !== null && (existing.session_id === null || sessionId === null || existing.session_id === sessionId) ? existing : null;
31
+ const merged = {
32
+ task: derived.task || (fallback?.task ?? ''),
33
+ summary: derived.summary || (fallback?.summary ?? ''),
34
+ next_step: derived.next_step || (fallback?.next_step ?? ''),
35
+ };
36
+ return merged.task || merged.summary || merged.next_step ? merged : null;
37
+ }
38
+ /** Keeps the LAST maxChars behind a trim marker, aligned to a nearby line start: the summary runs oldest first, so a head cap would drop the newest working state. */
39
+ export function truncateKeepNewest(text, maxChars) {
40
+ if (text.length <= maxChars)
41
+ return text;
42
+ let start = text.length - maxChars;
43
+ const code = text.charCodeAt(start);
44
+ if (code >= 0xdc00 && code <= 0xdfff)
45
+ start += 1; // never start on a low surrogate
46
+ const nl = text.indexOf('\n', start);
47
+ if (nl !== -1 && nl + 1 < text.length && nl - start < 200)
48
+ start = nl + 1;
49
+ return TRIM_MARKER + text.slice(start);
50
+ }
51
+ /** Most recent plain-text user message in a JSONL tail. Claude Code transcript shape only (PreCompact is claude-code-only). */
52
+ function lastPlainUserMessage(jsonl) {
53
+ const lines = jsonl.split('\n').filter((l) => l.trim());
54
+ for (let i = lines.length - 1; i >= 0; i--) {
55
+ let entry;
56
+ try {
57
+ entry = JSON.parse(lines[i]);
58
+ }
59
+ catch {
60
+ continue; // the tail read can start mid-line; skip the torn fragment
61
+ }
62
+ if (!isObjectLike(entry))
63
+ continue;
64
+ if (!('type' in entry) || entry.type !== 'user')
65
+ continue;
66
+ // An isMeta user line holds an earlier compact summary and an isSidechain one is a sub-agent turn; neither is the human's task.
67
+ if (('isMeta' in entry && entry.isMeta === true) || ('isSidechain' in entry && entry.isSidechain === true))
68
+ continue;
69
+ const message = 'message' in entry && isObjectLike(entry.message) ? entry.message : undefined;
70
+ if (!message)
71
+ continue;
72
+ const text = humanUserText(entry, message);
73
+ if (text)
74
+ return text;
75
+ }
76
+ return '';
77
+ }
78
+ /** Last assistant text block in a JSONL tail (skips thinking + tool_use, same as summariseTranscript). */
79
+ function lastAssistantTextBlock(jsonl) {
80
+ const lines = jsonl.split('\n').filter((l) => l.trim());
81
+ for (let i = lines.length - 1; i >= 0; i--) {
82
+ let entry;
83
+ try {
84
+ entry = JSON.parse(lines[i]);
85
+ }
86
+ catch {
87
+ continue; // the tail read can start mid-line; skip the torn fragment
88
+ }
89
+ if (!isObjectLike(entry))
90
+ continue;
91
+ if (!('type' in entry) || entry.type !== 'assistant')
92
+ continue;
93
+ // Same meta/sidechain guard as lastPlainUserMessage: sub-agent turns
94
+ // (isSidechain) are not this session's next step.
95
+ if (('isMeta' in entry && entry.isMeta === true) || ('isSidechain' in entry && entry.isSidechain === true))
96
+ continue;
97
+ const message = 'message' in entry && isObjectLike(entry.message) ? entry.message : undefined;
98
+ if (!message)
99
+ continue;
100
+ const content = 'content' in message ? message.content : undefined;
101
+ if (!Array.isArray(content))
102
+ continue;
103
+ for (let j = content.length - 1; j >= 0; j--) {
104
+ const block = content[j];
105
+ if (isObjectLike(block)) {
106
+ const blockText = 'type' in block && block.type === 'text' && 'text' in block ? block.text : undefined;
107
+ if (isStringValue(blockText) && blockText.trim()) {
108
+ return blockText.trim();
109
+ }
110
+ }
111
+ }
112
+ }
113
+ return '';
114
+ }
115
+ /** A session's task, summary and next step from its transcript tail, secrets scrubbed and capped, '' where none; null with a logged reason when nothing is derivable. */
116
+ export function transcriptWorkingState(transcriptPath, log) {
117
+ let tail = '';
118
+ let rawTask = '';
119
+ let rawNextStep = '';
120
+ try {
121
+ // Compaction fires when a big tool_result lands, so the last human and assistant turns can sit
122
+ // megabytes back: grow the window a bounded number of times until both turns are in it.
123
+ const size = fs.statSync(transcriptPath).size;
124
+ for (const cap of [PRE_COMPACT_TAIL_BYTES, PRE_COMPACT_TAIL_BYTES * 4, PRE_COMPACT_TAIL_BYTES * 16]) {
125
+ if (cap > PRE_COMPACT_TAIL_BYTES)
126
+ log(`tail window grown to ${cap} bytes (last ${rawTask ? 'assistant' : 'user'} turn is further back)`);
127
+ tail = readTranscriptTail(transcriptPath, cap);
128
+ rawTask = lastPlainUserMessage(tail);
129
+ rawNextStep = lastAssistantTextBlock(tail);
130
+ if ((rawTask && rawNextStep) || size <= cap)
131
+ break;
132
+ }
133
+ }
134
+ catch (err) {
135
+ log(`skip: could not read transcript tail: ${errorMessage(err)}`);
136
+ return null;
137
+ }
138
+ const rawSummary = summariseTranscript(tail);
139
+ if (!rawTask.trim() && !rawSummary.trim() && !rawNextStep.trim()) {
140
+ log('skip: empty summary');
141
+ return null;
142
+ }
143
+ // These fields skip the capture content gate and reach a prompt, so the strict scrub runs. The caps protect the
144
+ // re-injection token budget and never split a surrogate pair; `hippo snapshot save` stays uncapped.
145
+ const task = maskEmails(redactSecretsStrict(rawTask));
146
+ const summary = maskEmails(redactSecretsStrict(rawSummary));
147
+ const nextStep = maskEmails(redactSecretsStrict(rawNextStep));
148
+ return {
149
+ task: task.trim() ? truncateCodePointSafe(task, PRE_COMPACT_TASK_CAP) : '',
150
+ summary: summary.trim() ? truncateKeepNewest(summary, PRE_COMPACT_SUMMARY_CAP) : '',
151
+ next_step: nextStep.trim() ? truncateCodePointSafe(nextStep, PRE_COMPACT_NEXT_STEP_CAP) : '',
152
+ };
153
+ }
154
+ //# sourceMappingURL=working-state.js.map
@@ -1,26 +1,12 @@
1
+ import { type CaptureErrorOutcome } from './capture/failure-reading.js';
1
2
  import type { JsonValue } from './json.js';
2
- /** Why a failure was not stored, or `stored`. */
3
- export type CaptureErrorOutcome = 'stored' | 'duplicate' | 'skipped-interrupt' | 'skipped-routine' | 'skipped-invalid';
4
- /** Which routine check skipped a failure; the log keeps it so declines can be told apart from empty searches. */
5
- export type RoutineRule = 'declined' | 'os-permission' | 'no-match' | 'search-tool' | 'quiet-exit';
6
- /** What {@link lessonFromFailure} read from a payload; `detail` is the finer failure-log key (untruncated, command head). */
7
- export type FailureReading = {
8
- text: string;
9
- detail: string;
10
- } | {
11
- skip: 'skipped-routine';
12
- rule: RoutineRule;
13
- text: string;
14
- detail: string;
15
- } | {
16
- skip: 'skipped-interrupt' | 'skipped-invalid';
17
- text: null;
18
- detail: null;
19
- };
20
- /** Normalised form used to spot repeats. The failure log keeps only its hash, so changing it breaks repeat counts. */
21
- export declare function failureSignature(text: string): string;
22
- /** The memory text for a failure payload, or why it is not stored; a routine skip keeps its text for the log. Pure. */
23
- export declare function lessonFromFailure(payload: JsonValue): FailureReading;
24
3
  /** Store a failure as an error memory unless it is routine or a repeat, and log it either way, even when storing throws. */
25
4
  export declare function captureToolFailure(hippoRoot: string, tenantId: string, payload: JsonValue): CaptureErrorOutcome;
5
+ /** Who sent a failure from another machine: the audit actor and the project its lesson and repeat check belong to. */
6
+ export interface LessonCaller {
7
+ actor: string;
8
+ originProject: string;
9
+ origins: readonly string[];
10
+ }
11
+ export declare function storeLesson(hippoRoot: string, tenantId: string, text: string, caller?: LessonCaller): 'stored' | 'duplicate';
26
12
  //# sourceMappingURL=capture-error.d.ts.map