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.
- package/LICENSE.md +375 -0
- package/README.md +135 -0
- package/bin/elepha.js +2 -0
- package/dist/adapters/base.js +600 -0
- package/dist/adapters/base.js.map +1 -0
- package/dist/adapters/claude-code.js +260 -0
- package/dist/adapters/claude-code.js.map +1 -0
- package/dist/adapters/codex.js +499 -0
- package/dist/adapters/codex.js.map +1 -0
- package/dist/adapters/discriminators.js +39 -0
- package/dist/adapters/discriminators.js.map +1 -0
- package/dist/adapters/index.js +6 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/internal-command.js +3 -0
- package/dist/adapters/internal-command.js.map +1 -0
- package/dist/cli/backup-wizard.js +97 -0
- package/dist/cli/backup-wizard.js.map +1 -0
- package/dist/cli/capture-service.js +80 -0
- package/dist/cli/capture-service.js.map +1 -0
- package/dist/cli/commands/backfills.js +336 -0
- package/dist/cli/commands/backfills.js.map +1 -0
- package/dist/cli/commands/backup.js +234 -0
- package/dist/cli/commands/backup.js.map +1 -0
- package/dist/cli/commands/config.js +66 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/consent.js +237 -0
- package/dist/cli/commands/consent.js.map +1 -0
- package/dist/cli/commands/daemon-control.js +76 -0
- package/dist/cli/commands/daemon-control.js.map +1 -0
- package/dist/cli/commands/doctor.js +31 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/hook.js +24 -0
- package/dist/cli/commands/hook.js.map +1 -0
- package/dist/cli/commands/import.js +611 -0
- package/dist/cli/commands/import.js.map +1 -0
- package/dist/cli/commands/init.js +19 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/inspect.js +54 -0
- package/dist/cli/commands/inspect.js.map +1 -0
- package/dist/cli/commands/install.js +49 -0
- package/dist/cli/commands/install.js.map +1 -0
- package/dist/cli/commands/internal.js +16 -0
- package/dist/cli/commands/internal.js.map +1 -0
- package/dist/cli/commands/mcp.js +12 -0
- package/dist/cli/commands/mcp.js.map +1 -0
- package/dist/cli/commands/projects.js +26 -0
- package/dist/cli/commands/projects.js.map +1 -0
- package/dist/cli/commands/purge.js +216 -0
- package/dist/cli/commands/purge.js.map +1 -0
- package/dist/cli/commands/reingest.js +80 -0
- package/dist/cli/commands/reingest.js.map +1 -0
- package/dist/cli/commands/rekey.js +58 -0
- package/dist/cli/commands/rekey.js.map +1 -0
- package/dist/cli/commands/restore.js +376 -0
- package/dist/cli/commands/restore.js.map +1 -0
- package/dist/cli/commands/rollup.js +130 -0
- package/dist/cli/commands/rollup.js.map +1 -0
- package/dist/cli/commands/sanitize.js +72 -0
- package/dist/cli/commands/sanitize.js.map +1 -0
- package/dist/cli/commands/segment.js +153 -0
- package/dist/cli/commands/segment.js.map +1 -0
- package/dist/cli/commands/self-update.js +26 -0
- package/dist/cli/commands/self-update.js.map +1 -0
- package/dist/cli/commands/start.js +53 -0
- package/dist/cli/commands/start.js.map +1 -0
- package/dist/cli/commands/stats.js +42 -0
- package/dist/cli/commands/stats.js.map +1 -0
- package/dist/cli/commands/status.js +73 -0
- package/dist/cli/commands/status.js.map +1 -0
- package/dist/cli/commands/uninstall.js +23 -0
- package/dist/cli/commands/uninstall.js.map +1 -0
- package/dist/cli/config-wizard.js +104 -0
- package/dist/cli/config-wizard.js.map +1 -0
- package/dist/cli/destructive-op.js +32 -0
- package/dist/cli/destructive-op.js.map +1 -0
- package/dist/cli/import-wizard.js +89 -0
- package/dist/cli/import-wizard.js.map +1 -0
- package/dist/cli/index.js +74 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init-wizard.js +80 -0
- package/dist/cli/init-wizard.js.map +1 -0
- package/dist/cli/init.js +159 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/launcher-probe.js +71 -0
- package/dist/cli/launcher-probe.js.map +1 -0
- package/dist/cli/project-path.js +16 -0
- package/dist/cli/project-path.js.map +1 -0
- package/dist/cli/purge-wizard.js +200 -0
- package/dist/cli/purge-wizard.js.map +1 -0
- package/dist/cli/restore-wizard.js +101 -0
- package/dist/cli/restore-wizard.js.map +1 -0
- package/dist/cli/shared.js +128 -0
- package/dist/cli/shared.js.map +1 -0
- package/dist/cli/status.js +22 -0
- package/dist/cli/status.js.map +1 -0
- package/dist/cli/wordmark.js +15 -0
- package/dist/cli/wordmark.js.map +1 -0
- package/dist/config/constants.js +115 -0
- package/dist/config/constants.js.map +1 -0
- package/dist/config/env.js +168 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/memory-config.js +63 -0
- package/dist/config/memory-config.js.map +1 -0
- package/dist/config/paths.js +343 -0
- package/dist/config/paths.js.map +1 -0
- package/dist/config/settings.js +132 -0
- package/dist/config/settings.js.map +1 -0
- package/dist/config/version.js +12 -0
- package/dist/config/version.js.map +1 -0
- package/dist/daemon/failure-window.js +38 -0
- package/dist/daemon/failure-window.js.map +1 -0
- package/dist/daemon/heartbeat.js +40 -0
- package/dist/daemon/heartbeat.js.map +1 -0
- package/dist/daemon/index.js +873 -0
- package/dist/daemon/index.js.map +1 -0
- package/dist/daemon/log-rotation.js +27 -0
- package/dist/daemon/log-rotation.js.map +1 -0
- package/dist/daemon/readability-guard.js +89 -0
- package/dist/daemon/readability-guard.js.map +1 -0
- package/dist/daemon/rollup-service.js +239 -0
- package/dist/daemon/rollup-service.js.map +1 -0
- package/dist/daemon/update-check.js +87 -0
- package/dist/daemon/update-check.js.map +1 -0
- package/dist/daemon/work-queue.js +28 -0
- package/dist/daemon/work-queue.js.map +1 -0
- package/dist/discovery/session-projects.js +242 -0
- package/dist/discovery/session-projects.js.map +1 -0
- package/dist/hooks/codex-trust.js +80 -0
- package/dist/hooks/codex-trust.js.map +1 -0
- package/dist/hooks/common.js +81 -0
- package/dist/hooks/common.js.map +1 -0
- package/dist/hooks/hook-log.js +38 -0
- package/dist/hooks/hook-log.js.map +1 -0
- package/dist/hooks/installer.js +200 -0
- package/dist/hooks/installer.js.map +1 -0
- package/dist/hooks/session-start.js +321 -0
- package/dist/hooks/session-start.js.map +1 -0
- package/dist/hooks/user-prompt-submit.js +248 -0
- package/dist/hooks/user-prompt-submit.js.map +1 -0
- package/dist/install/binary.js +72 -0
- package/dist/install/binary.js.map +1 -0
- package/dist/install/config-file.js +119 -0
- package/dist/install/config-file.js.map +1 -0
- package/dist/install/daemon-health.js +48 -0
- package/dist/install/daemon-health.js.map +1 -0
- package/dist/install/daemon-service.js +309 -0
- package/dist/install/daemon-service.js.map +1 -0
- package/dist/install/doctor.js +180 -0
- package/dist/install/doctor.js.map +1 -0
- package/dist/install/health-checks.js +90 -0
- package/dist/install/health-checks.js.map +1 -0
- package/dist/install/installer.js +411 -0
- package/dist/install/installer.js.map +1 -0
- package/dist/install/launchd-backend.js +261 -0
- package/dist/install/launchd-backend.js.map +1 -0
- package/dist/install/launcher.js +224 -0
- package/dist/install/launcher.js.map +1 -0
- package/dist/install/markers.js +14 -0
- package/dist/install/markers.js.map +1 -0
- package/dist/install/platform.js +29 -0
- package/dist/install/platform.js.map +1 -0
- package/dist/install/present-tools.js +16 -0
- package/dist/install/present-tools.js.map +1 -0
- package/dist/install/self-update.js +120 -0
- package/dist/install/self-update.js.map +1 -0
- package/dist/install/service-backend.js +36 -0
- package/dist/install/service-backend.js.map +1 -0
- package/dist/install/service-manifest.js +36 -0
- package/dist/install/service-manifest.js.map +1 -0
- package/dist/install/status.js +58 -0
- package/dist/install/status.js.map +1 -0
- package/dist/install/systemd-backend.js +225 -0
- package/dist/install/systemd-backend.js.map +1 -0
- package/dist/markers.js +5 -0
- package/dist/markers.js.map +1 -0
- package/dist/mcp/installer.js +131 -0
- package/dist/mcp/installer.js.map +1 -0
- package/dist/mcp/server.js +81 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools/get-context.js +3 -0
- package/dist/mcp/tools/get-context.js.map +1 -0
- package/dist/mcp/tools/list-projects.js +3 -0
- package/dist/mcp/tools/list-projects.js.map +1 -0
- package/dist/mcp/tools/save-memory.js +3 -0
- package/dist/mcp/tools/save-memory.js.map +1 -0
- package/dist/mcp/tools.js +288 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/rendering/raw-turn-renderer.js +85 -0
- package/dist/rendering/raw-turn-renderer.js.map +1 -0
- package/dist/security/file-permissions.js +15 -0
- package/dist/security/file-permissions.js.map +1 -0
- package/dist/security/provider-transcript.js +74 -0
- package/dist/security/provider-transcript.js.map +1 -0
- package/dist/security/sanitize.js +210 -0
- package/dist/security/sanitize.js.map +1 -0
- package/dist/security/self-ingestion.js +47 -0
- package/dist/security/self-ingestion.js.map +1 -0
- package/dist/security/sentinel.js +19 -0
- package/dist/security/sentinel.js.map +1 -0
- package/dist/security/subprocess-allowlist.js +421 -0
- package/dist/security/subprocess-allowlist.js.map +1 -0
- package/dist/serving/instructions.js +33 -0
- package/dist/serving/instructions.js.map +1 -0
- package/dist/serving/lexical-recall.js +249 -0
- package/dist/serving/lexical-recall.js.map +1 -0
- package/dist/serving/session-reader.js +302 -0
- package/dist/serving/session-reader.js.map +1 -0
- package/dist/storage/backfill-runner.js +34 -0
- package/dist/storage/backfill-runner.js.map +1 -0
- package/dist/storage/backup.js +46 -0
- package/dist/storage/backup.js.map +1 -0
- package/dist/storage/candidate-validator.js +77 -0
- package/dist/storage/candidate-validator.js.map +1 -0
- package/dist/storage/consent-store.js +198 -0
- package/dist/storage/consent-store.js.map +1 -0
- package/dist/storage/custom-title-backfill.js +68 -0
- package/dist/storage/custom-title-backfill.js.map +1 -0
- package/dist/storage/db.js +312 -0
- package/dist/storage/db.js.map +1 -0
- package/dist/storage/external-agent-import-purge.js +182 -0
- package/dist/storage/external-agent-import-purge.js.map +1 -0
- package/dist/storage/first-prompt-search-backfill.js +76 -0
- package/dist/storage/first-prompt-search-backfill.js.map +1 -0
- package/dist/storage/first-prompt-search.js +7 -0
- package/dist/storage/first-prompt-search.js.map +1 -0
- package/dist/storage/injection-store.js +37 -0
- package/dist/storage/injection-store.js.map +1 -0
- package/dist/storage/memory-store.js +428 -0
- package/dist/storage/memory-store.js.map +1 -0
- package/dist/storage/project-resolver.js +294 -0
- package/dist/storage/project-resolver.js.map +1 -0
- package/dist/storage/project-store.js +104 -0
- package/dist/storage/project-store.js.map +1 -0
- package/dist/storage/rendered-chars-backfill.js +85 -0
- package/dist/storage/rendered-chars-backfill.js.map +1 -0
- package/dist/storage/resegmentation.js +582 -0
- package/dist/storage/resegmentation.js.map +1 -0
- package/dist/storage/rollup-store.js +246 -0
- package/dist/storage/rollup-store.js.map +1 -0
- package/dist/storage/root-commit-backfill.js +32 -0
- package/dist/storage/root-commit-backfill.js.map +1 -0
- package/dist/storage/sanitize-backfill.js +184 -0
- package/dist/storage/sanitize-backfill.js.map +1 -0
- package/dist/storage/segmentation.js +87 -0
- package/dist/storage/segmentation.js.map +1 -0
- package/dist/storage/session-fields-backfill.js +196 -0
- package/dist/storage/session-fields-backfill.js.map +1 -0
- package/dist/storage/session-read-model.js +130 -0
- package/dist/storage/session-read-model.js.map +1 -0
- package/dist/storage/session-store.js +200 -0
- package/dist/storage/session-store.js.map +1 -0
- package/dist/storage/session-title-backfill.js +75 -0
- package/dist/storage/session-title-backfill.js.map +1 -0
- package/dist/storage/session-title.js +71 -0
- package/dist/storage/session-title.js.map +1 -0
- package/dist/storage/shown-session-list-store.js +33 -0
- package/dist/storage/shown-session-list-store.js.map +1 -0
- package/dist/storage/stats.js +30 -0
- package/dist/storage/stats.js.map +1 -0
- package/dist/storage/turn-store.js +202 -0
- package/dist/storage/turn-store.js.map +1 -0
- package/dist/storage/ulid.js +17 -0
- package/dist/storage/ulid.js.map +1 -0
- package/dist/summarizer/anthropic-call.js +76 -0
- package/dist/summarizer/anthropic-call.js.map +1 -0
- package/dist/summarizer/call-log.js +120 -0
- package/dist/summarizer/call-log.js.map +1 -0
- package/dist/summarizer/haiku-provider.js +136 -0
- package/dist/summarizer/haiku-provider.js.map +1 -0
- package/dist/summarizer/pricing.js +8 -0
- package/dist/summarizer/pricing.js.map +1 -0
- package/dist/summarizer/prompt-cache.js +6 -0
- package/dist/summarizer/prompt-cache.js.map +1 -0
- package/dist/summarizer/prompt.js +47 -0
- package/dist/summarizer/prompt.js.map +1 -0
- package/dist/summarizer/provider-config.js +25 -0
- package/dist/summarizer/provider-config.js.map +1 -0
- package/dist/summarizer/rollup-prompt.js +225 -0
- package/dist/summarizer/rollup-prompt.js.map +1 -0
- package/dist/summarizer/rollup-provider.js +224 -0
- package/dist/summarizer/rollup-provider.js.map +1 -0
- package/dist/types/index.js +10 -0
- package/dist/types/index.js.map +1 -0
- package/dist/util/error.js +5 -0
- package/dist/util/error.js.map +1 -0
- package/dist/util/fs.js +97 -0
- package/dist/util/fs.js.map +1 -0
- package/dist/util/relative-time.js +12 -0
- package/dist/util/relative-time.js.map +1 -0
- 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
|