@raidiant/notifai 10.1.1 → 10.1.2

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 (156) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/commands-core.d.ts +1 -1
  3. package/dist/commands-init.js +9 -6
  4. package/dist/commands-init.js.map +1 -1
  5. package/dist/commands-send-support.js +3 -0
  6. package/dist/commands-send-support.js.map +1 -1
  7. package/dist/commands-skill.js +72 -54
  8. package/dist/commands-skill.js.map +1 -1
  9. package/dist/invocation-context.d.ts +1 -0
  10. package/dist/invocation-context.js +9 -1
  11. package/dist/invocation-context.js.map +1 -1
  12. package/dist/native-skills.d.ts +13 -3
  13. package/dist/native-skills.js +51 -12
  14. package/dist/native-skills.js.map +1 -1
  15. package/dist/node-floor.d.ts +2 -1
  16. package/dist/node-floor.js +9 -5
  17. package/dist/node-floor.js.map +1 -1
  18. package/dist/orca-session-title.d.ts +5 -4
  19. package/dist/orca-session-title.js +32 -19
  20. package/dist/orca-session-title.js.map +1 -1
  21. package/dist/platform.d.ts +11 -17
  22. package/dist/platform.js +20 -23
  23. package/dist/platform.js.map +1 -1
  24. package/dist/release.d.ts +5 -5
  25. package/dist/release.js +5 -5
  26. package/dist/setup-destinations.js +1 -1
  27. package/dist/setup-destinations.js.map +1 -1
  28. package/dist/skill-integrity.d.ts +49 -0
  29. package/dist/skill-integrity.js +154 -0
  30. package/dist/skill-integrity.js.map +1 -0
  31. package/dist/skill-source/manifest.json +16 -0
  32. package/dist/skill-source/notifai/SKILL.md +390 -0
  33. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  34. package/package.json +11 -14
  35. package/src/atomic-file.ts +0 -198
  36. package/src/claude-wake.test.ts +0 -321
  37. package/src/claude-wake.ts +0 -499
  38. package/src/cli-bin.test.ts +0 -176
  39. package/src/cli-bin.ts +0 -233
  40. package/src/cli-release.test.ts +0 -46
  41. package/src/cli-release.ts +0 -78
  42. package/src/cli-update-contract.ts +0 -9
  43. package/src/cli-update.test.ts +0 -125
  44. package/src/client.test.ts +0 -390
  45. package/src/client.ts +0 -352
  46. package/src/codex-wake.test.ts +0 -254
  47. package/src/codex-wake.ts +0 -301
  48. package/src/command-session.ts +0 -38
  49. package/src/commands-agent-sessions.ts +0 -72
  50. package/src/commands-auth.ts +0 -238
  51. package/src/commands-config.ts +0 -336
  52. package/src/commands-core.ts +0 -312
  53. package/src/commands-devices.ts +0 -180
  54. package/src/commands-doctor.ts +0 -1501
  55. package/src/commands-guidance.ts +0 -181
  56. package/src/commands-harness-context.ts +0 -207
  57. package/src/commands-hook-shape.ts +0 -69
  58. package/src/commands-hooks.ts +0 -2318
  59. package/src/commands-init.ts +0 -1055
  60. package/src/commands-io.ts +0 -108
  61. package/src/commands-logs.ts +0 -243
  62. package/src/commands-project.ts +0 -47
  63. package/src/commands-send-support.test.ts +0 -156
  64. package/src/commands-send-support.ts +0 -311
  65. package/src/commands-send.ts +0 -875
  66. package/src/commands-setup-proof.test.ts +0 -72
  67. package/src/commands-setup-proof.ts +0 -124
  68. package/src/commands-skill.test.ts +0 -44
  69. package/src/commands-skill.ts +0 -204
  70. package/src/commands-sounds.test.ts +0 -106
  71. package/src/commands-sounds.ts +0 -49
  72. package/src/commands-update.ts +0 -180
  73. package/src/commands.test.ts +0 -8297
  74. package/src/commands.ts +0 -77
  75. package/src/config-schema.test.ts +0 -153
  76. package/src/config-schema.ts +0 -349
  77. package/src/config.test.ts +0 -581
  78. package/src/config.ts +0 -475
  79. package/src/credentials.test.ts +0 -403
  80. package/src/credentials.ts +0 -403
  81. package/src/file-lock-process-worker.test.ts +0 -64
  82. package/src/file-lock.test.ts +0 -320
  83. package/src/file-lock.ts +0 -393
  84. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  85. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  86. package/src/generated-session-label.ts +0 -155
  87. package/src/guidance-content.ts +0 -278
  88. package/src/guidance-render.ts +0 -56
  89. package/src/guidance.test.ts +0 -327
  90. package/src/guidance.ts +0 -233
  91. package/src/harness-integration.test.ts +0 -294
  92. package/src/harnesses.test.ts +0 -149
  93. package/src/harnesses.ts +0 -164
  94. package/src/hook-adapter.test.ts +0 -496
  95. package/src/hook-adapter.ts +0 -424
  96. package/src/hook-input.test.ts +0 -39
  97. package/src/hook-input.ts +0 -72
  98. package/src/hooks.test.ts +0 -5824
  99. package/src/hooks.ts +0 -4011
  100. package/src/install-hooks.test.ts +0 -1614
  101. package/src/install-hooks.ts +0 -1501
  102. package/src/interactive.test.ts +0 -387
  103. package/src/interactive.ts +0 -858
  104. package/src/invocation-context.test.ts +0 -352
  105. package/src/invocation-context.ts +0 -197
  106. package/src/logging-process-worker.test.ts +0 -45
  107. package/src/logging.test.ts +0 -544
  108. package/src/logging.ts +0 -698
  109. package/src/main-run.ts +0 -45
  110. package/src/main.ts +0 -12
  111. package/src/native-skills.test.ts +0 -115
  112. package/src/native-skills.ts +0 -162
  113. package/src/node-floor.test.ts +0 -27
  114. package/src/node-floor.ts +0 -37
  115. package/src/onboarding-docs.test.ts +0 -25
  116. package/src/openclaw-plugin.ts +0 -409
  117. package/src/opencode-plugin.ts +0 -298
  118. package/src/orca-session-title.test.ts +0 -54
  119. package/src/orca-session-title.ts +0 -103
  120. package/src/pack-feedback-slice.test.ts +0 -212
  121. package/src/pack-feedback-slice.ts +0 -187
  122. package/src/platform.test.ts +0 -152
  123. package/src/platform.ts +0 -173
  124. package/src/program.test.ts +0 -317
  125. package/src/program.ts +0 -842
  126. package/src/project-enablement.test.ts +0 -55
  127. package/src/project-enablement.ts +0 -99
  128. package/src/question-caps.test.ts +0 -49
  129. package/src/question-settlement-process.ts +0 -47
  130. package/src/question-timing.ts +0 -56
  131. package/src/readiness.test.ts +0 -25
  132. package/src/readiness.ts +0 -212
  133. package/src/release.ts +0 -51
  134. package/src/send-attempts.test.ts +0 -143
  135. package/src/send-attempts.ts +0 -188
  136. package/src/send.test.ts +0 -28
  137. package/src/send.ts +0 -436
  138. package/src/session-activation.ts +0 -61
  139. package/src/session-end-log-lock-worker.test.ts +0 -35
  140. package/src/session-labels.test.ts +0 -490
  141. package/src/session-labels.ts +0 -474
  142. package/src/setup-destinations.test.ts +0 -41
  143. package/src/setup-destinations.ts +0 -50
  144. package/src/skill.test.ts +0 -426
  145. package/src/sound-ref.test.ts +0 -31
  146. package/src/sound-ref.ts +0 -34
  147. package/src/trust-policy.test.ts +0 -701
  148. package/src/ui/banner.ts +0 -95
  149. package/src/ui/config-view.test.ts +0 -90
  150. package/src/ui/config-view.ts +0 -138
  151. package/src/ui/help.test.ts +0 -16
  152. package/src/ui/help.ts +0 -128
  153. package/src/ui/theme.test.ts +0 -92
  154. package/src/ui/theme.ts +0 -265
  155. package/src/url-policy.ts +0 -399
  156. package/tsconfig.json +0 -10
