@lincoln504/pi-research 0.1.12 → 1.0.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 (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +64 -115
  3. package/dist/cli.mjs +23211 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +102 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +213 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +113 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +64 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1044 -0
  30. package/src/config.ts +1036 -147
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +264 -7
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -174
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -577
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. package/tsconfig.json +0 -40
package/src/logger.ts CHANGED
@@ -1,23 +1,46 @@
1
1
  /**
2
2
  * Logger — scoped file-based diagnostics
3
3
  *
4
- * Silent by default. When --verbose or PI_RESEARCH_VERBOSE=1 is set,
5
- * writes timestamped lines to {tmpdir}/pi-research-debug-{hash}.log where {hash}
6
- * is a random 4-character alphanumeric suffix (a-z, 0-9) to keep logs separate per run.
4
+ * Writes newline-delimited JSON records to a single consolidated log file. The
5
+ * path defaults to {os.tmpdir()}/pi-research.log and is overridable via the
6
+ * PI_RESEARCH_LOG_PATH env var (see buildDefaultDebugLogPath / log-utils). The
7
+ * file rotates at 10MB, keeping the last 10 archives.
8
+ * ERROR and WARN levels are always logged.
9
+ * INFO and DEBUG levels are only logged when PI_RESEARCH_DEBUG=true (or config.DEBUG=true).
7
10
  *
8
- * When NOT verbose: logFile is null, preventing all temp-dir writes.
9
11
  * This module never patches process-global console.* methods.
12
+ *
13
+ * Write durability / performance contract (see LogWriter):
14
+ * - WARN/ERROR are written SYNCHRONOUSLY. They are the primary forensic signal
15
+ * and must survive an immediate crash, so they are never buffered.
16
+ * - INFO/DEBUG (only emitted when verbose) are buffered and flushed off the
17
+ * event loop via fs.promises.appendFile, so high-frequency debug logging
18
+ * never blocks the event loop or the TUI render path — regardless of whether
19
+ * the log file lives on a fast (tmpfs/RAM) or slow (disk) filesystem. The
20
+ * buffer is drained synchronously before every WARN/ERROR write (preserving
21
+ * order) and on process 'exit', so graceful shutdowns lose nothing. Only a
22
+ * hard SIGKILL can drop the sub-millisecond tail of buffered INFO/DEBUG,
23
+ * which is acceptable for non-critical diagnostics.
10
24
  */
11
25
 
12
- import { appendFileSync } from 'node:fs';
26
+ import { appendFileSync, existsSync, mkdirSync, openSync, closeSync } from 'node:fs';
27
+ import { appendFile } from 'node:fs/promises';
13
28
  import { AsyncLocalStorage } from 'node:async_hooks';
14
- import { randomBytes } from 'node:crypto';
15
- import * as os from 'node:os';
16
29
  import * as path from 'node:path';
30
+ import { errorTracker, type ErrorContext } from './utils/error-tracker.ts';
31
+ import {
32
+ buildDefaultDebugLogPath,
33
+ isVerboseFromEnv,
34
+ getLogContext,
35
+ safeJsonStringify,
36
+ formatArg,
37
+ redactSecrets,
38
+ type LogContext,
39
+ } from './utils/log-utils.ts';
40
+ import { LogRotation } from './utils/log-rotation.ts';
41
+ import { DiskSpaceChecker } from './utils/disk-space-checker.ts';
42
+ import { captureStdio } from './utils/stdio-capture.ts';
17
43
 
18
- /**
19
- * Log level enum
20
- */
21
44
  export enum LogLevel {
22
45
  INFO = 'INFO',
23
46
  ERROR = 'ERROR',
@@ -25,142 +48,259 @@ export enum LogLevel {
25
48
  DEBUG = 'DEBUG',
26
49
  }
27
50
 
28
- /**
29
- * Logger interface for dependency injection
30
- */
31
51
  export interface ILogger {
32
52
  log(...args: unknown[]): void;
33
53
  info(...args: unknown[]): void;
34
54
  error(...args: unknown[]): void;
35
55
  warn(...args: unknown[]): void;
36
56
  debug(...args: unknown[]): void;
57
+ clear(): void;
58
+ flushSync(): void;
59
+ runCapturingStderr<T>(task: () => Promise<T>): Promise<T>;
37
60
  }
38
61
 
39
- /**
40
- * Logger configuration options
41
- */
42
62
  export interface LoggerOptions {
43
63
  verbose: boolean;
44
64
  logFilePath?: string;
45
- }
46
-
47
- export interface LogContext {
48
- sessionId?: string;
49
- sessionFile?: string;
50
- cwd?: string;
51
65
  researchRunId?: string;
52
- toolName?: string;
53
- phase?: string;
54
- eventName?: string;
66
+ consoleLog?: boolean;
55
67
  }
56
68
 
57
- const logContextStorage = new AsyncLocalStorage<LogContext>();
58
-
59
69
  /**
60
- * Generate a 4-character alphanumeric hash for uniqueness
70
+ * Per-file log writer, shared across every Logger instance that targets the
71
+ * same path (logging is consolidated to one file, so a single shared buffer is
72
+ * required to preserve global write order).
73
+ *
74
+ * INFO/DEBUG go through enqueue() — buffered, flushed asynchronously off the
75
+ * event loop. WARN/ERROR go through writeSync() — which first drains the buffer
76
+ * (keeping order) and then writes synchronously, so critical lines are durable.
61
77
  */
62
- function generateHash(): string {
63
- const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
64
- let hash = '';
65
- for (let i = 0; i < 4; i++) {
66
- hash += chars.charAt(Math.floor(Math.random() * chars.length));
78
+ class LogWriter {
79
+ private buffer: string[] = [];
80
+ private bufferBytes = 0;
81
+ private flushScheduled = false;
82
+ private flushing = false;
83
+ // Flush eagerly once buffered output gets large, to bound memory and latency
84
+ // during sustained bursts instead of waiting for the scheduled tick.
85
+ private static readonly MAX_BUFFER_BYTES = 256 * 1024;
86
+
87
+ constructor(private readonly filePath: string) {}
88
+
89
+ /** Queue a line for asynchronous, non-blocking write (INFO/DEBUG path). */
90
+ enqueue(line: string): void {
91
+ this.buffer.push(line);
92
+ this.bufferBytes += line.length;
93
+ if (this.bufferBytes >= LogWriter.MAX_BUFFER_BYTES) {
94
+ void this.flush();
95
+ return;
96
+ }
97
+ this.scheduleFlush();
67
98
  }
68
- return hash;
69
- }
70
-
71
- function buildDefaultDebugLogPath(hash: string): string {
72
- return path.join(os.tmpdir(), `pi-research-debug-${hash}.log`);
73
- }
74
-
75
- export function getDefaultDebugLogPathTemplate(): string {
76
- return buildDefaultDebugLogPath('{hash}');
77
- }
78
-
79
- /**
80
- * Check if verbose mode is enabled from environment
81
- */
82
- export function isVerboseFromEnv(): boolean {
83
- return process.argv.includes('--verbose') || process.env['PI_RESEARCH_VERBOSE'] === '1';
84
- }
85
99
 
86
- export function createResearchRunId(): string {
87
- return `run-${randomBytes(4).toString('hex')}`;
88
- }
100
+ private scheduleFlush(): void {
101
+ if (this.flushScheduled || this.flushing) return;
102
+ this.flushScheduled = true;
103
+ // setTimeout(0) runs the flush on the next event-loop turn — off the
104
+ // synchronous caller path — and coalesces a burst of lines into one write.
105
+ setTimeout(() => { void this.flush(); }, 0);
106
+ }
89
107
 
90
- export function getLogContext(): LogContext {
91
- return logContextStorage.getStore() ?? {};
92
- }
108
+ /** Asynchronously write buffered lines. Never blocks the event loop. */
109
+ private async flush(): Promise<void> {
110
+ this.flushScheduled = false;
111
+ if (this.flushing || this.buffer.length === 0) return;
112
+ this.flushing = true;
113
+ try {
114
+ while (this.buffer.length > 0) {
115
+ const batch = this.buffer.join('');
116
+ this.buffer.length = 0;
117
+ this.bufferBytes = 0;
118
+ try {
119
+ await appendFile(this.filePath, batch);
120
+ } catch {
121
+ // Silently ignore file write errors (matches the synchronous path).
122
+ }
123
+ }
124
+ } finally {
125
+ this.flushing = false;
126
+ // A line enqueued after the loop drained but before this point would
127
+ // otherwise linger with no scheduled flush — reschedule defensively.
128
+ if (this.buffer.length > 0) this.scheduleFlush();
129
+ }
130
+ }
93
131
 
94
- export function runWithLogContext<T>(context: LogContext, callback: () => T): T {
95
- const parent = logContextStorage.getStore() ?? {};
96
- return logContextStorage.run({ ...parent, ...context }, callback);
97
- }
132
+ /**
133
+ * Drain buffered lines, then write `line`, all synchronously and in order.
134
+ * Used for WARN/ERROR so the critical line (and all lines queued in the buffer
135
+ * before it) are on disk before this call returns.
136
+ *
137
+ * Ordering caveat: if a large INFO/DEBUG burst already cleared the buffer into
138
+ * an in-flight async appendFile, drainSync() will find the buffer empty and
139
+ * the WARN line will be appended before that in-flight batch completes. In
140
+ * practice this means WARN/ERROR lines may appear slightly before the last
141
+ * flush of the preceding INFO/DEBUG burst. Acceptable for a diagnostic log.
142
+ */
143
+ writeSync(line: string): void {
144
+ this.drainSync();
145
+ try {
146
+ appendFileSync(this.filePath, line);
147
+ } catch {
148
+ // Silently ignore file write errors.
149
+ }
150
+ }
98
151
 
99
- function safeJsonStringify(value: unknown): string {
100
- try {
101
- return JSON.stringify(value, null, 0);
102
- } catch {
103
- return '[unserializable]';
152
+ /** Synchronously flush all buffered lines (graceful exit / tests / pre-sync-write). */
153
+ drainSync(): void {
154
+ if (this.buffer.length === 0) return;
155
+ const batch = this.buffer.join('');
156
+ this.buffer.length = 0;
157
+ this.bufferBytes = 0;
158
+ try {
159
+ appendFileSync(this.filePath, batch);
160
+ } catch {
161
+ // Silently ignore file write errors.
162
+ }
104
163
  }
105
164
  }
106
165
 
107
- function formatArg(arg: unknown): string {
108
- if (arg instanceof Error) {
109
- return arg.stack ?? arg.message;
166
+ // One writer per target path → shared, ordered buffer for consolidated logging.
167
+ const logWriters = new Map<string, LogWriter>();
168
+
169
+ // Drain every writer synchronously on graceful process exit so no buffered
170
+ // INFO/DEBUG line is lost. Registered exactly once, on first writer creation.
171
+ let exitDrainRegistered = false;
172
+ function getLogWriter(filePath: string): LogWriter {
173
+ let writer = logWriters.get(filePath);
174
+ if (!writer) {
175
+ writer = new LogWriter(filePath);
176
+ logWriters.set(filePath, writer);
110
177
  }
111
- if (typeof arg === 'object' && arg !== null) {
112
- return safeJsonStringify(arg);
178
+ if (!exitDrainRegistered) {
179
+ exitDrainRegistered = true;
180
+ process.on('exit', () => {
181
+ for (const w of logWriters.values()) {
182
+ try { w.drainSync(); } catch { /* best-effort on exit */ }
183
+ }
184
+ });
113
185
  }
114
- return String(arg);
186
+ return writer;
115
187
  }
116
188
 
117
189
  /**
118
- * Logger implementation — writes to file when verbose, silent otherwise
190
+ * Symbol used to identify real Logger instances
119
191
  */
192
+ const LOGGER_BRAND = Symbol.for('pi-research.Logger');
193
+
120
194
  export class Logger implements ILogger {
121
195
  private verbose: boolean;
122
- private logFile: string | null;
196
+ private consoleLog: boolean;
197
+ private logFile: string;
198
+ private logDir: string;
199
+ private readonly sessionId: string | undefined;
200
+ readonly [LOGGER_BRAND] = true;
201
+
202
+ private readonly rotation: LogRotation;
203
+ private readonly diskSpaceChecker: DiskSpaceChecker;
123
204
 
124
205
  constructor(options: Partial<LoggerOptions> = {}) {
125
206
  this.verbose = options.verbose ?? isVerboseFromEnv();
126
- // Only set logFile path if verbose mode is enabled
127
- if (this.verbose) {
128
- const hash = generateHash();
129
- this.logFile = options.logFilePath ?? buildDefaultDebugLogPath(hash);
130
- } else {
131
- this.logFile = null;
207
+ this.consoleLog = options.consoleLog ?? (process.env['PI_RESEARCH_CONSOLE_LOG'] === 'true');
208
+ // Consolidated logging: always use the same file regardless of run ID
209
+ this.logFile = options.logFilePath ?? buildDefaultDebugLogPath();
210
+ this.logDir = path.dirname(this.logFile);
211
+ this.sessionId = options.researchRunId;
212
+
213
+ this.rotation = new LogRotation(this);
214
+ this.diskSpaceChecker = new DiskSpaceChecker();
215
+
216
+ // Ensure parent directory exists, and pre-create the log file owner-only.
217
+ // The default path lives in a world-traversable tmpdir; appendFile would create
218
+ // it at the umask default (often world-readable). Logs are redacted, but the
219
+ // file should still match the 0o600 posture used for state/lock/knowledge files.
220
+ try {
221
+ if (!existsSync(this.logDir)) {
222
+ mkdirSync(this.logDir, { recursive: true, mode: 0o700 });
223
+ }
224
+ // Atomically create the log file owner-only if it's absent. 'wx' = O_CREAT|
225
+ // O_EXCL: it either creates the file at 0o600 or fails with EEXIST when one
226
+ // already exists — no check-then-create window for another user to win.
227
+ try {
228
+ closeSync(openSync(this.logFile, 'wx', 0o600));
229
+ } catch {
230
+ // Already exists (or not creatable) — appendFile uses the existing file.
231
+ }
232
+ } catch {
233
+ // Ignore if we can't create the dir or file
132
234
  }
133
235
  }
134
236
 
135
- /**
136
- * Emit a log message to file (if verbose) or nowhere (if silent)
137
- * @param level - Log level (INFO, ERROR, WARN, DEBUG)
138
- * @param args - Arguments to log
139
- */
140
237
  private emit(level: string, ...args: unknown[]): void {
141
- if (!this.verbose || !this.logFile) {
238
+ // Only filter INFO and DEBUG if not verbose
239
+ if (!this.verbose && (level === LogLevel.INFO || level === LogLevel.DEBUG)) {
240
+ return;
241
+ }
242
+
243
+ // Check disk space before writing
244
+ if (!this.diskSpaceChecker.checkDiskSpace(this.logDir)) {
142
245
  return;
143
246
  }
144
247
 
248
+ // Rotate log file if needed (throttled to one stat per 60s — see LogRotation).
249
+ this.rotation.checkAndRotate(this.logFile, this.logDir);
250
+
145
251
  const timestamp = new Date().toISOString();
146
252
  const firstError = args.find((arg): arg is Error => arg instanceof Error);
253
+ // Redact credentials and bound size before the message reaches ANY sink, so
254
+ // neither the log file nor the console persists clear-text secrets or
255
+ // unbounded network/scraped payloads.
256
+ const message = redactSecrets(args.map(formatArg).join(' '));
147
257
  const entry = {
148
258
  timestamp,
149
259
  level,
150
260
  ...getLogContext(),
151
- message: args.map(formatArg).join(' '),
261
+ message,
152
262
  ...(firstError
153
- ? { errorMessage: firstError.message, errorStack: firstError.stack }
263
+ ? { errorMessage: redactSecrets(firstError.message), errorStack: redactSecrets(firstError.stack ?? '') }
154
264
  : {}),
155
265
  };
156
266
  const line = `${safeJsonStringify(entry)}\n`;
157
267
 
158
- // Write to file
159
- try {
160
- appendFileSync(this.logFile, line);
161
- } catch {
162
- // Silently ignore file write errors
268
+ // Write to file. WARN/ERROR are written synchronously for crash-durability;
269
+ // INFO/DEBUG are buffered and flushed off the event loop so high-frequency
270
+ // logging never blocks the loop or the TUI render path (see LogWriter).
271
+ const writer = getLogWriter(this.logFile);
272
+ if (level === LogLevel.ERROR || level === LogLevel.WARN) {
273
+ writer.writeSync(line);
274
+ } else {
275
+ writer.enqueue(line);
163
276
  }
277
+
278
+ // Optional console output
279
+ if (this.consoleLog) {
280
+ const color = level === LogLevel.ERROR ? '\x1b[31m' :
281
+ level === LogLevel.WARN ? '\x1b[33m' :
282
+ level === LogLevel.DEBUG ? '\x1b[90m' : '\x1b[36m';
283
+ const reset = '\x1b[0m';
284
+ // Reuse the already-redacted message; additionally strip CR/LF so untrusted
285
+ // content cannot forge log lines (log-injection defense). The JSON file sink
286
+ // escapes control chars on its own. CodeQL (js/log-injection) StringReplaceSanitizer
287
+ // requires the replacement to be "" (empty string) — not ' ' — per its QL definition:
288
+ // replaces(s, "") and s.regexpMatch("\\n")
289
+ const msg = message.replace(/[\r\n]/g, '');
290
+ const rawSid = this.sessionId ?? '';
291
+ const sid = rawSid.replace(/[\r\n]/g, '');
292
+ const prefix = sid ? `[${sid}] ` : '';
293
+ console.log(`${color}${timestamp} ${level} ${prefix}${reset}${msg}`);
294
+ }
295
+ }
296
+
297
+ async runCapturingStderr<T>(task: () => Promise<T>): Promise<T> {
298
+ return captureStdio(
299
+ this.logFile,
300
+ () => this.diskSpaceChecker.checkDiskSpace(this.logDir),
301
+ task,
302
+ this.sessionId
303
+ );
164
304
  }
165
305
 
166
306
  log(...args: unknown[]): void {
@@ -172,6 +312,11 @@ export class Logger implements ILogger {
172
312
  }
173
313
 
174
314
  error(...args: unknown[]): void {
315
+ const errArg = args.find((arg): arg is Error => arg instanceof Error);
316
+ if (errArg) {
317
+ const context: ErrorContext = getLogContext();
318
+ errorTracker.trackError(errArg, context);
319
+ }
175
320
  this.emit(LogLevel.ERROR, ...args);
176
321
  }
177
322
 
@@ -183,64 +328,113 @@ export class Logger implements ILogger {
183
328
  this.emit(LogLevel.DEBUG, ...args);
184
329
  }
185
330
 
331
+ clear(): void {
332
+ this.flushSync();
333
+ this.rotation.clearLogs(this.logFile, this.logDir);
334
+ }
335
+
186
336
  /**
187
- * Get the log file path
337
+ * Synchronously flush this logger's buffered INFO/DEBUG output to disk.
338
+ * Use before reading the log file directly (tests) or during shutdown.
188
339
  */
340
+ flushSync(): void {
341
+ getLogWriter(this.logFile).drainSync();
342
+ }
343
+
189
344
  getLogFilePath(): string | null {
190
345
  return this.logFile;
191
346
  }
192
347
 
193
- /**
194
- * Check if verbose mode is enabled
195
- */
196
348
  isVerbose(): boolean {
197
349
  return this.verbose;
198
350
  }
199
351
  }
200
352
 
201
- /**
202
- * Global logger instance
203
- */
204
- let globalLogger: Logger | null = null;
353
+ // AsyncLocalStorage context for concurrent run isolation
354
+ const loggerContext = new AsyncLocalStorage<Logger>();
205
355
 
206
356
  /**
207
- * Create a new logger instance
357
+ * Run a function with a specific logger bound to the async context.
358
+ * All calls to getLogger() within fn (and any async work it spawns) will
359
+ * return this logger instance, providing proper isolation for concurrent runs.
208
360
  */
209
- export function createLogger(options: Partial<LoggerOptions> = {}): Logger {
210
- return new Logger(options);
361
+ export function runWithLogger<T>(loggerInstance: Logger, fn: () => Promise<T>): Promise<T> {
362
+ return loggerContext.run(loggerInstance, fn);
211
363
  }
212
364
 
213
- /**
214
- * Get the global logger instance (singleton)
215
- */
216
- export function getLogger(): Logger {
217
- if (!globalLogger) {
218
- globalLogger = createLogger({ verbose: isVerboseFromEnv() });
365
+ // Session-scoped logger storage to support concurrent research runs
366
+ // Each session gets its own logger instance to prevent log bleeding
367
+ const sessionLoggers = new Map<string, Logger>();
368
+ let _globalLogger: Logger | null = null;
369
+
370
+ export function getLogger(sessionId?: string): Logger {
371
+ // Check async context first (used for concurrent run isolation via runWithLogger)
372
+ const contextLogger = loggerContext.getStore();
373
+ if (contextLogger) return contextLogger;
374
+
375
+ // Return session-scoped logger if sessionId is provided
376
+ if (sessionId) {
377
+ let lg = sessionLoggers.get(sessionId);
378
+ if (!lg) {
379
+ lg = new Logger({
380
+ verbose: isVerboseFromEnv(),
381
+ researchRunId: sessionId,
382
+ consoleLog: process.env['PI_RESEARCH_CONSOLE_LOG'] === 'true'
383
+ });
384
+ sessionLoggers.set(sessionId, lg);
385
+ }
386
+ return lg;
387
+ }
388
+ // Fall back to creating a global logger if no context-bound logger is available
389
+ if (!_globalLogger) {
390
+ _globalLogger = new Logger({
391
+ verbose: isVerboseFromEnv(),
392
+ consoleLog: process.env['PI_RESEARCH_CONSOLE_LOG'] === 'true'
393
+ });
219
394
  }
220
- return globalLogger;
395
+ return _globalLogger;
221
396
  }
222
397
 
223
- /**
224
- * Set the global logger instance (for testing)
225
- */
226
- export function setLogger(logger: Logger): void {
227
- globalLogger = logger;
398
+ export function setLogger(loggerInstance: Logger, sessionId?: string): void {
399
+ if (loggerInstance && !(loggerInstance as any)[LOGGER_BRAND]) {
400
+ throw new Error('setLogger must be called with a Logger instance, not the wrapper.');
401
+ }
402
+ if (sessionId) {
403
+ sessionLoggers.set(sessionId, loggerInstance);
404
+ } else {
405
+ _globalLogger = loggerInstance;
406
+ }
228
407
  }
229
408
 
230
- /**
231
- * Reset the global logger instance (for testing)
232
- */
233
- export function resetLogger(): void {
234
- globalLogger = null;
409
+ export function resetLogger(sessionId?: string): void {
410
+ if (sessionId) {
411
+ sessionLoggers.delete(sessionId);
412
+ } else {
413
+ _globalLogger = null;
414
+ sessionLoggers.clear();
415
+ }
416
+ }
417
+
418
+ export function createLogger(options: Partial<LoggerOptions> = {}): Logger {
419
+ if (options.consoleLog === undefined) {
420
+ options.consoleLog = process.env['PI_RESEARCH_CONSOLE_LOG'] === 'true';
421
+ }
422
+ return new Logger(options);
235
423
  }
236
424
 
237
425
  /**
238
- * Logger singleton for backward compatibility
426
+ * Global default logger proxy
239
427
  */
240
- export const logger = {
428
+ export const logger: ILogger = {
241
429
  log: (...args: unknown[]) => getLogger().log(...args),
242
430
  info: (...args: unknown[]) => getLogger().info(...args),
243
431
  error: (...args: unknown[]) => getLogger().error(...args),
244
432
  warn: (...args: unknown[]) => getLogger().warn(...args),
245
433
  debug: (...args: unknown[]) => getLogger().debug(...args),
434
+ clear: () => getLogger().clear(),
435
+ flushSync: () => getLogger().flushSync(),
436
+ runCapturingStderr: <T>(task: () => Promise<T>) => getLogger().runCapturingStderr(task),
246
437
  };
438
+
439
+ export { type LogContext };
440
+ export { buildDefaultDebugLogPath, isVerboseFromEnv, createResearchRunId, getLogContext, runWithLogContext } from './utils/log-utils.ts';