elepha 0.1.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 (290) hide show
  1. package/LICENSE.md +375 -0
  2. package/README.md +135 -0
  3. package/bin/elepha.js +2 -0
  4. package/dist/adapters/base.js +600 -0
  5. package/dist/adapters/base.js.map +1 -0
  6. package/dist/adapters/claude-code.js +260 -0
  7. package/dist/adapters/claude-code.js.map +1 -0
  8. package/dist/adapters/codex.js +499 -0
  9. package/dist/adapters/codex.js.map +1 -0
  10. package/dist/adapters/discriminators.js +39 -0
  11. package/dist/adapters/discriminators.js.map +1 -0
  12. package/dist/adapters/index.js +6 -0
  13. package/dist/adapters/index.js.map +1 -0
  14. package/dist/adapters/internal-command.js +3 -0
  15. package/dist/adapters/internal-command.js.map +1 -0
  16. package/dist/cli/backup-wizard.js +97 -0
  17. package/dist/cli/backup-wizard.js.map +1 -0
  18. package/dist/cli/capture-service.js +80 -0
  19. package/dist/cli/capture-service.js.map +1 -0
  20. package/dist/cli/commands/backfills.js +336 -0
  21. package/dist/cli/commands/backfills.js.map +1 -0
  22. package/dist/cli/commands/backup.js +234 -0
  23. package/dist/cli/commands/backup.js.map +1 -0
  24. package/dist/cli/commands/config.js +66 -0
  25. package/dist/cli/commands/config.js.map +1 -0
  26. package/dist/cli/commands/consent.js +237 -0
  27. package/dist/cli/commands/consent.js.map +1 -0
  28. package/dist/cli/commands/daemon-control.js +76 -0
  29. package/dist/cli/commands/daemon-control.js.map +1 -0
  30. package/dist/cli/commands/doctor.js +31 -0
  31. package/dist/cli/commands/doctor.js.map +1 -0
  32. package/dist/cli/commands/hook.js +24 -0
  33. package/dist/cli/commands/hook.js.map +1 -0
  34. package/dist/cli/commands/import.js +611 -0
  35. package/dist/cli/commands/import.js.map +1 -0
  36. package/dist/cli/commands/init.js +19 -0
  37. package/dist/cli/commands/init.js.map +1 -0
  38. package/dist/cli/commands/inspect.js +54 -0
  39. package/dist/cli/commands/inspect.js.map +1 -0
  40. package/dist/cli/commands/install.js +49 -0
  41. package/dist/cli/commands/install.js.map +1 -0
  42. package/dist/cli/commands/internal.js +16 -0
  43. package/dist/cli/commands/internal.js.map +1 -0
  44. package/dist/cli/commands/mcp.js +12 -0
  45. package/dist/cli/commands/mcp.js.map +1 -0
  46. package/dist/cli/commands/projects.js +26 -0
  47. package/dist/cli/commands/projects.js.map +1 -0
  48. package/dist/cli/commands/purge.js +216 -0
  49. package/dist/cli/commands/purge.js.map +1 -0
  50. package/dist/cli/commands/reingest.js +80 -0
  51. package/dist/cli/commands/reingest.js.map +1 -0
  52. package/dist/cli/commands/rekey.js +58 -0
  53. package/dist/cli/commands/rekey.js.map +1 -0
  54. package/dist/cli/commands/restore.js +376 -0
  55. package/dist/cli/commands/restore.js.map +1 -0
  56. package/dist/cli/commands/rollup.js +130 -0
  57. package/dist/cli/commands/rollup.js.map +1 -0
  58. package/dist/cli/commands/sanitize.js +72 -0
  59. package/dist/cli/commands/sanitize.js.map +1 -0
  60. package/dist/cli/commands/segment.js +153 -0
  61. package/dist/cli/commands/segment.js.map +1 -0
  62. package/dist/cli/commands/self-update.js +26 -0
  63. package/dist/cli/commands/self-update.js.map +1 -0
  64. package/dist/cli/commands/start.js +53 -0
  65. package/dist/cli/commands/start.js.map +1 -0
  66. package/dist/cli/commands/stats.js +42 -0
  67. package/dist/cli/commands/stats.js.map +1 -0
  68. package/dist/cli/commands/status.js +73 -0
  69. package/dist/cli/commands/status.js.map +1 -0
  70. package/dist/cli/commands/uninstall.js +23 -0
  71. package/dist/cli/commands/uninstall.js.map +1 -0
  72. package/dist/cli/config-wizard.js +104 -0
  73. package/dist/cli/config-wizard.js.map +1 -0
  74. package/dist/cli/destructive-op.js +32 -0
  75. package/dist/cli/destructive-op.js.map +1 -0
  76. package/dist/cli/import-wizard.js +89 -0
  77. package/dist/cli/import-wizard.js.map +1 -0
  78. package/dist/cli/index.js +74 -0
  79. package/dist/cli/index.js.map +1 -0
  80. package/dist/cli/init-wizard.js +80 -0
  81. package/dist/cli/init-wizard.js.map +1 -0
  82. package/dist/cli/init.js +159 -0
  83. package/dist/cli/init.js.map +1 -0
  84. package/dist/cli/launcher-probe.js +71 -0
  85. package/dist/cli/launcher-probe.js.map +1 -0
  86. package/dist/cli/project-path.js +16 -0
  87. package/dist/cli/project-path.js.map +1 -0
  88. package/dist/cli/purge-wizard.js +200 -0
  89. package/dist/cli/purge-wizard.js.map +1 -0
  90. package/dist/cli/restore-wizard.js +101 -0
  91. package/dist/cli/restore-wizard.js.map +1 -0
  92. package/dist/cli/shared.js +128 -0
  93. package/dist/cli/shared.js.map +1 -0
  94. package/dist/cli/status.js +22 -0
  95. package/dist/cli/status.js.map +1 -0
  96. package/dist/cli/wordmark.js +15 -0
  97. package/dist/cli/wordmark.js.map +1 -0
  98. package/dist/config/constants.js +115 -0
  99. package/dist/config/constants.js.map +1 -0
  100. package/dist/config/env.js +168 -0
  101. package/dist/config/env.js.map +1 -0
  102. package/dist/config/memory-config.js +63 -0
  103. package/dist/config/memory-config.js.map +1 -0
  104. package/dist/config/paths.js +343 -0
  105. package/dist/config/paths.js.map +1 -0
  106. package/dist/config/settings.js +132 -0
  107. package/dist/config/settings.js.map +1 -0
  108. package/dist/config/version.js +12 -0
  109. package/dist/config/version.js.map +1 -0
  110. package/dist/daemon/failure-window.js +38 -0
  111. package/dist/daemon/failure-window.js.map +1 -0
  112. package/dist/daemon/heartbeat.js +40 -0
  113. package/dist/daemon/heartbeat.js.map +1 -0
  114. package/dist/daemon/index.js +873 -0
  115. package/dist/daemon/index.js.map +1 -0
  116. package/dist/daemon/log-rotation.js +27 -0
  117. package/dist/daemon/log-rotation.js.map +1 -0
  118. package/dist/daemon/readability-guard.js +89 -0
  119. package/dist/daemon/readability-guard.js.map +1 -0
  120. package/dist/daemon/rollup-service.js +239 -0
  121. package/dist/daemon/rollup-service.js.map +1 -0
  122. package/dist/daemon/update-check.js +87 -0
  123. package/dist/daemon/update-check.js.map +1 -0
  124. package/dist/daemon/work-queue.js +28 -0
  125. package/dist/daemon/work-queue.js.map +1 -0
  126. package/dist/discovery/session-projects.js +242 -0
  127. package/dist/discovery/session-projects.js.map +1 -0
  128. package/dist/hooks/codex-trust.js +80 -0
  129. package/dist/hooks/codex-trust.js.map +1 -0
  130. package/dist/hooks/common.js +81 -0
  131. package/dist/hooks/common.js.map +1 -0
  132. package/dist/hooks/hook-log.js +38 -0
  133. package/dist/hooks/hook-log.js.map +1 -0
  134. package/dist/hooks/installer.js +200 -0
  135. package/dist/hooks/installer.js.map +1 -0
  136. package/dist/hooks/session-start.js +321 -0
  137. package/dist/hooks/session-start.js.map +1 -0
  138. package/dist/hooks/user-prompt-submit.js +248 -0
  139. package/dist/hooks/user-prompt-submit.js.map +1 -0
  140. package/dist/install/binary.js +72 -0
  141. package/dist/install/binary.js.map +1 -0
  142. package/dist/install/config-file.js +119 -0
  143. package/dist/install/config-file.js.map +1 -0
  144. package/dist/install/daemon-health.js +48 -0
  145. package/dist/install/daemon-health.js.map +1 -0
  146. package/dist/install/daemon-service.js +309 -0
  147. package/dist/install/daemon-service.js.map +1 -0
  148. package/dist/install/doctor.js +180 -0
  149. package/dist/install/doctor.js.map +1 -0
  150. package/dist/install/health-checks.js +90 -0
  151. package/dist/install/health-checks.js.map +1 -0
  152. package/dist/install/installer.js +411 -0
  153. package/dist/install/installer.js.map +1 -0
  154. package/dist/install/launchd-backend.js +261 -0
  155. package/dist/install/launchd-backend.js.map +1 -0
  156. package/dist/install/launcher.js +224 -0
  157. package/dist/install/launcher.js.map +1 -0
  158. package/dist/install/markers.js +14 -0
  159. package/dist/install/markers.js.map +1 -0
  160. package/dist/install/platform.js +29 -0
  161. package/dist/install/platform.js.map +1 -0
  162. package/dist/install/present-tools.js +16 -0
  163. package/dist/install/present-tools.js.map +1 -0
  164. package/dist/install/self-update.js +120 -0
  165. package/dist/install/self-update.js.map +1 -0
  166. package/dist/install/service-backend.js +36 -0
  167. package/dist/install/service-backend.js.map +1 -0
  168. package/dist/install/service-manifest.js +36 -0
  169. package/dist/install/service-manifest.js.map +1 -0
  170. package/dist/install/status.js +58 -0
  171. package/dist/install/status.js.map +1 -0
  172. package/dist/install/systemd-backend.js +225 -0
  173. package/dist/install/systemd-backend.js.map +1 -0
  174. package/dist/markers.js +5 -0
  175. package/dist/markers.js.map +1 -0
  176. package/dist/mcp/installer.js +131 -0
  177. package/dist/mcp/installer.js.map +1 -0
  178. package/dist/mcp/server.js +81 -0
  179. package/dist/mcp/server.js.map +1 -0
  180. package/dist/mcp/tools/get-context.js +3 -0
  181. package/dist/mcp/tools/get-context.js.map +1 -0
  182. package/dist/mcp/tools/list-projects.js +3 -0
  183. package/dist/mcp/tools/list-projects.js.map +1 -0
  184. package/dist/mcp/tools/save-memory.js +3 -0
  185. package/dist/mcp/tools/save-memory.js.map +1 -0
  186. package/dist/mcp/tools.js +288 -0
  187. package/dist/mcp/tools.js.map +1 -0
  188. package/dist/rendering/raw-turn-renderer.js +85 -0
  189. package/dist/rendering/raw-turn-renderer.js.map +1 -0
  190. package/dist/security/file-permissions.js +15 -0
  191. package/dist/security/file-permissions.js.map +1 -0
  192. package/dist/security/provider-transcript.js +74 -0
  193. package/dist/security/provider-transcript.js.map +1 -0
  194. package/dist/security/sanitize.js +210 -0
  195. package/dist/security/sanitize.js.map +1 -0
  196. package/dist/security/self-ingestion.js +47 -0
  197. package/dist/security/self-ingestion.js.map +1 -0
  198. package/dist/security/sentinel.js +19 -0
  199. package/dist/security/sentinel.js.map +1 -0
  200. package/dist/security/subprocess-allowlist.js +421 -0
  201. package/dist/security/subprocess-allowlist.js.map +1 -0
  202. package/dist/serving/instructions.js +33 -0
  203. package/dist/serving/instructions.js.map +1 -0
  204. package/dist/serving/lexical-recall.js +249 -0
  205. package/dist/serving/lexical-recall.js.map +1 -0
  206. package/dist/serving/session-reader.js +302 -0
  207. package/dist/serving/session-reader.js.map +1 -0
  208. package/dist/storage/backfill-runner.js +34 -0
  209. package/dist/storage/backfill-runner.js.map +1 -0
  210. package/dist/storage/backup.js +46 -0
  211. package/dist/storage/backup.js.map +1 -0
  212. package/dist/storage/candidate-validator.js +77 -0
  213. package/dist/storage/candidate-validator.js.map +1 -0
  214. package/dist/storage/consent-store.js +198 -0
  215. package/dist/storage/consent-store.js.map +1 -0
  216. package/dist/storage/custom-title-backfill.js +68 -0
  217. package/dist/storage/custom-title-backfill.js.map +1 -0
  218. package/dist/storage/db.js +312 -0
  219. package/dist/storage/db.js.map +1 -0
  220. package/dist/storage/external-agent-import-purge.js +182 -0
  221. package/dist/storage/external-agent-import-purge.js.map +1 -0
  222. package/dist/storage/first-prompt-search-backfill.js +76 -0
  223. package/dist/storage/first-prompt-search-backfill.js.map +1 -0
  224. package/dist/storage/first-prompt-search.js +7 -0
  225. package/dist/storage/first-prompt-search.js.map +1 -0
  226. package/dist/storage/injection-store.js +37 -0
  227. package/dist/storage/injection-store.js.map +1 -0
  228. package/dist/storage/memory-store.js +428 -0
  229. package/dist/storage/memory-store.js.map +1 -0
  230. package/dist/storage/project-resolver.js +294 -0
  231. package/dist/storage/project-resolver.js.map +1 -0
  232. package/dist/storage/project-store.js +104 -0
  233. package/dist/storage/project-store.js.map +1 -0
  234. package/dist/storage/rendered-chars-backfill.js +85 -0
  235. package/dist/storage/rendered-chars-backfill.js.map +1 -0
  236. package/dist/storage/resegmentation.js +582 -0
  237. package/dist/storage/resegmentation.js.map +1 -0
  238. package/dist/storage/rollup-store.js +246 -0
  239. package/dist/storage/rollup-store.js.map +1 -0
  240. package/dist/storage/root-commit-backfill.js +32 -0
  241. package/dist/storage/root-commit-backfill.js.map +1 -0
  242. package/dist/storage/sanitize-backfill.js +184 -0
  243. package/dist/storage/sanitize-backfill.js.map +1 -0
  244. package/dist/storage/segmentation.js +87 -0
  245. package/dist/storage/segmentation.js.map +1 -0
  246. package/dist/storage/session-fields-backfill.js +196 -0
  247. package/dist/storage/session-fields-backfill.js.map +1 -0
  248. package/dist/storage/session-read-model.js +130 -0
  249. package/dist/storage/session-read-model.js.map +1 -0
  250. package/dist/storage/session-store.js +200 -0
  251. package/dist/storage/session-store.js.map +1 -0
  252. package/dist/storage/session-title-backfill.js +75 -0
  253. package/dist/storage/session-title-backfill.js.map +1 -0
  254. package/dist/storage/session-title.js +71 -0
  255. package/dist/storage/session-title.js.map +1 -0
  256. package/dist/storage/shown-session-list-store.js +33 -0
  257. package/dist/storage/shown-session-list-store.js.map +1 -0
  258. package/dist/storage/stats.js +30 -0
  259. package/dist/storage/stats.js.map +1 -0
  260. package/dist/storage/turn-store.js +202 -0
  261. package/dist/storage/turn-store.js.map +1 -0
  262. package/dist/storage/ulid.js +17 -0
  263. package/dist/storage/ulid.js.map +1 -0
  264. package/dist/summarizer/anthropic-call.js +76 -0
  265. package/dist/summarizer/anthropic-call.js.map +1 -0
  266. package/dist/summarizer/call-log.js +120 -0
  267. package/dist/summarizer/call-log.js.map +1 -0
  268. package/dist/summarizer/haiku-provider.js +136 -0
  269. package/dist/summarizer/haiku-provider.js.map +1 -0
  270. package/dist/summarizer/pricing.js +8 -0
  271. package/dist/summarizer/pricing.js.map +1 -0
  272. package/dist/summarizer/prompt-cache.js +6 -0
  273. package/dist/summarizer/prompt-cache.js.map +1 -0
  274. package/dist/summarizer/prompt.js +47 -0
  275. package/dist/summarizer/prompt.js.map +1 -0
  276. package/dist/summarizer/provider-config.js +25 -0
  277. package/dist/summarizer/provider-config.js.map +1 -0
  278. package/dist/summarizer/rollup-prompt.js +225 -0
  279. package/dist/summarizer/rollup-prompt.js.map +1 -0
  280. package/dist/summarizer/rollup-provider.js +224 -0
  281. package/dist/summarizer/rollup-provider.js.map +1 -0
  282. package/dist/types/index.js +10 -0
  283. package/dist/types/index.js.map +1 -0
  284. package/dist/util/error.js +5 -0
  285. package/dist/util/error.js.map +1 -0
  286. package/dist/util/fs.js +97 -0
  287. package/dist/util/fs.js.map +1 -0
  288. package/dist/util/relative-time.js +12 -0
  289. package/dist/util/relative-time.js.map +1 -0
  290. package/package.json +81 -0