package/src/logging.ts DELETED
@@ -1,698 +0,0 @@
1
- /**
2
- * Local, agent-facing logs.
3
- *
4
- * Most of what this CLI does happens where nobody is looking. A harness hook
5
- * runs headless in front of every prompt; its stderr is swallowed by the
6
- * harness, and its most consequential act is usually a decision to do nothing —
7
- * the question stayed in the terminal, and no line anywhere says why. An agent
8
- * asked afterwards "did my question reach the phone?" has had no way to find
9
- * out. This is the record that answers that.
10
- *
11
- * Written for an agent to read, which is a different job from writing for a
12
- * person:
13
- *
14
- * - One JSON object per line, so `grep`, `jq`, and a plain read all work
15
- * without a parser and without reading the file whole.
16
- * - A closed event vocabulary, so a filter written once keeps matching.
17
- * - A run id stamped on every line of one invocation, so "what happened
18
- * during that command" is a single query rather than a reconstruction.
19
- * - Retrieval bounded by default (`notifai logs`), because an unbounded dump
20
- * into a context window is worse than no log at all.
21
- *
22
- * Local only. Nothing here is uploaded, and nothing in the record is shaped for
23
- * a server. If submitting a log for debugging is ever added it will be an
24
- * explicit, separate act by the user.
25
- *
26
- * ## Concurrency
27
- *
28
- * Several processes share one file: the hook fires per turn, `send` and `ask`
29
- * fire whenever an agent calls them, and worktrees run in parallel. Every write
30
- * takes one short cross-process lock around the shared size check, any rotation,
31
- * and the append. Without that transaction, several writers can all observe the
32
- * same remaining budget and independently push the file past its configured cap.
33
- *
34
- * The record itself still lands in one `O_APPEND` write. That keeps a line whole
35
- * if an uncooperating process touches the file, while the lock makes cooperating
36
- * Notifai processes agree which inode owns the record and when it must rotate.
37
- *
38
- * ## Failure
39
- *
40
- * Logging may never break the CLI. A read-only home, a full disk, or a
41
- * directory someone replaced with a file all end the same way: logging switches
42
- * itself off for the rest of the process and the command carries on. A
43
- * diagnostic that can take down the thing it observes is not a diagnostic.
44
- */
45
- import { randomBytes } from 'node:crypto'
46
- import { appendFileSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs'
47
- import path from 'node:path'
48
- import {
49
- CONFIG_KEYS,
50
- LOG_LEVELS,
51
- loadConfig,
52
- stateDir,
53
- type CliConfig,
54
- type LogLevel,
55
- } from './config.js'
56
- import { withFileLock } from './file-lock.js'
57
-
58
- /** Bumped when the record shape changes in a way a reader must notice. */
59
- export const LOG_SCHEMA_VERSION = 1
60
-
61
- export { LOG_LEVELS, type LogLevel }
62
- export type RecordLevel = Exclude<LogLevel, 'off'>
63
-
64
- /**
65
- * The levels a record can actually carry.
66
- *
67
- * `off` is a setting that stops writing, never a severity anything is written
68
- * at — so offering it as a retrieval filter accepted a query that could only
69
- * ever match nothing, and then blamed the result on too narrow a search.
70
- */
71
- export const RECORD_LEVELS: readonly RecordLevel[] = LOG_LEVELS.filter(
72
- (level): level is RecordLevel => level !== 'off',
73
- )
74
-
75
- const LEVEL_RANK: Record<LogLevel, number> = { off: 0, error: 1, info: 2, debug: 3 }
76
-
77
- /**
78
- * The closed vocabulary. A filter is only worth writing if it keeps matching,
79
- * so these names are a contract: add to the list rather than rephrasing an
80
- * existing one, and keep each event answering exactly one question.
81
- */
82
- export const LOG_EVENTS = [
83
- /** A CLI invocation began: which command, which flag names, which version. */
84
- 'cli.start',
85
- /** It ended: exit code and how long it took. */
86
- 'cli.end',
87
- /** A command failed. Carries the server's own code and rejected paths. */
88
- 'cli.error',
89
- /** Which config layer won, for the keys that changed the outcome. */
90
- 'config.resolved',
91
- /** One HTTP call to the service: method, path, status, duration. */
92
- 'http.call',
93
- /** A send crossed its ambiguous network boundary with this retry identity. */
94
- 'send.attempt',
95
- /** A notification was accepted by the service. */
96
- 'send.submitted',
97
- /** What became of it per device. */
98
- 'send.outcome',
99
- /** `notifai ask` registered a question for the turn-end hook. */
100
- 'ask.registered',
101
- /** An answer came back from a device. */
102
- 'reply.received',
103
- /** An agent attempted to acknowledge the user's reply. */
104
- 'acknowledgement.attempted',
105
- /** The service recorded or replayed an Agent Acknowledgement. */
106
- 'acknowledgement.outcome',
107
- /** A harness hook started. */
108
- 'hook.start',
109
- /** A hook decided whether a question may leave the terminal, and why. */
110
- 'hook.gate',
111
- /** A question reached the user's devices. */
112
- 'hook.pushed',
113
- /** A retirement close was attempted, and whether the server proved it. */
114
- 'hook.retirement',
115
- /** The hook handed an answer back to the agent. */
116
- 'hook.answer',
117
- /** The hook finished, with or without a decision. */
118
- 'hook.end',
119
- /** The active file was rotated. Explains a gap to anyone reading backwards. */
120
- 'log.rotated',
121
- ] as const
122
- export type LogEvent = (typeof LOG_EVENTS)[number]
123
-
124
- export interface LogRecord {
125
- /** Schema version. On every line, so a line pulled out by grep still parses. */
126
- v: number
127
- /** ISO 8601, UTC. */
128
- ts: string
129
- level: RecordLevel
130
- event: LogEvent
131
- /** Correlation id for one CLI invocation. */
132
- run: string
133
- /** The command path, e.g. `send` or `config set`. */
134
- cmd: string
135
- pid: number
136
- project?: string
137
- session?: string
138
- data?: Record<string, unknown>
139
- }
140
-
141
- // ---------------------------------------------------------------------------
142
- // Limits
143
- // ---------------------------------------------------------------------------
144
-
145
- /**
146
- * A record must fit in one write to stay atomic against other processes, and
147
- * must fit in a reader's context window to be worth writing at all. Both point
148
- * at the same answer: keep them small and truncate loudly.
149
- */
150
- const MAX_RECORD_BYTES = 8_000
151
- const MAX_STRING_CHARS = 400
152
- const MAX_ARRAY_ITEMS = 20
153
- const MAX_DEPTH = 4
154
-
155
- /**
156
- * Redaction is unconditional, not a level. A log is the artefact most likely to
157
- * be pasted into a conversation, an issue, or a future upload, and a bearer
158
- * token that only leaks at `debug` still leaks.
159
- */
160
- const SECRET_KEY_PATTERN = /secret|token|password|authorization|credential|verifier|api[_-]?key/i
161
- const MACHINE_TOKEN_PATTERN = /nfm_[A-Za-z0-9._-]{6,}/g
162
- /** Notification and question content: persist length, never the words. */
163
- const CONTENT_STRING_KEYS = new Set(['title', 'subtitle', 'body', 'text', 'question'])
164
- /** Choice labels, reply answers, and raw argv: persist counts, never values. */
165
- const CONTENT_LIST_KEYS = new Set(['answers', 'choices', 'argv'])
166
-
167
- export const REDACTED = '[redacted]'
168
-
169
- // ---------------------------------------------------------------------------
170
- // Paths
171
- // ---------------------------------------------------------------------------
172
-
173
- export function logsDir(env: NodeJS.ProcessEnv = process.env): string {
174
- return path.join(stateDir(env), 'logs')
175
- }
176
-
177
- export function activeLogPath(env: NodeJS.ProcessEnv = process.env): string {
178
- return path.join(logsDir(env), 'notifai.jsonl')
179
- }
180
-
181
- /**
182
- * Archives are timestamp-named so lexical order is chronological order — the
183
- * reader needs no `stat` to walk them, and two processes rotating at once
184
- * cannot collide on a name.
185
- */
186
- const ARCHIVE_PATTERN = /^notifai-\d{8}T\d{9}Z-[0-9a-f]{4}\.jsonl$/
187
-
188
- function archiveName(now: number): string {
189
- const stamp = new Date(now).toISOString().replace(/[-:.]/g, '')
190
- return `notifai-${stamp}-${randomBytes(2).toString('hex')}.jsonl`
191
- }
192
-
193
- /** Archives, oldest first. */
194
- export function archiveLogPaths(env: NodeJS.ProcessEnv = process.env): string[] {
195
- const dir = logsDir(env)
196
- let entries: string[]
197
- try {
198
- entries = readdirSync(dir)
199
- } catch {
200
- return []
201
- }
202
- return entries
203
- .filter((name) => ARCHIVE_PATTERN.test(name))
204
- .sort()
205
- .map((name) => path.join(dir, name))
206
- }
207
-
208
- // ---------------------------------------------------------------------------
209
- // Redaction and shaping
210
- // ---------------------------------------------------------------------------
211
-
212
- function redactString(value: string): string {
213
- const scrubbed = value.replace(MACHINE_TOKEN_PATTERN, 'nfm_[redacted]')
214
- if (scrubbed.length <= MAX_STRING_CHARS) return scrubbed
215
- return `${scrubbed.slice(0, MAX_STRING_CHARS)}…(+${scrubbed.length - MAX_STRING_CHARS} chars)`
216
- }
217
-
218
- /**
219
- * Flag names only — argv values are notification content and user text.
220
- *
221
- * `--title=SECRET` must become `--title`. Filtering on a `--` prefix is not
222
- * enough: inline assignment would otherwise persist the value.
223
- */
224
- export function argvFlagNames(argv: readonly string[]): string[] {
225
- const names: string[] = []
226
- for (const token of argv) {
227
- if (!token.startsWith('--') || token === '--') continue
228
- const eq = token.indexOf('=')
229
- names.push(eq === -1 ? token : token.slice(0, eq))
230
- }
231
- return names
232
- }
233
-
234
- /**
235
- * Shape a value for the record: drop what must never be written, truncate what
236
- * would flood a reader, and refuse to recurse for ever into a cyclic object.
237
- */
238
- export function shape(value: unknown, depth = 0): unknown {
239
- if (value === null || value === undefined) return value
240
- if (typeof value === 'string') return redactString(value)
241
- if (typeof value === 'number' || typeof value === 'boolean') return value
242
- if (typeof value === 'bigint') return String(value)
243
- if (value instanceof Error) return redactString(`${value.name}: ${value.message}`)
244
- if (depth >= MAX_DEPTH) return '[deep]'
245
- if (Array.isArray(value)) {
246
- const items = value.slice(0, MAX_ARRAY_ITEMS).map((item) => shape(item, depth + 1))
247
- return value.length > MAX_ARRAY_ITEMS ? [...items, `…(+${value.length - MAX_ARRAY_ITEMS} more)`] : items
248
- }
249
- if (typeof value === 'object') {
250
- const out: Record<string, unknown> = {}
251
- for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
252
- if (nested === undefined) continue
253
- if (SECRET_KEY_PATTERN.test(key)) {
254
- out[key] = REDACTED
255
- continue
256
- }
257
- if (CONTENT_STRING_KEYS.has(key) && typeof nested === 'string') {
258
- out[`${key}_chars`] = nested.length
259
- continue
260
- }
261
- if (key === 'argv' && Array.isArray(nested)) {
262
- out.flags = argvFlagNames(
263
- nested.filter((token): token is string => typeof token === 'string'),
264
- )
265
- out.argv_count = nested.length
266
- continue
267
- }
268
- if (CONTENT_LIST_KEYS.has(key) && Array.isArray(nested)) {
269
- out[`${key}_count`] = nested.length
270
- continue
271
- }
272
- out[key] = shape(nested, depth + 1)
273
- }
274
- return out
275
- }
276
- return String(value)
277
- }
278
-
279
- /**
280
- * A record as one line, guaranteed to fit a single write. An oversized payload
281
- * loses its data rather than its line: knowing an event happened and that its
282
- * detail was too large beats losing the event.
283
- */
284
- export function serialize(record: LogRecord): string {
285
- const line = `${JSON.stringify(record)}\n`
286
- if (Buffer.byteLength(line) <= MAX_RECORD_BYTES) return line
287
- const trimmed: LogRecord = {
288
- ...record,
289
- data: { dropped: 'record exceeded the per-line cap', bytes: Buffer.byteLength(line) },
290
- }
291
- return `${JSON.stringify(trimmed)}\n`
292
- }
293
-
294
- // ---------------------------------------------------------------------------
295
- // The logger
296
- // ---------------------------------------------------------------------------
297
-
298
- export interface LogSettings {
299
- level: LogLevel
300
- maxBytes: number
301
- /** Files kept in total, active included. */
302
- maxFiles: number
303
- }
304
-
305
- export interface LoggerBindings {
306
- cmd?: string
307
- project?: string | null
308
- session?: string | null
309
- }
310
-
311
- export interface Logger {
312
- /** Correlation id for this invocation; print it in error messages. */
313
- readonly runId: string
314
- /** False when the level is `off` or the sink has failed. */
315
- readonly enabled: boolean
316
- error(event: LogEvent, data?: Record<string, unknown>): void
317
- info(event: LogEvent, data?: Record<string, unknown>): void
318
- debug(event: LogEvent, data?: Record<string, unknown>): void
319
- /** Attach fields to every later record: command, project, session. */
320
- bind(fields: LoggerBindings): void
321
- /** Adopt settings from a config resolved later (a hook's project, not ours). */
322
- adopt(settings: Partial<LogSettings>): void
323
- }
324
-
325
- /** Everything the sink needs, so tests can drive it without a clock or a disk. */
326
- export interface LoggerOptions {
327
- env?: NodeJS.ProcessEnv
328
- settings?: Partial<LogSettings>
329
- now?: () => number
330
- cmd?: string
331
- runId?: string
332
- }
333
-
334
- export const DEFAULT_LOG_SETTINGS: LogSettings = {
335
- level: 'info',
336
- maxBytes: 2_000_000,
337
- maxFiles: 3,
338
- }
339
-
340
- /** A logger that records nothing, for tests and for `log_level = off`. */
341
- export function nullLogger(runId = 'r_none'): Logger {
342
- return {
343
- runId,
344
- enabled: false,
345
- error: () => undefined,
346
- info: () => undefined,
347
- debug: () => undefined,
348
- bind: () => undefined,
349
- adopt: () => undefined,
350
- }
351
- }
352
-
353
- export function newRunId(): string {
354
- return `r_${randomBytes(6).toString('hex')}`
355
- }
356
-
357
- export function createLogger(options: LoggerOptions = {}): Logger {
358
- const env = options.env ?? process.env
359
- const now = options.now ?? Date.now
360
- const runId = options.runId ?? newRunId()
361
- const settings: LogSettings = { ...DEFAULT_LOG_SETTINGS, ...options.settings }
362
- const bindings: LoggerBindings = { cmd: options.cmd ?? 'notifai' }
363
-
364
- const dir = logsDir(env)
365
- const active = activeLogPath(env)
366
- const lock = `${active}.lock`
367
- let broken = false
368
-
369
- function currentSize(): number {
370
- try {
371
- return statSync(active).size
372
- } catch {
373
- return 0
374
- }
375
- }
376
-
377
- /**
378
- * Keep the inode handoff and retention prune inside the caller's byte-budget
379
- * transaction, so every writer agrees which history the fresh file replaces.
380
- */
381
- function rotate(): string | null {
382
- const target = path.join(dir, archiveName(now()))
383
- try {
384
- renameSync(active, target)
385
- } catch (err) {
386
- const code = (err as NodeJS.ErrnoException).code
387
- if (code !== 'ENOENT') throw err
388
- return null
389
- }
390
- const keep = Math.max(0, settings.maxFiles - 1)
391
- const archives = archiveLogPaths(env)
392
- for (const stale of archives.slice(0, Math.max(0, archives.length - keep))) {
393
- try {
394
- unlinkSync(stale)
395
- } catch {
396
- // An external cleaner removed it; the retained-file bound still holds.
397
- }
398
- }
399
- return path.basename(target)
400
- }
401
-
402
- function write(level: RecordLevel, event: LogEvent, data?: Record<string, unknown>): void {
403
- if (broken || LEVEL_RANK[settings.level] < LEVEL_RANK[level]) return
404
- try {
405
- const record: LogRecord = {
406
- v: LOG_SCHEMA_VERSION,
407
- ts: new Date(now()).toISOString(),
408
- level,
409
- event,
410
- run: runId,
411
- cmd: bindings.cmd ?? 'notifai',
412
- pid: process.pid,
413
- ...(bindings.project ? { project: bindings.project } : {}),
414
- ...(bindings.session ? { session: bindings.session } : {}),
415
- ...(data === undefined ? {} : { data: shape(data) as Record<string, unknown> }),
416
- }
417
- const line = serialize(record)
418
- const bytes = Buffer.byteLength(line)
419
- mkdirSync(dir, { recursive: true, mode: 0o700 })
420
- withFileLock(lock, () => {
421
- let payload = line
422
- if (currentSize() + bytes > settings.maxBytes) {
423
- const rotated = rotate()
424
- if (rotated !== null) {
425
- const marker = serialize({
426
- ...record,
427
- event: 'log.rotated',
428
- data: { archived_to: rotated },
429
- })
430
- // A marker is diagnostic, not permission to violate the cap. The
431
- // product configuration cannot make this branch drop it — maxBytes
432
- // is at least 64 KB and each line is at most 8 KB — but tiny injected
433
- // test settings may not have room for both atomic records.
434
- if (Buffer.byteLength(marker) + bytes <= settings.maxBytes) payload = marker + line
435
- }
436
- }
437
- // One record cannot be split without corrupting JSONL. If an internal
438
- // caller injects maxBytes below that record's size, this one-record file
439
- // is the only unavoidable overshoot. Valid CLI configuration makes the
440
- // invariant unreachable (64 KB minimum versus an 8 KB record cap).
441
- appendFileSync(active, payload, { mode: 0o600, flag: 'a' })
442
- })
443
- } catch {
444
- // One failure is enough: a sink that cannot write will not start working
445
- // mid-command, and retrying it on every event would cost the hook path
446
- // real time for nothing.
447
- broken = true
448
- }
449
- }
450
-
451
- return {
452
- runId,
453
- get enabled() {
454
- return !broken && settings.level !== 'off'
455
- },
456
- error: (event, data) => write('error', event, data),
457
- info: (event, data) => write('info', event, data),
458
- debug: (event, data) => write('debug', event, data),
459
- bind: (fields) => Object.assign(bindings, fields),
460
- adopt: (next) => Object.assign(settings, next),
461
- }
462
- }
463
-
464
- /** Log settings as the layered config resolved them. */
465
- export function logSettingsFrom(config: CliConfig): LogSettings {
466
- return {
467
- level: config.log_level.value,
468
- maxBytes: config.log_max_bytes.value,
469
- maxFiles: config.log_max_files.value,
470
- }
471
- }
472
-
473
- const lastResolvedConfig = new WeakMap<Logger, string>()
474
-
475
- /** Record each distinct resolution once per invocation when debug logging is on. */
476
- export function logConfigResolved(logger: Logger, config: CliConfig): void {
477
- const values = Object.fromEntries(
478
- CONFIG_KEYS.map((key) => [
479
- key,
480
- { value: config[key].value, source: config[key].source },
481
- ]),
482
- )
483
- const fingerprint = JSON.stringify(values)
484
- if (lastResolvedConfig.get(logger) === fingerprint) return
485
- lastResolvedConfig.set(logger, fingerprint)
486
- logger.debug('config.resolved', { values })
487
- }
488
-
489
- /**
490
- * The logger for a CLI invocation, configured from disk.
491
- *
492
- * It loads config itself rather than being handed one, because the earliest
493
- * thing worth logging is the command starting — which is before any command has
494
- * resolved anything, and sometimes before a config file that fails to parse has
495
- * been discovered. A config that throws leaves the defaults in place; it is
496
- * reported by the command that needed it, not silently swallowed here.
497
- */
498
- export function bootstrapLogger(options: { env?: NodeJS.ProcessEnv; cwd?: string } = {}): Logger {
499
- const env = options.env ?? process.env
500
- let settings: Partial<LogSettings> = {}
501
- let project: string | null = null
502
- try {
503
- const config = loadConfig({ cwd: options.cwd ?? process.cwd(), env })
504
- settings = logSettingsFrom(config)
505
- project = config.project.value
506
- } catch {
507
- // Defaults, and the command reports the parse failure in its own words.
508
- }
509
- // Keep a real, mutable logger even when this bootstrap directory says off.
510
- // Harness payloads can resolve a more-specific project or session later and
511
- // adopt settings that legitimately re-enable the same invocation.
512
- const logger = createLogger({ env, settings })
513
- if (project !== null) logger.bind({ project })
514
- return logger
515
- }
516
-
517
- // ---------------------------------------------------------------------------
518
- // Reading
519
- // ---------------------------------------------------------------------------
520
-
521
- export interface LogQuery {
522
- /** Newest N matching records. Bounded on purpose. */
523
- limit?: number
524
- /** Epoch milliseconds; records older than this are not returned. */
525
- since?: number
526
- /** Minimum severity, e.g. `error` for failures only. */
527
- level?: LogLevel
528
- event?: readonly string[]
529
- run?: string
530
- session?: string
531
- project?: string
532
- /** Matches a notification request id exactly in structured event data. */
533
- request?: string
534
- /** Free-text match over the serialized record. */
535
- contains?: string
536
- }
537
-
538
- export interface LogReadResult {
539
- /** Oldest first, so the result reads as a narrative and ends with `tail`. */
540
- records: LogRecord[]
541
- /** Files consulted, newest first. */
542
- files: string[]
543
- /** True when the limit cut the result short — there is more history behind it. */
544
- more: boolean
545
- }
546
-
547
- function containsExactString(value: unknown, expected: string): boolean {
548
- if (typeof value === 'string') return value === expected
549
- if (Array.isArray(value)) return value.some((item) => containsExactString(item, expected))
550
- return false
551
- }
552
-
553
- const REQUEST_ID_KEYS = new Set(['request_id', 'request_ids', 'retires_request_id'])
554
-
555
- /** Match identity fields, never prose that happens to mention the same token. */
556
- function containsRequestIdentity(value: unknown, expected: string): boolean {
557
- if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
558
- for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
559
- if (REQUEST_ID_KEYS.has(key) && containsExactString(nested, expected)) return true
560
- if (containsRequestIdentity(nested, expected)) return true
561
- }
562
- return false
563
- }
564
-
565
- function matches(record: LogRecord, raw: string, query: LogQuery): boolean {
566
- if (query.level !== undefined && LEVEL_RANK[record.level] > LEVEL_RANK[query.level]) return false
567
- if (query.event !== undefined && query.event.length > 0 && !query.event.includes(record.event)) return false
568
- if (query.run !== undefined && record.run !== query.run) return false
569
- if (query.session !== undefined && record.session !== query.session) return false
570
- if (query.project !== undefined && record.project !== query.project) return false
571
- if (query.request !== undefined && !containsRequestIdentity(record.data, query.request)) return false
572
- if (query.contains !== undefined && !raw.toLowerCase().includes(query.contains.toLowerCase())) return false
573
- return true
574
- }
575
-
576
- function isRecord(parsed: unknown): parsed is LogRecord {
577
- return (
578
- typeof parsed === 'object' &&
579
- parsed !== null &&
580
- (parsed as { v?: unknown }).v === LOG_SCHEMA_VERSION &&
581
- typeof (parsed as { event?: unknown }).event === 'string' &&
582
- typeof (parsed as { ts?: unknown }).ts === 'string'
583
- )
584
- }
585
-
586
- /**
587
- * Newest matching records, walking the active file and then the archives
588
- * backwards and stopping as soon as the limit is met.
589
- *
590
- * A partially written or corrupt line is skipped rather than fatal. The point
591
- * of a line-oriented log is that damage stays on its own line, and a reader
592
- * that throws on one bad byte throws away everything else that was fine.
593
- */
594
- export function readLogRecords(
595
- env: NodeJS.ProcessEnv = process.env,
596
- query: LogQuery = {},
597
- ): LogReadResult {
598
- const limit = query.limit ?? 50
599
- const files = [activeLogPath(env), ...archiveLogPaths(env).reverse()]
600
- const collected: LogRecord[] = []
601
- const consulted: string[] = []
602
- let more = false
603
-
604
- for (const file of files) {
605
- if (collected.length >= limit) {
606
- more = true
607
- break
608
- }
609
- let contents: string
610
- try {
611
- contents = readFileSync(file, 'utf8')
612
- } catch {
613
- continue
614
- }
615
- consulted.push(file)
616
- const lines = contents.split('\n')
617
- let reachedFloor = false
618
- for (let index = lines.length - 1; index >= 0; index -= 1) {
619
- const raw = lines[index]
620
- if (raw === undefined || raw.trim() === '') continue
621
- let parsed: unknown
622
- try {
623
- parsed = JSON.parse(raw)
624
- } catch {
625
- continue
626
- }
627
- if (!isRecord(parsed)) continue
628
- if (query.since !== undefined && Date.parse(parsed.ts) < query.since) {
629
- // Files are chronological, so everything behind this is older too.
630
- reachedFloor = true
631
- break
632
- }
633
- if (!matches(parsed, raw, query)) continue
634
- if (collected.length >= limit) {
635
- more = true
636
- break
637
- }
638
- collected.push(parsed)
639
- }
640
- if (reachedFloor) break
641
- }
642
-
643
- return { records: collected.reverse(), files: consulted, more }
644
- }
645
-
646
- /** Bytes currently held by the logs, for `doctor` and for `logs --path`. */
647
- export function logsDiskUsage(env: NodeJS.ProcessEnv = process.env): { files: number; bytes: number } {
648
- let bytes = 0
649
- let files = 0
650
- for (const file of [activeLogPath(env), ...archiveLogPaths(env)]) {
651
- try {
652
- bytes += statSync(file).size
653
- files += 1
654
- } catch {
655
- // Not there; it simply does not count.
656
- }
657
- }
658
- return { files, bytes }
659
- }
660
-
661
- // ---------------------------------------------------------------------------
662
- // Rendering
663
- // ---------------------------------------------------------------------------
664
-
665
- const LEVEL_MARK: Record<RecordLevel, string> = { error: '!', info: ' ', debug: '·' }
666
-
667
- /** `key=value` pairs for the parts of `data` worth a glance, bounded in width. */
668
- function summarize(data: Record<string, unknown> | undefined, width: number): string {
669
- if (data === undefined) return ''
670
- const parts: string[] = []
671
- let used = 0
672
- for (const [key, value] of Object.entries(data)) {
673
- if (value === null || value === undefined) continue
674
- const rendered =
675
- typeof value === 'object' ? JSON.stringify(value) : String(value)
676
- const flattened = rendered.replace(/\s+/g, ' ')
677
- const piece = `${key}=${flattened.length > 60 ? `${flattened.slice(0, 60)}…` : flattened}`
678
- if (used + piece.length > width) {
679
- parts.push('…')
680
- break
681
- }
682
- parts.push(piece)
683
- used += piece.length + 1
684
- }
685
- return parts.join(' ')
686
- }
687
-
688
- /**
689
- * One record, one line. The fixed left columns matter more than they look:
690
- * aligned columns are what let a reader — human or model — scan for the one
691
- * line that differs instead of reading every line.
692
- */
693
- export function renderRecord(record: LogRecord): string {
694
- const when = record.ts.replace('T', ' ').replace(/\.\d+Z$/, '')
695
- const event = record.event.padEnd(15)
696
- const summary = summarize(record.data, 110)
697
- return `${when} ${LEVEL_MARK[record.level]} ${event} ${summary}`.trimEnd()
698
- }