@@ -0,0 +1,873 @@
1
+ // Ingestion daemon: watches Claude Code / Codex session paths, feeds new turns
2
+ // through adapters and writes structural capture into storage. Provider-backed
3
+ // extraction and rollups are optional work layered on top.
4
+ //
5
+ // Every file change triggers two kinds of scan:
6
+ // - a prompt scan (closeTrailingOnIdle: false) - picks up any turn that
7
+ // already closed via a following boundary line, without waiting;
8
+ // - a debounced idle scan (closeTrailingOnIdle: true), fired once the file
9
+ // has gone quiet for idleDebounceMs - flushes a trailing turn that never
10
+ // got a following boundary line (e.g. the session is still open).
11
+ // Both go through a bounded concurrency queue: cost per summarization call is
12
+ // negligible, but several projects writing at once are not, and without a
13
+ // per-file mutex a second scan could re-read a cursor the first is still
14
+ // advancing.
15
+ import { stat as fsStat, readdir, realpath } from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import chokidar from 'chokidar';
18
+ import { OversizedTranscriptRecordError } from '../adapters/base.js';
19
+ import { ClaudeCodeAdapter } from '../adapters/claude-code.js';
20
+ import { CodexAdapter } from '../adapters/codex.js';
21
+ import { claudeCodeSurface, codexSurface, toSessionRowKind } from '../adapters/discriminators.js';
22
+ import { DEFAULT_IDLE_DEBOUNCE_MS, DEFAULT_MAX_CONCURRENT, HEARTBEAT_INTERVAL_MS, MAX_DAEMON_UNKNOWN_LINE_WARNINGS, SWEEP_INTERVAL_MS, UPDATE_CHECK_LOOP_INTERVAL_MS, } from '../config/constants.js';
23
+ import { readMemoryConfig } from '../config/memory-config.js';
24
+ import { canonicalizeExisting, claudeProjectsRoot, codexSessionsRoot, daemonStderrLogPath, daemonStdoutLogPath, isReadableProviderSource, isRefusedProjectRoot, isWithin, updateAvailablePath, updateCheckStatePath, } from '../config/paths.js';
25
+ import { readSessionMetadata } from '../discovery/session-projects.js';
26
+ import { installedAndLatestElephaVersionAsync } from '../install/self-update.js';
27
+ import { openProviderTranscript } from '../security/provider-transcript.js';
28
+ import { isNearVerbatim, turnText } from '../security/self-ingestion.js';
29
+ import { openDb } from '../storage/db.js';
30
+ import { MemoryStore } from '../storage/memory-store.js';
31
+ import { evaluateSegmentBoundary } from '../storage/segmentation.js';
32
+ import { FailureWindow } from './failure-window.js';
33
+ import { clearHeartbeat, defaultHeartbeatPath, writeHeartbeat } from './heartbeat.js';
34
+ import { rotateDaemonLogs } from './log-rotation.js';
35
+ import { ReadabilityGuard } from './readability-guard.js';
36
+ import { runUpdateCheck, updateCheckEnabled } from './update-check.js';
37
+ import { WorkQueue } from './work-queue.js';
38
+ const NOTABLE_FILE_SKIP_CATEGORIES = new Set([
39
+ 'unreadable content',
40
+ 'oversized record',
41
+ 'unexpected error',
42
+ 'outside watched store',
43
+ ]);
44
+ // How often to look for sessions that have gone quiet. Well under the idle
45
+ // threshold so a closed session rolls up promptly rather than up to a full
46
+ // threshold late.
47
+ /**
48
+ * A format change can create one unknown line per transcript record. Keep the
49
+ * daemon log useful by emitting each distinct adapter message once, while a
50
+ * bounded FIFO prevents a long-lived process from retaining unbounded keys.
51
+ */
52
+ export function deduplicateDaemonUnknownLineWarnings(warn, limit = MAX_DAEMON_UNKNOWN_LINE_WARNINGS) {
53
+ const max = Math.max(1, limit);
54
+ const seen = new Set();
55
+ const order = [];
56
+ return (message) => {
57
+ if (seen.has(message)) {
58
+ return;
59
+ }
60
+ if (order.length === max) {
61
+ const oldest = order.shift();
62
+ if (oldest !== undefined) {
63
+ seen.delete(oldest);
64
+ }
65
+ }
66
+ seen.add(message);
67
+ order.push(message);
68
+ warn(message);
69
+ };
70
+ }
71
+ // chokidar 5 dropped glob-pattern support in watch paths - it only accepts
72
+ // literal files/directories and watches them recursively. Glob-shaped
73
+ // filtering (*.jsonl under a project subdir, rollout-*.jsonl under a date
74
+ // subdir) happens ourselves via SessionAdapter.matches() on every raw event.
75
+ //
76
+ // Resolved per call, not frozen at module load: both roots honor an env var
77
+ // (CLAUDE_CONFIG_DIR / CODEX_HOME), and a module-level constant would bake in
78
+ // whatever the environment looked like at import time.
79
+ export function watchRoots() {
80
+ return [claudeProjectsRoot(), codexSessionsRoot()];
81
+ }
82
+ function formatDaemonLog(message, context = {}) {
83
+ const fields = [context.tool && `tool=${context.tool}`, context.sessionId && `session_id=${context.sessionId}`].filter(Boolean);
84
+ return fields.length === 0 ? message : `${message} ${fields.join(' ')}`;
85
+ }
86
+ export class IngestionDaemon {
87
+ store;
88
+ summarizer;
89
+ adapters;
90
+ idleDebounceMs;
91
+ log;
92
+ logError;
93
+ watchRoots;
94
+ heartbeatPath;
95
+ daemonLogPaths;
96
+ rollupService;
97
+ rollups;
98
+ sweepIntervalMs;
99
+ updateCheck;
100
+ updateCheckIntervalMs;
101
+ watcherUsePolling;
102
+ watcherPollIntervalMs;
103
+ captureClaudeCode;
104
+ captureCodex;
105
+ readCorpus;
106
+ openTranscript;
107
+ sweepTimer;
108
+ initialUpdateCheckTimer;
109
+ updateCheckTimer;
110
+ startedAt = new Date().toISOString();
111
+ watcher;
112
+ heartbeatTimer;
113
+ idleTimers = new Map();
114
+ processing = new Set();
115
+ workQueue;
116
+ stopPromise;
117
+ signalHandlersInstalled = false;
118
+ shutdownOnSignal = () => {
119
+ void this.stop().catch((error) => this.logError(`[elepha] shutdown failed: ${error.message}`));
120
+ };
121
+ /** Shared with the adapters' unknown-line warnings: one log line per distinct message, bounded FIFO. */
122
+ warnDeduplicated;
123
+ failureWindow;
124
+ skippedFiles = new Map();
125
+ oversizedFileSkipCache = new Map();
126
+ readabilityGuard = new ReadabilityGuard();
127
+ /** Classification per transcript file, so the rollup path doesn't re-read session_meta on every batch. */
128
+ kindCache = new Map();
129
+ customTitleCache = new Map();
130
+ constructor(options = {}) {
131
+ this.log = options.log ?? (() => { });
132
+ this.logError = options.logError ?? console.error;
133
+ const configResult = (options.readConfig ?? readMemoryConfig)();
134
+ if ('error' in configResult) {
135
+ throw new Error(`cannot start daemon: ${configResult.error}`);
136
+ }
137
+ this.captureClaudeCode = configResult.config.captureClaudeCode ?? true;
138
+ this.captureCodex = configResult.config.captureCodex ?? true;
139
+ this.store = options.store ?? new MemoryStore(openDb());
140
+ this.openTranscript = options.openTranscript ?? openProviderTranscript;
141
+ this.summarizer = options.summarizer;
142
+ const warnUnknownLine = deduplicateDaemonUnknownLineWarnings(this.log);
143
+ this.warnDeduplicated = warnUnknownLine;
144
+ this.adapters = options.adapters ?? [new ClaudeCodeAdapter(warnUnknownLine), new CodexAdapter(warnUnknownLine)];
145
+ this.idleDebounceMs = options.idleDebounceMs ?? DEFAULT_IDLE_DEBOUNCE_MS;
146
+ this.workQueue = new WorkQueue(options.maxConcurrentSummaries ?? DEFAULT_MAX_CONCURRENT, this.log, this.logError);
147
+ this.failureWindow = new FailureWindow(this.log, this.logError);
148
+ this.watchRoots = options.watchRoots ?? watchRoots();
149
+ this.heartbeatPath = options.heartbeatPath ?? defaultHeartbeatPath();
150
+ this.daemonLogPaths = options.daemonLogPaths ?? { stdout: daemonStdoutLogPath(), stderr: daemonStderrLogPath() };
151
+ this.rollupService = options.rollupService;
152
+ this.rollups = options.rollups;
153
+ this.sweepIntervalMs = options.sweepIntervalMs ?? SWEEP_INTERVAL_MS;
154
+ this.updateCheck =
155
+ options.updateCheck ??
156
+ (() => runUpdateCheck({
157
+ statePath: updateCheckStatePath(),
158
+ markerPath: updateAvailablePath(),
159
+ enabled: updateCheckEnabled(),
160
+ queryVersions: installedAndLatestElephaVersionAsync,
161
+ warn: this.logError,
162
+ }));
163
+ this.updateCheckIntervalMs = options.updateCheckIntervalMs ?? UPDATE_CHECK_LOOP_INTERVAL_MS;
164
+ this.watcherUsePolling = options.watcherUsePolling ?? false;
165
+ this.watcherPollIntervalMs = options.watcherPollIntervalMs ?? 50;
166
+ this.readCorpus = options.readCorpus ?? ((watchRoot) => readdir(watchRoot, { recursive: true }));
167
+ }
168
+ start() {
169
+ this.installSignalHandlers();
170
+ rotateDaemonLogs(this.daemonLogPaths);
171
+ // followSymlinks left at chokidar's default (true) deliberately:
172
+ // false also blocks traversal through a symlinked ANCESTOR of the
173
+ // watch root, not just symlinks inside it - macOS's /tmp -> /private/tmp
174
+ // is exactly this shape, and a relocated CLAUDE_CONFIG_DIR could be
175
+ // too. It is also not the real guard either way: a symlink
176
+ // swapped in after the watch event fires would slip past a watch-time
177
+ // check. The authoritative guard is the realpath containment re-check
178
+ // in scanFile(), which runs immediately before any read.
179
+ this.watcher = chokidar.watch(this.watchRoots, {
180
+ persistent: true,
181
+ // The initial corpus uses sweepStartupFiles() below: unlike
182
+ // chokidar's fire-and-forget add events it has a defined end and
183
+ // can report what every file did. New files still arrive here.
184
+ ignoreInitial: true,
185
+ ...(this.watcherUsePolling ? { usePolling: true, interval: this.watcherPollIntervalMs } : {}),
186
+ });
187
+ this.watcher.on('add', (filePath) => this.onFileEvent(filePath));
188
+ this.watcher.on('change', (filePath) => this.onFileEvent(filePath));
189
+ this.log(`[elepha] watching:\n ${this.watchRoots.join('\n ')}`);
190
+ void this.sweepStartupFiles();
191
+ writeHeartbeat(this.heartbeatPath, this.startedAt);
192
+ this.heartbeatTimer = setInterval(() => writeHeartbeat(this.heartbeatPath, this.startedAt), HEARTBEAT_INTERVAL_MS);
193
+ this.heartbeatTimer.unref();
194
+ // The registry request belongs to the background daemon, never the
195
+ // synchronous SessionStart hook. The persisted state enforces the
196
+ // 24-hour limit across restarts; this modest timer merely revisits it.
197
+ const checkForUpdate = () => {
198
+ try {
199
+ void Promise.resolve(this.updateCheck()).catch((error) => {
200
+ this.logError(`[elepha] update check failed: ${error.message}`);
201
+ });
202
+ }
203
+ catch (error) {
204
+ this.logError(`[elepha] update check failed: ${error.message}`);
205
+ }
206
+ };
207
+ this.initialUpdateCheckTimer = setTimeout(checkForUpdate, 0);
208
+ this.initialUpdateCheckTimer.unref();
209
+ this.updateCheckTimer = setInterval(checkForUpdate, this.updateCheckIntervalMs);
210
+ this.updateCheckTimer.unref();
211
+ if (this.rollupService) {
212
+ // Startup sweep: sessions that ended while the daemon was down will
213
+ // never produce another file event, so nothing else would ever
214
+ // close them.
215
+ void this.sweepIdleSessions().catch((err) => this.logError(`[elepha] startup sweep failed: ${err.message}`));
216
+ this.sweepTimer = setInterval(() => {
217
+ void this.sweepIdleSessions().catch((err) => this.logError(`[elepha] sweep failed: ${err.message}`));
218
+ }, this.sweepIntervalMs);
219
+ this.sweepTimer.unref();
220
+ }
221
+ }
222
+ async stop() {
223
+ if (this.stopPromise) {
224
+ return this.stopPromise;
225
+ }
226
+ this.stopPromise = this.stopInternal();
227
+ return this.stopPromise;
228
+ }
229
+ installSignalHandlers() {
230
+ if (this.signalHandlersInstalled) {
231
+ return;
232
+ }
233
+ process.once('SIGINT', this.shutdownOnSignal);
234
+ process.once('SIGTERM', this.shutdownOnSignal);
235
+ this.signalHandlersInstalled = true;
236
+ }
237
+ removeSignalHandlers() {
238
+ if (!this.signalHandlersInstalled) {
239
+ return;
240
+ }
241
+ process.off('SIGINT', this.shutdownOnSignal);
242
+ process.off('SIGTERM', this.shutdownOnSignal);
243
+ this.signalHandlersInstalled = false;
244
+ }
245
+ async stopInternal() {
246
+ this.removeSignalHandlers();
247
+ if (this.heartbeatTimer) {
248
+ clearInterval(this.heartbeatTimer);
249
+ }
250
+ if (this.sweepTimer) {
251
+ clearInterval(this.sweepTimer);
252
+ }
253
+ if (this.initialUpdateCheckTimer) {
254
+ clearTimeout(this.initialUpdateCheckTimer);
255
+ }
256
+ if (this.updateCheckTimer) {
257
+ clearInterval(this.updateCheckTimer);
258
+ }
259
+ clearHeartbeat(this.heartbeatPath);
260
+ for (const timer of this.idleTimers.values()) {
261
+ clearTimeout(timer);
262
+ }
263
+ this.idleTimers.clear();
264
+ await this.watcher?.close();
265
+ }
266
+ adapterFor(filePath, onSkipped) {
267
+ const adapter = this.adapters.find((a) => a.matches(filePath));
268
+ if (!adapter) {
269
+ return undefined;
270
+ }
271
+ const enabled = adapter.tool === 'claude-code' ? this.captureClaudeCode : this.captureCodex;
272
+ if (!enabled) {
273
+ const skipped = this.recordSkippedFile(filePath, {
274
+ category: 'capture disabled',
275
+ reason: `capture is disabled for ${adapter.tool}`,
276
+ }, { tool: adapter.tool, sessionId: adapter.nativeSessionId(filePath) });
277
+ onSkipped?.(skipped);
278
+ return undefined;
279
+ }
280
+ return adapter;
281
+ }
282
+ onFileEvent(filePath) {
283
+ const adapter = this.adapterFor(filePath);
284
+ if (!adapter) {
285
+ return;
286
+ }
287
+ this.enqueueScan(adapter, filePath, false);
288
+ this.scheduleIdleScan(adapter, filePath);
289
+ }
290
+ scheduleIdleScan(adapter, filePath) {
291
+ const existing = this.idleTimers.get(filePath);
292
+ if (existing) {
293
+ clearTimeout(existing);
294
+ }
295
+ this.idleTimers.set(filePath, setTimeout(() => {
296
+ this.idleTimers.delete(filePath);
297
+ this.enqueueScan(adapter, filePath, true);
298
+ }, this.idleDebounceMs));
299
+ }
300
+ enqueueScan(adapter, filePath, closeTrailingOnIdle) {
301
+ this.workQueue.enqueue(async () => {
302
+ await this.scanFile(adapter, filePath, closeTrailingOnIdle);
303
+ });
304
+ }
305
+ /**
306
+ * Replays local transcripts for one newly-approved root without enabling a
307
+ * synthesis provider. Approval must make the already-written transcript
308
+ * useful, but it must not turn a CLI acknowledgement into unbounded API
309
+ * spend. The normal daemon will continue with provider work on new turns.
310
+ */
311
+ async backfillApprovedRoot(root) {
312
+ return this.backfillApprovedRoots([root]);
313
+ }
314
+ /** Replays the provider corpus once for every newly-approved root in the set. */
315
+ async backfillApprovedRoots(roots) {
316
+ const canonicalRoots = [...new Set(roots.map((root) => canonicalizeExisting(root)))];
317
+ if (canonicalRoots.length === 0) {
318
+ return 0;
319
+ }
320
+ let ingested = 0;
321
+ for (const watchRoot of this.watchRoots) {
322
+ const files = await this.readCorpus(watchRoot).catch(() => []);
323
+ for (const relativePath of files.sort()) {
324
+ const filePath = path.join(watchRoot, relativePath);
325
+ const adapter = this.adapterFor(filePath);
326
+ if (adapter) {
327
+ ingested += (await this.scanFile(adapter, filePath, true, canonicalRoots)).ingested;
328
+ }
329
+ }
330
+ }
331
+ return ingested;
332
+ }
333
+ /**
334
+ * Cold-start work is intentionally an ordered, awaited sweep rather than
335
+ * unbounded initial watch events. scanFile() is its file boundary: one
336
+ * bad transcript reports a skip and cannot prevent a later one from being
337
+ * read.
338
+ */
339
+ async sweepStartupFiles() {
340
+ const summary = { files: 0, ingested: 0, skipped: new Map(), emptySessions: new Map() };
341
+ for (const watchRoot of this.watchRoots) {
342
+ const files = await this.readCorpus(watchRoot).catch((err) => {
343
+ this.logError(`[elepha] startup sweep could not list ${watchRoot}: ${err.message}`);
344
+ return [];
345
+ });
346
+ for (const relativePath of files.sort()) {
347
+ const filePath = path.join(watchRoot, relativePath);
348
+ let adapterSkip;
349
+ const adapter = this.adapterFor(filePath, (skipped) => {
350
+ adapterSkip = skipped;
351
+ });
352
+ if (!adapter) {
353
+ if (adapterSkip) {
354
+ summary.skipped.set(adapterSkip.category, (summary.skipped.get(adapterSkip.category) ?? 0) + 1);
355
+ }
356
+ continue;
357
+ }
358
+ summary.files++;
359
+ // Every file in this one-time cold-start replay predates the
360
+ // daemon process, so its final assistant response is already
361
+ // complete. Close that trailing turn now; the live watcher
362
+ // still uses false and relies on its idle debounce.
363
+ const result = await this.scanFile(adapter, filePath, true);
364
+ summary.ingested += result.ingested;
365
+ if (result.emptySession) {
366
+ summary.emptySessions.set(result.emptySession, (summary.emptySessions.get(result.emptySession) ?? 0) + 1);
367
+ }
368
+ if (result.skipped) {
369
+ summary.skipped.set(result.skipped.category, (summary.skipped.get(result.skipped.category) ?? 0) + 1);
370
+ }
371
+ this.scheduleIdleScan(adapter, filePath);
372
+ }
373
+ }
374
+ const skipped = [...summary.skipped.entries()];
375
+ const emptySessions = [...summary.emptySessions.entries()];
376
+ const reasonSummary = skipped.length === 0 ? 'none' : skipped.map(([reason, count]) => `${reason}: ${count}`).join('; ');
377
+ const emptySummary = emptySessions.length === 0
378
+ ? ''
379
+ : `, empty sessions: ${emptySessions.reduce((total, [, count]) => total + count, 0)} (${emptySessions.map(([kind, count]) => `${kind}: ${count}`).join('; ')})`;
380
+ this.log(`[elepha] startup sweep: scanned ${summary.files} file(s), ingested ${summary.ingested} turn(s), ` +
381
+ `skipped ${skipped.reduce((total, [, count]) => total + count, 0)} file(s) (${reasonSummary})${emptySummary}`);
382
+ }
383
+ recordSkippedFile(filePath, skipped, context) {
384
+ const canonicalPath = canonicalizeExisting(filePath);
385
+ const previous = this.skippedFiles.get(canonicalPath);
386
+ this.skippedFiles.set(canonicalPath, skipped);
387
+ if (NOTABLE_FILE_SKIP_CATEGORIES.has(skipped.category) &&
388
+ (previous?.category !== skipped.category || previous.reason !== skipped.reason)) {
389
+ this.log(formatDaemonLog(`[elepha] skipped ${filePath}: ${skipped.reason}`, context));
390
+ }
391
+ return skipped;
392
+ }
393
+ quarantineOversizedTranscript(filePath, error, context, fileStat) {
394
+ if (this.skippedFiles.get(filePath)?.category !== 'oversized record') {
395
+ this.skippedFiles.delete(filePath);
396
+ }
397
+ const skipped = this.recordSkippedFile(filePath, {
398
+ category: 'oversized record',
399
+ reason: error.message,
400
+ }, context);
401
+ if (fileStat) {
402
+ this.oversizedFileSkipCache.set(filePath, {
403
+ size: fileStat.size,
404
+ mtimeMs: fileStat.mtimeMs,
405
+ skipped,
406
+ });
407
+ }
408
+ return skipped;
409
+ }
410
+ async scanFile(adapter, filePath, closeTrailingOnIdle, onlyProjectRoots) {
411
+ const logContext = { tool: adapter.tool, sessionId: adapter.nativeSessionId(filePath) };
412
+ // Resolve, contain, and bind the physical file to one opened object
413
+ // immediately before any adapter read. The same canonical path remains
414
+ // the mutex key, so lexical aliases cannot scan one transcript concurrently.
415
+ const opened = await this.openTranscript(adapter.tool, filePath);
416
+ if ('reason' in opened) {
417
+ return {
418
+ ingested: 0,
419
+ skipped: this.recordSkippedFile(filePath, {
420
+ category: 'outside watched store',
421
+ reason: opened.reason === 'transcript_outside_store'
422
+ ? `resolves outside the ${adapter.tool} provider store (cross-store, escaping, or dangling symlink?)`
423
+ : `cannot open a stable regular file inside the ${adapter.tool} provider store (${opened.reason})`,
424
+ }, logContext),
425
+ };
426
+ }
427
+ const { handle, resolvedPath: real, stat: openedStat } = opened;
428
+ // A second scan could read a cursor the first has not advanced yet and
429
+ // re-emit a turn already in flight. Retry after the idle debounce so
430
+ // trailing work is not missed when no later file event arrives.
431
+ if (this.processing.has(real)) {
432
+ await handle.close();
433
+ this.scheduleIdleScan(adapter, filePath);
434
+ return { ingested: 0 };
435
+ }
436
+ this.processing.add(real);
437
+ let fileStat;
438
+ try {
439
+ fileStat = { size: openedStat.size, mtimeMs: openedStat.mtimeMs };
440
+ const oversizedCached = this.oversizedFileSkipCache.get(real);
441
+ if (oversizedCached && fileStat && fileStat.size === oversizedCached.size && fileStat.mtimeMs === oversizedCached.mtimeMs) {
442
+ return { ingested: 0, skipped: oversizedCached.skipped };
443
+ }
444
+ if (oversizedCached) {
445
+ this.oversizedFileSkipCache.delete(real);
446
+ if (this.skippedFiles.get(real)?.category === 'oversized record') {
447
+ this.skippedFiles.delete(real);
448
+ }
449
+ }
450
+ const unreadable = await this.readabilityGuard.assertReadableJsonl(real);
451
+ if (unreadable) {
452
+ return { ingested: 0, skipped: this.recordSkippedFile(real, unreadable, logContext) };
453
+ }
454
+ // Consent is a file boundary, not a persistence-only check. Read
455
+ // only the first cwd-bearing metadata line, then reject before an
456
+ // adapter can parse a turn, title, or any transcript body.
457
+ const metadata = await readSessionMetadata(real);
458
+ if (!metadata) {
459
+ return {
460
+ ingested: 0,
461
+ skipped: this.recordSkippedFile(real, {
462
+ category: 'unapproved root',
463
+ reason: 'session cwd is unavailable from metadata; skipped before parsing transcript content',
464
+ }, logContext),
465
+ };
466
+ }
467
+ const nativeId = adapter.nativeSessionId(real);
468
+ if (onlyProjectRoots !== undefined) {
469
+ const selectedRoots = typeof onlyProjectRoots === 'string' ? [canonicalizeExisting(onlyProjectRoots)] : onlyProjectRoots;
470
+ const canonicalCwd = canonicalizeExisting(metadata.cwd);
471
+ const coveringRoot = selectedRoots.find((root) => isWithin(root, canonicalCwd));
472
+ if (coveringRoot === undefined || this.store.consent.consentState(canonicalCwd) !== 'approved') {
473
+ return {
474
+ ingested: 0,
475
+ skipped: this.recordSkippedFile(real, {
476
+ category: 'unapproved root',
477
+ reason: `${metadata.cwd} is outside/unapproved for the selected backfill roots; skipped before parsing transcript content`,
478
+ }, logContext),
479
+ };
480
+ }
481
+ }
482
+ if (onlyProjectRoots === undefined) {
483
+ if (isRefusedProjectRoot(metadata.cwd)) {
484
+ return {
485
+ ingested: 0,
486
+ skipped: this.recordSkippedFile(real, {
487
+ category: 'refused root',
488
+ reason: `refusing to ingest from "${metadata.cwd}" - not a permitted project root`,
489
+ }, logContext),
490
+ };
491
+ }
492
+ const consentState = this.store.consent.consentState(metadata.cwd);
493
+ if (consentState !== 'approved') {
494
+ // Pending means the user has never opted in, so its first
495
+ // grant must still backfill history. Denied is an explicit
496
+ // no: only that state writes the permanent incognito veto.
497
+ // The stable tool/session key keeps a future forced disk
498
+ // re-scan possible without retaining any transcript content.
499
+ if (consentState === 'denied') {
500
+ this.store.recordIncognitoTranscript(adapter.tool, nativeId);
501
+ }
502
+ const canonicalCwd = await realpath(metadata.cwd).catch(() => undefined);
503
+ const cwdStat = canonicalCwd === undefined ? undefined : await fsStat(canonicalCwd).catch(() => undefined);
504
+ if (canonicalCwd === undefined || !cwdStat?.isDirectory()) {
505
+ return {
506
+ ingested: 0,
507
+ skipped: this.recordSkippedFile(real, {
508
+ category: 'unapproved root',
509
+ reason: `${metadata.cwd} is not an existing project directory; skipped before parsing transcript content`,
510
+ }, logContext),
511
+ };
512
+ }
513
+ const root = this.store.consent.recordPending(canonicalCwd);
514
+ return {
515
+ ingested: 0,
516
+ skipped: this.recordSkippedFile(real, {
517
+ category: 'unapproved root',
518
+ reason: `${root.path} is not an approved memory root; skipped before parsing transcript content. Grant it with \`elepha consent grant ${root.path}\`.`,
519
+ }, logContext),
520
+ };
521
+ }
522
+ }
523
+ if (this.store.isTranscriptPurged(adapter.tool, nativeId)) {
524
+ return {
525
+ ingested: 0,
526
+ skipped: this.recordSkippedFile(real, {
527
+ category: 'purged',
528
+ reason: `transcript ${nativeId} was purged and is permanently excluded from ingestion`,
529
+ }, logContext),
530
+ };
531
+ }
532
+ if (this.store.isTranscriptIncognito(adapter.tool, nativeId)) {
533
+ return {
534
+ ingested: 0,
535
+ skipped: this.recordSkippedFile(real, {
536
+ category: 'incognito',
537
+ reason: `transcript ${nativeId} was observed while capture was denied and is permanently excluded from ingestion`,
538
+ }, logContext),
539
+ };
540
+ }
541
+ const classification = await adapter.classifySession(real, { handle });
542
+ this.kindCache.set(real, classification);
543
+ const skipLabel = classification.exclusion ??
544
+ (classification.kind === 'fork-copy' || classification.kind === 'adjudicator' ? classification.kind : undefined);
545
+ if (skipLabel) {
546
+ return {
547
+ ingested: 0,
548
+ skipped: this.recordSkippedFile(real, {
549
+ category: 'excluded session',
550
+ reason: `skipping ${skipLabel} session ${path.basename(real)}: ${classification.reason ?? ''}`,
551
+ }, logContext),
552
+ };
553
+ }
554
+ // custom-title is standalone Claude Code UI metadata. Reading it
555
+ // separately keeps the turn parser and rendered output byte-neutral.
556
+ const customTitle = await this.readCustomTitle(adapter, real);
557
+ const cursor = this.store.getSessionCursor(adapter.tool, nativeId);
558
+ let ingested = 0;
559
+ let parsedTurns = 0;
560
+ for await (const turn of adapter.parseTurns(real, cursor, { closeTrailingOnIdle, handle })) {
561
+ parsedTurns++;
562
+ const consentState = this.consentStateForTurn(turn);
563
+ if (consentState === 'denied') {
564
+ this.store.recordIncognitoTranscript(turn.tool, turn.sessionId);
565
+ break;
566
+ }
567
+ if (turn.droppedReason === 'sentinel') {
568
+ await this.advanceDroppedTurn(turn, customTitle);
569
+ if (this.store.isTranscriptIncognito(turn.tool, turn.sessionId)) {
570
+ break;
571
+ }
572
+ continue;
573
+ }
574
+ if (await this.persistTurn(adapter, turn, customTitle)) {
575
+ ingested++;
576
+ }
577
+ if (this.store.isTranscriptIncognito(turn.tool, turn.sessionId)) {
578
+ break;
579
+ }
580
+ }
581
+ // Mid-task handoff can't wait for session close - that's the whole
582
+ // wedge - so the rollup refreshes as each batch lands, not only at
583
+ // the end. Incremental by construction (see RollupService), so this
584
+ // costs one small merge per batch rather than a full re-summary.
585
+ if (ingested > 0) {
586
+ await this.refreshRollup(adapter, real, nativeId, 'live');
587
+ }
588
+ this.skippedFiles.delete(real);
589
+ // A non-empty transcript that parses successfully yet emits no
590
+ // turns means the adapter's expected boundary may have vanished.
591
+ // Alert only before a cursor exists: a steady-state scan with no
592
+ // bytes after its cursor is normal, and a genuinely empty file is
593
+ // not a format-migration signal.
594
+ if (cursor === undefined && parsedTurns === 0 && (await handle.stat()).size > 0) {
595
+ const emptySession = await adapter.classifyEmptySession(real);
596
+ if (emptySession) {
597
+ return { ingested: 0, emptySession: emptySession.kind };
598
+ }
599
+ return {
600
+ ingested: 0,
601
+ skipped: this.recordSkippedFile(real, {
602
+ category: 'zero parsed turns',
603
+ reason: 'parsed successfully but yielded zero turns - expected user-turn boundary was not found; ' +
604
+ 'no memory rows were written (check the transcript format and update its adapter)',
605
+ }, logContext),
606
+ };
607
+ }
608
+ return { ingested, skipped: ingested === 0 ? this.skippedFiles.get(real) : undefined };
609
+ }
610
+ catch (err) {
611
+ if (err instanceof OversizedTranscriptRecordError) {
612
+ const skipped = this.quarantineOversizedTranscript(real, err, logContext, fileStat);
613
+ return {
614
+ ingested: 0,
615
+ skipped,
616
+ };
617
+ }
618
+ return {
619
+ ingested: 0,
620
+ skipped: this.recordSkippedFile(filePath, {
621
+ category: 'unexpected error',
622
+ reason: `unexpected error: ${err.message}`,
623
+ }, logContext),
624
+ };
625
+ }
626
+ finally {
627
+ this.processing.delete(real);
628
+ await handle.close();
629
+ }
630
+ }
631
+ async readCustomTitle(adapter, filePath) {
632
+ if (!adapter.readCustomTitle) {
633
+ return undefined;
634
+ }
635
+ const { size, mtimeMs } = await fsStat(filePath);
636
+ const cached = this.customTitleCache.get(filePath);
637
+ if (cached && size === cached.size && mtimeMs === cached.mtimeMs) {
638
+ return cached.customTitle;
639
+ }
640
+ const fromOffset = cached && size >= cached.size && mtimeMs >= cached.mtimeMs ? cached.scannedTo : 0;
641
+ const result = await adapter.readCustomTitle(filePath, fromOffset);
642
+ const customTitle = fromOffset === 0 ? result.customTitle : (result.customTitle ?? cached?.customTitle);
643
+ this.customTitleCache.set(filePath, { size, mtimeMs, scannedTo: result.scannedTo, customTitle });
644
+ return customTitle;
645
+ }
646
+ async persistTurn(adapter, turn, customTitle) {
647
+ // Refused roots ($HOME itself, document dumps) never become projects.
648
+ // Enforced here rather than downstream because a project row created
649
+ // from a bad cwd is self-healing in the wrong direction: purge it and
650
+ // the next turn from that directory recreates it.
651
+ if (isRefusedProjectRoot(turn.projectPath)) {
652
+ this.recordSkippedFile(turn.sourcePath, {
653
+ category: 'refused root',
654
+ reason: `refusing to ingest from "${turn.projectPath || '(empty cwd)'}" - not a permitted project root`,
655
+ }, turn);
656
+ return false;
657
+ }
658
+ const consentState = this.consentStateForTurn(turn);
659
+ if (consentState === 'denied') {
660
+ this.store.recordIncognitoTranscript(turn.tool, turn.sessionId);
661
+ }
662
+ if (consentState !== 'approved') {
663
+ return false;
664
+ }
665
+ // Quote-back is deliberately before project/session persistence and the
666
+ // summarizer: adapters stay DB-free, while a match must have no memory
667
+ // side effects. The existing session's cursor is the sole exception,
668
+ // otherwise this complete source turn would be re-read forever.
669
+ const injections = this.store.injectionsForSession(turn.tool, turn.sessionId, turn.startedAt);
670
+ if (injections.some((injection) => isNearVerbatim(turnText(turn), injection.body))) {
671
+ this.store.advanceExistingSessionCursor(turn.tool, turn.sessionId, turn.cursor);
672
+ this.log(formatDaemonLog(`[elepha] dropped turn ${turn.turnIndex} of ${turn.sessionId}: self-injected content (quote-back)`, turn));
673
+ return false;
674
+ }
675
+ const surface = turn.tool === 'claude-code' ? claudeCodeSurface(turn.surface) : codexSurface(turn.surface);
676
+ const classification = this.kindCache.get(turn.sourcePath) ?? (await this.adapterFor(turn.sourcePath)?.classifySession(turn.sourcePath));
677
+ const meta = {
678
+ surface,
679
+ gitBranch: turn.gitBranch ?? null,
680
+ kind: classification ? toSessionRowKind(classification.kind) : null,
681
+ customTitle,
682
+ };
683
+ const session = this.store.findSession(turn.tool, turn.sessionId);
684
+ // Segmentation precedes both dedupe and soft-final wakeup. The entire
685
+ // comparison comes from this one hydrated session row plus the parsed
686
+ // closed turn; memories/turn history never contributes boundary data.
687
+ const previousEndedAt = Date.parse(session?.last_turn_at ?? '');
688
+ const resumingStartedAt = Date.parse(turn.startedAt);
689
+ const gapHours = session?.last_turn_at !== null && Number.isFinite(previousEndedAt) && Number.isFinite(resumingStartedAt)
690
+ ? Math.max(0, resumingStartedAt - previousEndedAt) / (60 * 60 * 1000)
691
+ : 0;
692
+ const cut = session !== undefined &&
693
+ evaluateSegmentBoundary({
694
+ gapHours,
695
+ trailingBranch: session.trailing_branch,
696
+ resumingBranch: turn.gitBranch ?? null,
697
+ trailingFiles: session.trailing_files,
698
+ resumingFiles: turn.toolCalls.flatMap((call) => call.filePaths),
699
+ resumeMarkerBefore: turn.resumeMarkerBefore,
700
+ });
701
+ // Overlapping watch events (prompt scan + idle scan, or two rapid
702
+ // 'add' events on cold start) can re-present an already-recorded
703
+ // turn. INSERT OR IGNORE makes that safe for the data, but a Haiku
704
+ // call whose result gets discarded is still wasted spend - skip it.
705
+ // The native-wide lookup matters after a cut: session-local UNIQUE
706
+ // cannot tell that this turn index already lives in an older segment.
707
+ // It deliberately runs after the boundary comparison (soft-final's
708
+ // required ordering) and contributes no evidence to that comparison.
709
+ if (this.store.hasMemoryForNativeTurn(turn.tool, turn.sessionId, turn.turnIndex)) {
710
+ return false;
711
+ }
712
+ if (cut && session) {
713
+ // Everything already stored in the old segment belongs to it.
714
+ // Finalize before opening the fresh segment so no rollup can ever
715
+ // merge content from opposite sides of the boundary.
716
+ await this.refreshStoredSessionRollup(adapter, turn.sourcePath, session, classification, 'final');
717
+ }
718
+ const summary = this.summarizer
719
+ ? await this.summarizer.summarize({ userMessage: turn.userMessage, assistantText: turn.assistantText })
720
+ : { decisions: [], pending_items: [], status: 'not_configured' };
721
+ if (this.summarizer) {
722
+ this.trackOutcome(summary.status);
723
+ }
724
+ const persisted = this.store.recordIngestedTurn(turn, meta, cut, summary);
725
+ if (!persisted) {
726
+ return false;
727
+ }
728
+ const { inserted, project, session: storedSession } = persisted;
729
+ if (inserted) {
730
+ this.log(formatDaemonLog(`[elepha] captured turn ${turn.turnIndex} (${adapter.tool}) for ${project.display_name ?? project.path}`, turn));
731
+ // This happens only AFTER the boundary decision above. On a cut,
732
+ // the new segment starts live; without a cut, a soft-final session
733
+ // wakes and incrementally merges into the same watermark-protected
734
+ // aggregate. Reprocessing the same turn returns before this call,
735
+ // so it cannot spuriously wake or double-merge a final rollup.
736
+ if (this.rollups) {
737
+ this.rollups.markLive(storedSession.id);
738
+ }
739
+ else {
740
+ this.rollupService?.noteActivity(storedSession.id);
741
+ }
742
+ }
743
+ return inserted;
744
+ }
745
+ /**
746
+ * The adapter has already logged a sentinel match and intentionally did
747
+ * not emit a persistable turn. We still create/locate its ordinary session
748
+ * so its cursor can move past the complete source range without recording
749
+ * memory, rendered stats, boundary state, or a summarizer call.
750
+ */
751
+ async advanceDroppedTurn(turn, customTitle) {
752
+ if (isRefusedProjectRoot(turn.projectPath)) {
753
+ this.recordSkippedFile(turn.sourcePath, {
754
+ category: 'refused root',
755
+ reason: `refusing to ingest from "${turn.projectPath || '(empty cwd)'}" - not a permitted project root`,
756
+ }, turn);
757
+ return;
758
+ }
759
+ const consentState = this.consentStateForTurn(turn);
760
+ if (consentState === 'denied') {
761
+ this.store.recordIncognitoTranscript(turn.tool, turn.sessionId);
762
+ }
763
+ if (consentState !== 'approved') {
764
+ return;
765
+ }
766
+ const classification = this.kindCache.get(turn.sourcePath) ?? (await this.adapterFor(turn.sourcePath)?.classifySession(turn.sourcePath));
767
+ const meta = {
768
+ surface: turn.tool === 'claude-code' ? claudeCodeSurface(turn.surface) : codexSurface(turn.surface),
769
+ gitBranch: turn.gitBranch ?? null,
770
+ kind: classification ? toSessionRowKind(classification.kind) : null,
771
+ customTitle,
772
+ };
773
+ if (!this.store.recordDroppedTurn(turn, meta)) {
774
+ return;
775
+ }
776
+ }
777
+ /**
778
+ * Turn-level consent fallback behind the file-level gate in scanFile. A
779
+ * later pending cwd is dropped without creating a pending root (that
780
+ * decision belongs to the first cwd) and is logged once per transcript and
781
+ * cwd. A denied cwd is returned distinctly so callers can permanently veto
782
+ * the native session and stop before advancing beyond the denied turn.
783
+ */
784
+ consentStateForTurn(turn) {
785
+ const state = this.store.consent.consentState(turn.projectPath);
786
+ if (state === 'pending') {
787
+ this.warnDeduplicated(formatDaemonLog(`[elepha] dropped turn(s) of ${turn.sourcePath}: cwd "${turn.projectPath}" is outside every approved root; not persisted (grant a covering root with \`elepha consent grant <path>\`)`, turn));
788
+ }
789
+ return state;
790
+ }
791
+ /**
792
+ * Recomputes a session's rollup. `state` is 'live' for a mid-session batch
793
+ * and 'final' once the transcript has gone idle - but 'final' is only ever
794
+ * a heuristic, and any later turn returns the session to 'live'.
795
+ */
796
+ async refreshRollup(adapter, filePath, nativeId, state) {
797
+ if (!this.rollupService) {
798
+ return;
799
+ }
800
+ const session = this.store.findSession(adapter.tool, nativeId);
801
+ if (!session) {
802
+ return;
803
+ }
804
+ const classification = this.kindCache.get(filePath) ?? (await adapter.classifySession(filePath));
805
+ await this.refreshStoredSessionRollup(adapter, filePath, session, classification, state);
806
+ }
807
+ async refreshStoredSessionRollup(adapter, filePath, session, classification, state) {
808
+ if (!this.rollupService) {
809
+ return;
810
+ }
811
+ const resolvedClassification = classification ?? this.kindCache.get(filePath) ?? (await adapter.classifySession(filePath));
812
+ const kind = resolvedClassification.kind;
813
+ // Sub-agent work is attached to the parent session rather than listed
814
+ // as a peer; an un-ingested parent leaves it standalone rather than
815
+ // orphaning it out of every listing.
816
+ const parentSessionId = resolvedClassification.parentNativeId
817
+ ? (this.store.findSession(adapter.tool, resolvedClassification.parentNativeId)?.id ?? null)
818
+ : null;
819
+ try {
820
+ await this.rollupService.rollupSession(session, kind, parentSessionId, state);
821
+ }
822
+ catch (err) {
823
+ this.logError(formatDaemonLog(`[elepha] rollup failed for ${session.native_id}: ${err.message}`, {
824
+ tool: session.tool,
825
+ sessionId: session.native_id,
826
+ }));
827
+ }
828
+ }
829
+ /**
830
+ * Closes sessions whose transcripts have gone quiet, including those that
831
+ * ended while the daemon was down - without this startup sweep, a session
832
+ * that finished during downtime would sit 'live' forever, since no further
833
+ * file event will ever arrive for it.
834
+ */
835
+ async sweepIdleSessions(now = Date.now()) {
836
+ if (!this.rollupService) {
837
+ return 0;
838
+ }
839
+ let closed = 0;
840
+ for (const session of this.store.listOpenSessions()) {
841
+ const adapter = this.adapters.find((a) => a.tool === session.tool);
842
+ if (!adapter) {
843
+ continue;
844
+ }
845
+ if (!isReadableProviderSource(session.tool, session.source_path)) {
846
+ continue;
847
+ }
848
+ const stat = await fsStat(session.source_path).catch(() => undefined);
849
+ if (!stat || !this.rollupService.isIdle(stat.mtimeMs, now)) {
850
+ continue;
851
+ }
852
+ try {
853
+ await this.refreshRollup(adapter, session.source_path, session.native_id, 'final');
854
+ }
855
+ catch (error) {
856
+ if (error instanceof OversizedTranscriptRecordError) {
857
+ this.quarantineOversizedTranscript(session.source_path, error, { tool: session.tool, sessionId: session.native_id }, { size: stat.size, mtimeMs: stat.mtimeMs });
858
+ continue;
859
+ }
860
+ throw error;
861
+ }
862
+ closed++;
863
+ }
864
+ if (closed > 0) {
865
+ this.log(`[elepha] closed ${closed} idle session(s)`);
866
+ }
867
+ return closed;
868
+ }
869
+ trackOutcome(status) {
870
+ this.failureWindow.trackOutcome(status);
871
+ }
872
+ }
873
+ //# sourceMappingURL=index.js.map