ai-notify 0.7.1 → 0.9.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/README.ja.md CHANGED
@@ -121,6 +121,7 @@ ai-notify menubar install # ネイティブのメニューバーアプリ・
121
121
 
122
122
  - **左クリック** → メニュー:**音量スライダー**、**ツンデレ**トグル+デレ⇄ツンスライダー、**声の一覧**(システム+VOICEVOX)、**ペイン別**設定。開いている各ターミナルに、**読み上げ名**(どのペインが終わったか声で分かる)・**声**・**音量**を個別設定でき、各行にそのペインの声が一覧表示されます。
123
123
  - **右クリック** → 即ミュート切替。
124
+ - **⚙ 設定…** → **整列したスライダー+編集可能な数値フィールド**(音量・ツンデレ・戦争・速さ/高さ/抑揚)と**プリセット保存**(`ai-notify preset save <名前>` / `load` / `delete`)の設定ウィンドウ。毎回調整し直さなくて済みます。
124
125
 
125
126
  <p>
126
127
  <img alt="ai-notify メニュー — 音量・ツンデレ・声" src="https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/menubar.png" width="250">
@@ -205,6 +206,19 @@ ai-notify tsundere test # T3/T2/T1/T0 のサンプルを試聴
205
206
 
206
207
  **無API・決定論・オフライン**(テンプレートで生成。課金ゼロ)。緊急度はエージェントの文面からのキーワード推定(厳密な重大度ではなくベストエフォート)で、デスクトップ通知は素の文面のまま。**VOICEVOX**利用時は、強さに応じて同じキャラの**ツンツン/あまあま**スタイルを選ぶので、声色そのものがツン・デレに変わります。`lang` は `ja` / `en` 対応。
207
208
 
209
+ ## ⚔️ 戦争モード(任意・遊び心)
210
+
211
+ ツンデレとは別の読み上げスキン。**作戦司令室**を演じます。レベルで状況が変わり、**ツンデレレベル(=オペレーターの好感度)との組合せ**で台詞が変化します:
212
+
213
+ ```sh
214
+ ai-notify war on
215
+ ai-notify war level 0.5 # 0 = 平時 ・ 0.5 = 戦闘中/第一種戦闘配置 ・ 1 = 危機(短く絶叫)
216
+ ai-notify war test
217
+ ```
218
+
219
+ - **平時** — 落ち着いた無線。**戦闘中** — 第一種戦闘配置、緊迫。**危機** — 短い絶叫、音量↑・速度↑。
220
+ - **ツンデレレベルが各段を味付け**(デレ=優しいオペレーター/ツン=厳しいオペレーター)。戦争×ツンデレで9通りの空気感。メニューバーにもトグル+スライダーあり。(ツンデレモード自体はOFFでも、そのスライダーは戦争モードの好感度入力として機能します。)
221
+
208
222
  ## ⏳ どの窓が・何を求めているか
209
223
 
210
224
  各通知のタイトルに窓ラベルが付きます — 入力待ちは `⏳ <label>`、完了は `✓ <label>`。本文には**何を**(翻訳されたプロンプト、または作業内容の要約)が出ます。各ペインに短い `AI_NOTIFY_LABEL` を設定すれば、10個のターミナルもひと目で見分けられます。
package/README.md CHANGED
@@ -122,6 +122,7 @@ A monochrome waveform icon shows status by color (Adobe-style): plain when idle,
122
122
 
123
123
  - **Left-click** → menu: a **volume slider**, a **tsundere** toggle + デレ⇄ツン slider, the **voice list** (system + VOICEVOX), and **per-pane** controls. Each open terminal gets its own **spoken name** (read out so you know *which* pane finished), **voice**, and **volume** — the row shows each pane's voice at a glance.
124
124
  - **Right-click** → instant mute toggle.
125
+ - **⚙ 設定…** → a settings window with **aligned sliders + editable numeric fields** (volume, tsundere, war, speed/pitch/intonation) and **saveable presets** (`ai-notify preset save <name>` / `load` / `delete`), so you don't re-tune every time.
125
126
 
126
127
  <p>
127
128
  <img alt="ai-notify menu — volume, tsundere, and voice controls" src="https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/menubar.png" width="250">
@@ -206,6 +207,19 @@ ai-notify tsundere test # hear T3/T2/T1/T0 samples
206
207
 
207
208
  It's **deterministic and offline** — phrase banks, no API, no cost. The urgency is a keyword heuristic over the agent's text (so it's best-effort, not a real severity signal), and the desktop banner stays factual. With **VOICEVOX** the level also picks the character's own **ツンツン / あまあま** style, so the same character actually *sounds* harsher or sweeter. `lang` supports `ja` and `en`.
208
209
 
210
+ ## ⚔️ War mode (optional, fun)
211
+
212
+ A separate read-out skin: a **military ops room**. The level sets the situation, and — combined with the tsundere level (the operator's 好感度) — picks the line:
213
+
214
+ ```sh
215
+ ai-notify war on
216
+ ai-notify war level 0.5 # 0 = 平時 (calm) · 0.5 = 戦闘中 / 第一種戦闘配置 · 1 = 危機 (short, shouted)
217
+ ai-notify war test
218
+ ```
219
+
220
+ - **平時** — calm radio chatter. **戦闘中** — general quarters, urgent. **危機** — short shouts, louder and faster.
221
+ - The **tsundere level flavors every band** (a warm デレ operator vs a harsh ツン one), so war × tsundere gives 9 distinct moods. Toggle + slider are in the menu bar too. (Tsundere mode itself can be off; its slider still acts as the affection input for war.)
222
+
209
223
  ## ⏳ Which window, and what it's asking
210
224
 
211
225
  Each notification is titled with the window label — `⏳ <label>` when an agent is waiting, `✓ <label>` when it's done — and the body says **what** (the translated prompt, or a summary of what was done). Set a short `AI_NOTIFY_LABEL` per pane and you can tell ten terminals apart at a glance.
@@ -275,9 +275,200 @@ final class PopupCard: NSObject {
275
275
  }
276
276
  }
277
277
 
278
+ // One settings row: a label (or checkbox) + a slider + an editable numeric field,
279
+ // kept on a single shared grid so every row lines up. The slider and the field
280
+ // stay in sync; both call `onChange`.
281
+ final class SettingsRow: NSObject {
282
+ let view = NSView(frame: NSRect(x: 0, y: 0, width: 470, height: 32))
283
+ private let slider: NSSlider
284
+ private let field = NSTextField()
285
+ private let lo: Double
286
+ private let hi: Double
287
+ private let onChange: (Double) -> Void
288
+ private let onToggle: (() -> Void)?
289
+
290
+ init(title: String, asCheckbox: Bool, on: Bool, lo: Double, hi: Double, value: Double,
291
+ fill: NSColor, onToggle: (() -> Void)? = nil, onChange: @escaping (Double) -> Void) {
292
+ self.lo = lo; self.hi = hi; self.onChange = onChange; self.onToggle = onToggle
293
+ slider = NSSlider(value: value, minValue: lo, maxValue: hi, target: nil, action: nil)
294
+ super.init()
295
+
296
+ if asCheckbox {
297
+ let cb = NSButton(checkboxWithTitle: title, target: self, action: #selector(toggled))
298
+ cb.frame = NSRect(x: 16, y: 6, width: 106, height: 20)
299
+ cb.state = on ? .on : .off
300
+ view.addSubview(cb)
301
+ } else {
302
+ let lbl = NSTextField(labelWithString: title)
303
+ lbl.frame = NSRect(x: 16, y: 7, width: 106, height: 18)
304
+ lbl.textColor = .labelColor
305
+ view.addSubview(lbl)
306
+ }
307
+ // Unified grid: slider always at the same x/width, field always after it.
308
+ slider.frame = NSRect(x: 128, y: 5, width: 250, height: 20)
309
+ slider.target = self; slider.action = #selector(sliderMoved)
310
+ slider.isContinuous = true
311
+ slider.trackFillColor = fill
312
+ view.addSubview(slider)
313
+
314
+ field.frame = NSRect(x: 392, y: 5, width: 56, height: 20)
315
+ field.alignment = .right
316
+ field.target = self; field.action = #selector(fieldEdited)
317
+ view.addSubview(field)
318
+ setField(value)
319
+ }
320
+
321
+ private func setField(_ v: Double) { field.stringValue = String(format: "%.2f", v) }
322
+ @objc private func toggled() { onToggle?() }
323
+ @objc private func sliderMoved() { setField(slider.doubleValue); onChange(slider.doubleValue) }
324
+ @objc private func fieldEdited() {
325
+ var v = Double(field.stringValue) ?? slider.doubleValue
326
+ v = min(hi, max(lo, v))
327
+ slider.doubleValue = v; setField(v); onChange(v)
328
+ }
329
+ }
330
+
331
+ // The settings window: aligned sliders + editable numeric fields for volume,
332
+ // tsundere, war, and the VOICEVOX prosody, plus a saveable preset bar.
333
+ final class SettingsWindowController: NSObject {
334
+ private var window: NSWindow?
335
+ private var presetPopup: NSPopUpButton?
336
+ var windowNumber: Int { window?.windowNumber ?? 0 }
337
+
338
+ func show() {
339
+ if window == nil { build() }
340
+ reloadValues()
341
+ NSApp.activate(ignoringOtherApps: true)
342
+ window?.center()
343
+ window?.makeKeyAndOrderFront(nil)
344
+ window?.orderFrontRegardless()
345
+ }
346
+
347
+ private func menuJSON() -> [String: Any] {
348
+ (State.cli(["menu-json"], capture: true)?.data(using: .utf8))
349
+ .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } ?? [:]
350
+ }
351
+
352
+ private func build() {
353
+ let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 470, height: 360),
354
+ styleMask: [.titled, .closable], backing: .buffered, defer: false)
355
+ w.title = "ai-notify 設定"
356
+ w.isReleasedWhenClosed = false
357
+ window = w
358
+ rebuildContent()
359
+ }
360
+
361
+ // Re-create the rows from the current state (also used after loading a preset).
362
+ private func rebuildContent() {
363
+ guard let w = window else { return }
364
+ let j = menuJSON()
365
+ let content = NSView(frame: NSRect(x: 0, y: 0, width: 470, height: 360))
366
+
367
+ // Preset bar.
368
+ let presetLabel = NSTextField(labelWithString: "プリセット")
369
+ presetLabel.frame = NSRect(x: 16, y: 322, width: 70, height: 18)
370
+ content.addSubview(presetLabel)
371
+ let popup = NSPopUpButton(frame: NSRect(x: 92, y: 318, width: 180, height: 26))
372
+ for name in presetNames() { popup.addItem(withTitle: name) }
373
+ if popup.numberOfItems == 0 { popup.addItem(withTitle: "(なし)"); popup.isEnabled = false }
374
+ content.addSubview(popup)
375
+ presetPopup = popup
376
+ let apply = NSButton(title: "適用", target: self, action: #selector(applyPreset)); apply.frame = NSRect(x: 278, y: 318, width: 52, height: 26); apply.bezelStyle = .rounded; content.addSubview(apply)
377
+ let save = NSButton(title: "保存…", target: self, action: #selector(savePreset)); save.frame = NSRect(x: 332, y: 318, width: 60, height: 26); save.bezelStyle = .rounded; content.addSubview(save)
378
+ let del = NSButton(title: "削除", target: self, action: #selector(deletePreset)); del.frame = NSRect(x: 394, y: 318, width: 52, height: 26); del.bezelStyle = .rounded; content.addSubview(del)
379
+
380
+ let sep = NSBox(frame: NSRect(x: 12, y: 306, width: 446, height: 1)); sep.boxType = .separator; content.addSubview(sep)
381
+
382
+ // Parameter rows (top-down).
383
+ let blue = NSColor(srgbRed: 0, green: 122.0 / 255.0, blue: 1, alpha: 1)
384
+ let pink = NSColor.systemPink
385
+ let red = NSColor(srgbRed: 0.85, green: 0.2, blue: 0.15, alpha: 1)
386
+ let tsun = j["tsundere"] as? [String: Any]
387
+ let warj = j["war"] as? [String: Any]
388
+ let pr = j["prosody"] as? [String: Any] ?? [:]
389
+ let range = j["prosodyRange"] as? [String: Any] ?? [:]
390
+ func bound(_ key: String, _ dlo: Double, _ dhi: Double) -> (Double, Double) {
391
+ let r = range[key] as? [Any]
392
+ return ((r?.first as? Double) ?? dlo, (r?.last as? Double) ?? dhi)
393
+ }
394
+ let (slo, shi) = bound("speed", 0.5, 1.5)
395
+ let (plo, phi) = bound("pitch", -0.15, 0.15)
396
+ let (ilo, ihi) = bound("intonation", 0.0, 1.5)
397
+
398
+ let rows: [SettingsRow] = [
399
+ SettingsRow(title: "音量", asCheckbox: false, on: false, lo: 0, hi: 2, value: (j["volume"] as? Double) ?? 1, fill: blue,
400
+ onChange: { State.cli(["volume", String(format: "%.2f", $0)]) }),
401
+ SettingsRow(title: "ツンデレ", asCheckbox: true, on: (tsun?["enabled"] as? Bool) ?? false, lo: 0, hi: 1, value: (tsun?["level"] as? Double) ?? 0.5, fill: pink,
402
+ onToggle: { State.cli(["tsundere", "toggle"]) },
403
+ onChange: { State.cli(["tsundere", "level", String(format: "%.2f", $0)]) }),
404
+ SettingsRow(title: "戦争", asCheckbox: true, on: (warj?["enabled"] as? Bool) ?? false, lo: 0, hi: 1, value: (warj?["level"] as? Double) ?? 0.5, fill: red,
405
+ onToggle: { State.cli(["war", "toggle"]) },
406
+ onChange: { State.cli(["war", "level", String(format: "%.2f", $0)]) }),
407
+ SettingsRow(title: "速さ", asCheckbox: false, on: false, lo: slo, hi: shi, value: (pr["speed"] as? Double) ?? 1, fill: blue,
408
+ onChange: { State.cli(["voice-prosody", "speed", String(format: "%.3f", $0)]) }),
409
+ SettingsRow(title: "高さ", asCheckbox: false, on: false, lo: plo, hi: phi, value: (pr["pitch"] as? Double) ?? 0, fill: blue,
410
+ onChange: { State.cli(["voice-prosody", "pitch", String(format: "%.3f", $0)]) }),
411
+ SettingsRow(title: "抑揚", asCheckbox: false, on: false, lo: ilo, hi: ihi, value: (pr["intonation"] as? Double) ?? 1, fill: blue,
412
+ onChange: { State.cli(["voice-prosody", "intonation", String(format: "%.3f", $0)]) }),
413
+ ]
414
+ var y = 264
415
+ let header = NSTextField(labelWithString: "ツンデレ/戦争は 0=デレ・平時 〜 1=ツン・危機")
416
+ header.frame = NSRect(x: 16, y: 286, width: 440, height: 16)
417
+ header.font = .systemFont(ofSize: 11); header.textColor = .secondaryLabelColor
418
+ content.addSubview(header)
419
+ for r in rows {
420
+ r.view.frame.origin = NSPoint(x: 0, y: CGFloat(y))
421
+ content.addSubview(r.view)
422
+ self.rows.append(r) // retain
423
+ y -= 36
424
+ }
425
+ w.contentView = content
426
+ }
427
+
428
+ private var rows: [SettingsRow] = []
429
+
430
+ private func presetNames() -> [String] {
431
+ let out = State.cli(["preset", "list"], capture: true) ?? ""
432
+ return out.split(separator: "\n").map { String($0).trimmingCharacters(in: .whitespaces) }
433
+ .filter { !$0.isEmpty && $0 != "(no presets)" }
434
+ }
435
+
436
+ private func reloadValues() {
437
+ rows.removeAll()
438
+ rebuildContent()
439
+ }
440
+
441
+ @objc private func applyPreset() {
442
+ guard let name = presetPopup?.titleOfSelectedItem, name != "(なし)" else { return }
443
+ State.cli(["preset", "load", name])
444
+ reloadValues()
445
+ }
446
+ @objc private func deletePreset() {
447
+ guard let name = presetPopup?.titleOfSelectedItem, name != "(なし)" else { return }
448
+ State.cli(["preset", "delete", name])
449
+ reloadValues()
450
+ }
451
+ @objc private func savePreset() {
452
+ let alert = NSAlert()
453
+ alert.messageText = "プリセットを保存"
454
+ alert.informativeText = "現在の音量・ツンデレ・戦争・読み上げ設定を名前を付けて保存します"
455
+ let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24))
456
+ field.placeholderString = "例: 集中モード"
457
+ alert.accessoryView = field
458
+ alert.addButton(withTitle: "保存"); alert.addButton(withTitle: "キャンセル")
459
+ alert.window.initialFirstResponder = field
460
+ guard alert.runModal() == .alertFirstButtonReturn else { return }
461
+ let name = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
462
+ guard !name.isEmpty else { return }
463
+ State.cli(["preset", "save", name])
464
+ reloadValues()
465
+ }
466
+ }
467
+
278
468
  final class AppDelegate: NSObject, NSApplicationDelegate {
279
469
  private var statusItem: NSStatusItem!
280
470
  private var timer: Timer?
471
+ private let settings = SettingsWindowController()
281
472
 
282
473
  // The "waiting for input" popup — one floating card per waiting pane.
283
474
  private var waitingCards: [String: PopupCard] = [:] // keyed by tty
@@ -424,6 +615,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
424
615
 
425
616
  private func toggle() { State.setMuted(!State.isMuted); render() }
426
617
  @objc private func quit() { NSApp.terminate(nil) }
618
+ @objc private func openSettings() { settings.show() }
427
619
 
428
620
  @objc private func volumeChanged(_ s: NSSlider) { State.setVolume(s.doubleValue) }
429
621
  // Slider is shown reversed (left = ツン, right = デレ) but the file keeps the
@@ -552,6 +744,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
552
744
  return item
553
745
  }
554
746
 
747
+ // War mode on/off checkbox + 平時⇄危機 slider (level 0–1). Separate axis from
748
+ // tsundere; the tsundere level flavors it.
749
+ private func warToggleRow(on: Bool) -> NSMenuItem {
750
+ let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 24))
751
+ let btn = NSButton(checkboxWithTitle: "戦争モード", target: self, action: #selector(warToggled(_:)))
752
+ btn.frame = NSRect(x: 12, y: 2, width: 196, height: 20)
753
+ btn.state = on ? .on : .off
754
+ row.addSubview(btn)
755
+ let item = NSMenuItem(); item.view = row
756
+ return item
757
+ }
758
+
759
+ private func warRow(value: Double) -> NSMenuItem {
760
+ let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 26))
761
+ let left = NSTextField(labelWithString: "平時")
762
+ left.frame = NSRect(x: 12, y: 5, width: 30, height: 16)
763
+ left.font = .systemFont(ofSize: 10); left.textColor = .secondaryLabelColor
764
+ let slider = NSSlider(value: value, minValue: 0, maxValue: 1, target: self, action: #selector(warLevelChanged(_:)))
765
+ slider.frame = NSRect(x: 46, y: 3, width: 128, height: 20)
766
+ slider.isContinuous = false
767
+ slider.trackFillColor = NSColor(srgbRed: 0.85, green: 0.2, blue: 0.15, alpha: 1) // war red
768
+ let right = NSTextField(labelWithString: "危機")
769
+ right.frame = NSRect(x: 178, y: 5, width: 30, height: 16)
770
+ right.font = .systemFont(ofSize: 10); right.textColor = .secondaryLabelColor
771
+ row.addSubview(left); row.addSubview(slider); row.addSubview(right)
772
+ let item = NSMenuItem(); item.view = row
773
+ return item
774
+ }
775
+
776
+ @objc private func warToggled(_ b: NSButton) { State.cli(["war", "toggle"]) }
777
+ @objc private func warLevelChanged(_ s: NSSlider) { State.cli(["war", "level", String(format: "%.2f", s.doubleValue)]) }
778
+
555
779
  // representedObject is the full CLI arg array to run.
556
780
  @objc private func runItem(_ item: NSMenuItem) {
557
781
  if let cmd = item.representedObject as? [String] { State.cli(cmd) }
@@ -583,6 +807,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
583
807
  let tsunLevel = (tsun?["level"] as? Double) ?? 0.5
584
808
  menu.addItem(tsundereToggleRow(on: tsunOn))
585
809
  menu.addItem(tsundereRow(value: tsunLevel))
810
+
811
+ // War mode: checkbox + 平時⇄危機 slider (a separate read-out skin).
812
+ let warJson = json?["war"] as? [String: Any]
813
+ menu.addItem(warToggleRow(on: (warJson?["enabled"] as? Bool) ?? false))
814
+ menu.addItem(warRow(value: (warJson?["level"] as? Double) ?? 0.5))
586
815
  menu.addItem(.separator())
587
816
 
588
817
  // VOICEVOX base prosody (speed / pitch / intonation) — only when VOICEVOX
@@ -725,6 +954,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
725
954
  notifyParent.submenu = notifySub
726
955
  menu.addItem(notifyParent)
727
956
 
957
+ menu.addItem(.separator())
958
+ let settingsItem = NSMenuItem(title: "⚙ 設定(スライダー・数値・プリセット)…", action: #selector(openSettings), keyEquivalent: ",")
959
+ settingsItem.target = self
960
+ menu.addItem(settingsItem)
961
+
728
962
  menu.addItem(.separator())
729
963
  let quitItem = NSMenuItem(title: "ai-notify を終了", action: #selector(quit), keyEquivalent: "q")
730
964
  quitItem.target = self
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-notify",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "Desktop, sound, and spoken notifications for terminal AI coding agents (Claude Code, Codex, Gemini, ...) — with one mute switch that covers all of them, across every terminal.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -13,6 +13,7 @@ import { translate } from './translate.mjs';
13
13
  import { diagnose as highlightDiagnose, clearHighlight } from './highlight.mjs';
14
14
  import * as voicevox from './voicevox.mjs';
15
15
  import * as tsundere from './tsundere.mjs';
16
+ import * as war from './war.mjs';
16
17
  import {
17
18
  isMuted,
18
19
  setMuted,
@@ -45,6 +46,10 @@ import {
45
46
  getNotifyKinds,
46
47
  setNotifyKind,
47
48
  isNotifyKindEnabled,
49
+ isWarEnabled,
50
+ setWarEnabled,
51
+ readWarLevel,
52
+ setWarLevel,
48
53
  } from './state.mjs';
49
54
  import { resolve as resolvePath, join as pathJoin } from 'node:path';
50
55
 
@@ -446,6 +451,152 @@ const cmds = {
446
451
  if (!ts.enabled) log('\nEnable: ai-notify tsundere on 試聴: ai-notify tsundere test');
447
452
  },
448
453
 
454
+ // Named presets: snapshot the current volume / prosody / tsundere / war and
455
+ // restore them later, so you don't re-tune every time.
456
+ // preset [list | save <name> | load <name> | delete <name>]
457
+ preset() {
458
+ const sub = positionals[0] || 'list';
459
+ const file = pathJoin(paths.stateDir(), 'presets.json');
460
+ const read = () => {
461
+ try {
462
+ return JSON.parse(readFileSync(file, 'utf8'));
463
+ } catch {
464
+ return {};
465
+ }
466
+ };
467
+ const write = (o) => {
468
+ mkdirSync(paths.stateDir(), { recursive: true });
469
+ writeFileSync(file, JSON.stringify(o, null, 2));
470
+ };
471
+ const name = positionals.slice(1).join(' ').trim();
472
+
473
+ if (sub === 'list') {
474
+ const names = Object.keys(read());
475
+ return log(names.length ? names.join('\n') : '(no presets)');
476
+ }
477
+ if (sub === 'save') {
478
+ if (!name) return void (console.error('usage: preset save <name>'), process.exit(1));
479
+ const config = readConfig();
480
+ const snap = {
481
+ volume: readVolume() != null ? readVolume() : config.volume ?? 1,
482
+ prosody: readVoiceProsody(),
483
+ tsundere: {
484
+ enabled: !!config.tsundere?.enabled,
485
+ level: readTsundereLevel() != null ? readTsundereLevel() : config.tsundere?.level ?? 0.5,
486
+ },
487
+ war: { enabled: isWarEnabled(), level: readWarLevel() },
488
+ };
489
+ const p = read();
490
+ p[name] = snap;
491
+ write(p);
492
+ return log(`💾 saved preset "${name}"`);
493
+ }
494
+ if (sub === 'delete' || sub === 'rm') {
495
+ const p = read();
496
+ if (!(name in p)) return void (console.error(`no preset: ${name}`), process.exit(1));
497
+ delete p[name];
498
+ write(p);
499
+ return log(`deleted "${name}"`);
500
+ }
501
+ if (sub === 'load' || sub === 'apply') {
502
+ const s = read()[name];
503
+ if (!s) return void (console.error(`no preset: ${name}`), process.exit(1));
504
+ if (typeof s.volume === 'number') setVolume(s.volume);
505
+ if (s.prosody) for (const k of ['speed', 'pitch', 'intonation']) if (typeof s.prosody[k] === 'number') setVoiceProsody(k, s.prosody[k]);
506
+ if (s.tsundere) {
507
+ if (typeof s.tsundere.level === 'number') setTsundereLevel(s.tsundere.level);
508
+ const config = readConfig();
509
+ config.tsundere = { ...(config.tsundere || {}), enabled: !!s.tsundere.enabled };
510
+ writeConfig(config);
511
+ }
512
+ if (s.war) {
513
+ setWarEnabled(!!s.war.enabled);
514
+ if (typeof s.war.level === 'number') setWarLevel(s.war.level);
515
+ }
516
+ return log(`▶ loaded preset "${name}"`);
517
+ }
518
+ console.error('usage: preset [list | save <name> | load <name> | delete <name>]');
519
+ process.exit(1);
520
+ },
521
+
522
+ // War mode: a military-ops-room read-out skin. Level: min 平時 / mid 戦闘中 /
523
+ // max 危機的. Combined with the tsundere level for the operator's 好感度.
524
+ // war [on|off|toggle|level <0-1>|test|status]
525
+ war() {
526
+ const sub = positionals[0] || 'status';
527
+ const config = readConfig();
528
+ const ts = config.tsundere || {};
529
+ const url = config.voicevox?.url || voicevox.DEFAULT_URL;
530
+
531
+ if (sub === 'on' || sub === 'off' || sub === 'toggle') {
532
+ const enabled = sub === 'toggle' ? !isWarEnabled() : sub === 'on';
533
+ setWarEnabled(enabled);
534
+ // Cache the VOICEVOX tsun/dere style map so fire-time skips the lookup.
535
+ if (enabled && config.tts === 'voicevox') {
536
+ const sm = voicevox.resolveStyles(config.voicevox?.speaker, url);
537
+ if (sm) {
538
+ config.tsundere = { ...ts, styleMap: sm };
539
+ writeConfig(config);
540
+ }
541
+ }
542
+ log(enabled ? '⚔️ 戦争モード ON(平時⇄戦闘⇄危機・ツンデレ好感度で口調変化)' : '戦争モード OFF');
543
+ if (enabled) {
544
+ log(' レベル: ai-notify war level <0=平時 〜 0.5=戦闘 〜 1=危機>');
545
+ log(' 試聴: ai-notify war test');
546
+ }
547
+ return;
548
+ }
549
+ if (sub === 'level') {
550
+ const arg = positionals[1];
551
+ if (arg === undefined) return log(`war level: ${readWarLevel()} (0=平時 〜 0.5=戦闘 〜 1=危機)`);
552
+ return log(`⚔️ war level → ${setWarLevel(arg)} (0=平時 〜 0.5=戦闘 〜 1=危機)`);
553
+ }
554
+ if (sub === 'test') {
555
+ const lang = ts.lang || 'ja';
556
+ const level = readWarLevel();
557
+ const aff = readTsundereLevel() != null ? readTsundereLevel() : ts.level ?? 0.5;
558
+ const sm = config.tts === 'voicevox' ? ts.styleMap || voicevox.resolveStyles(config.voicevox?.speaker, url) : null;
559
+ log(`war test (level ${level} = ${war.band(level)}, 好感度 ${aff}, lang ${lang}):\n`);
560
+ const rows =
561
+ lang === 'ja'
562
+ ? [
563
+ { tier: 'T3', body: 'ビルドが失敗' },
564
+ { tier: 'T2', body: '許可待ち' },
565
+ { tier: 'T1', body: '3ファイルを更新' },
566
+ { tier: 'T0', body: 'テスト全部パス' },
567
+ ]
568
+ : [
569
+ { tier: 'T3', body: 'the build failed' },
570
+ { tier: 'T2', body: 'waiting for approval' },
571
+ { tier: 'T1', body: 'updated 3 files' },
572
+ { tier: 'T0', body: 'all tests passed' },
573
+ ];
574
+ for (const s of rows) {
575
+ const eff = tsundere.effectiveLevel(aff, s.tier, ts.urgencyShift !== false);
576
+ const text = war.wrap(s.body, level, eff, lang, 0);
577
+ const mul = war.volumeMul(level, s.tier);
578
+ const tone = tsundere.axisFor(eff);
579
+ log(` [${s.tier} ×${mul.toFixed(2)} ${tone}] ${text}`);
580
+ if (sm) {
581
+ const speaker = sm[tone] ?? config.voicevox?.speaker;
582
+ voicevox.speak(text, speaker, url, mul, undefined, war.effectiveProsody(level, tsundere.effectiveProsody(tone, readVoiceProsody())));
583
+ } else {
584
+ try {
585
+ execFileSync('say', config.voice ? ['-v', config.voice, tsundere.decorateForSay(text, tone)] : [tsundere.decorateForSay(text, tone)], { stdio: 'ignore' });
586
+ } catch {
587
+ /* non-mac / no say */
588
+ }
589
+ }
590
+ }
591
+ return;
592
+ }
593
+ // status
594
+ log(`war mode: ${isWarEnabled() ? '⚔️ ON' : 'OFF'}`);
595
+ log(` level: ${readWarLevel()} → ${war.band(readWarLevel())} (0=平時 〜 0.5=戦闘 〜 1=危機)`);
596
+ log(` 好感度 (tsundere level): ${readTsundereLevel() != null ? readTsundereLevel() : ts.level ?? 0.5}`);
597
+ if (!isWarEnabled()) log('\nEnable: ai-notify war on 試聴: ai-notify war test');
598
+ },
599
+
449
600
  // Assign a voice to a specific pane (by tty), from the menu bar.
450
601
  // voice-pane <tty> voicevox <id> | say <name> | clear
451
602
  'voice-pane'() {
@@ -786,6 +937,7 @@ const cmds = {
786
937
  voices,
787
938
  panes,
788
939
  tsundere: { enabled: !!config.tsundere?.enabled, level: tsLevel },
940
+ war: { enabled: isWarEnabled(), level: readWarLevel() },
789
941
  tts: config.tts || 'say',
790
942
  prosody: readVoiceProsody(),
791
943
  prosodyRange: VOICE_PROSODY_RANGE,
@@ -948,6 +1100,8 @@ Usage:
948
1100
  ai-notify voice [number|name|preview|default] pick the spoken voice
949
1101
  ai-notify voicevox [setup|on <id>|off|speakers|test] speak in VOICEVOX character voices
950
1102
  ai-notify tsundere [on|off|level <0-1>|test|status] tsundere persona (ツン⇄デレ by urgency)
1103
+ ai-notify war [on|off|level <0-1>|test|status] war mode (平時⇄戦闘⇄危機; tsundere level = 好感度)
1104
+ ai-notify preset [list|save <name>|load <name>|delete <name>] save/restore volume+tsundere+war+prosody
951
1105
  ai-notify voice-prosody [speed|pitch|intonation <v>|reset] VOICEVOX read-out tuning
952
1106
  ai-notify menubar [install|uninstall|status] native menu bar bell (macOS)
953
1107
  ai-notify notify [<kind> on|off] which events alert: input|permission|info|done|subagent-done
package/src/notify.mjs CHANGED
@@ -18,12 +18,15 @@ import {
18
18
  readTsundereLevel,
19
19
  readVoiceProsody,
20
20
  nextCounter,
21
+ isWarEnabled,
22
+ readWarLevel,
21
23
  } from './state.mjs';
22
24
  import { controllingTty } from './util.mjs';
23
25
  import { translate } from './translate.mjs';
24
26
  import { highlightWaiting, clearHighlight } from './highlight.mjs';
25
27
  import * as voicevox from './voicevox.mjs';
26
28
  import * as tsundere from './tsundere.mjs';
29
+ import * as war from './war.mjs';
27
30
 
28
31
  const platform = process.platform; // 'darwin' | 'linux' | 'win32'
29
32
 
@@ -234,29 +237,44 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
234
237
  let outText = speakText;
235
238
  let outVol = vol;
236
239
  let outSpeaker = speaker;
237
- let speakTone = 'normal'; // delivery contour; tsundere sets it to tsun/dere
240
+ let speakTone = 'normal'; // delivery contour; tsundere/war set it to tsun/dere
241
+ let warActive = false;
242
+ let warLevel = 0.5;
238
243
  const ts = config.tsundere;
239
- if (ts && ts.enabled) {
240
- const tier = tsundere.classifyUrgency(event, message, fullBody);
244
+ const tier = tsundere.classifyUrgency(event, message, fullBody);
245
+ // The tsundere LEVEL doubles as the operator's 好感度 for war mode, so resolve
246
+ // it even when tsundere skinning is off.
247
+ const tsBaseLevel = (() => {
241
248
  const envLevel = parseFloat(process.env.AI_NOTIFY_TSUNDERE_LEVEL);
242
- const baseLevel = Number.isFinite(envLevel)
243
- ? Math.min(1, Math.max(0, envLevel))
244
- : typeof pane.tsundere === 'number'
245
- ? pane.tsundere
246
- : readTsundereLevel() != null
247
- ? readTsundereLevel()
248
- : typeof ts.level === 'number'
249
- ? ts.level
250
- : 0.5;
251
- const eff = tsundere.effectiveLevel(baseLevel, tier, ts.urgencyShift !== false);
249
+ if (Number.isFinite(envLevel)) return Math.min(1, Math.max(0, envLevel));
250
+ if (typeof pane.tsundere === 'number') return pane.tsundere;
251
+ const f = readTsundereLevel();
252
+ if (f != null) return f;
253
+ return typeof ts?.level === 'number' ? ts.level : 0.5;
254
+ })();
255
+
256
+ if (isWarEnabled()) {
257
+ // War mode skins the read-out (ops room); the tsundere level flavors it.
258
+ warActive = true;
259
+ warLevel = readWarLevel();
260
+ const eff = tsundere.effectiveLevel(tsBaseLevel, tier, ts?.urgencyShift !== false);
261
+ speakTone = tsundere.axisFor(eff);
262
+ outVol = Math.min(2, Math.max(0, vol * war.volumeMul(warLevel, tier)));
263
+ outText = war.wrap(spokenBody, warLevel, eff, ts?.lang || 'ja', nextCounter('war'));
264
+ if (spokenName) outText = joinName(spokenName, outText);
265
+ if (tts === 'voicevox') {
266
+ const sm = ts?.styleMap || voicevox.resolveStyles(outSpeaker, config.voicevox?.url);
267
+ if (sm && sm[speakTone] != null) outSpeaker = sm[speakTone];
268
+ }
269
+ } else if (ts && ts.enabled) {
270
+ const eff = tsundere.effectiveLevel(tsBaseLevel, tier, ts.urgencyShift !== false);
252
271
  speakTone = tsundere.axisFor(eff);
253
272
  outVol = Math.min(2, Math.max(0, vol * tsundere.volumeMul(tier, ts.volumeBoost !== false)));
254
273
  outText = tsundere.wrap(spokenBody, eff, tier, ts.lang || 'ja', nextCounter('tsundere'));
255
274
  if (spokenName) outText = joinName(spokenName, outText);
256
275
  if (tts === 'voicevox') {
257
276
  const sm = ts.styleMap || voicevox.resolveStyles(outSpeaker, config.voicevox?.url);
258
- const axis = tsundere.axisFor(eff);
259
- if (sm && sm[axis] != null) outSpeaker = sm[axis];
277
+ if (sm && sm[speakTone] != null) outSpeaker = sm[speakTone];
260
278
  }
261
279
  }
262
280
 
@@ -265,7 +283,8 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
265
283
  if (config.speak && outVol > 0) {
266
284
  let spoken = false;
267
285
  if (tts === 'voicevox') {
268
- const prosody = tsundere.effectiveProsody(speakTone, readVoiceProsody());
286
+ let prosody = tsundere.effectiveProsody(speakTone, readVoiceProsody());
287
+ if (warActive) prosody = war.effectiveProsody(warLevel, prosody); // band scale on top
269
288
  spoken = voicevox.speak(outText, outSpeaker, config.voicevox?.url, outVol, undefined, prosody);
270
289
  }
271
290
  if (!spoken) speak(outText, voice, outVol, speakTone); // OS `say` (also the VOICEVOX fallback)
package/src/state.mjs CHANGED
@@ -90,6 +90,33 @@ export const setTsundereLevel = (v) => {
90
90
  return n;
91
91
  };
92
92
 
93
+ // --- War mode --------------------------------------------------------------
94
+ // A separate read-out skin (military ops room). enabled flag + 0–1 level:
95
+ // min 平時 / mid 戦闘中 / max 危機的. Combined with the tsundere level for the
96
+ // operator's 好感度. Same small-file pattern as the mute flag / tsundere level.
97
+ const warFlagPath = () => join(stateDir(), 'war-enabled');
98
+ const warLevelPath = () => join(stateDir(), 'war-level');
99
+ export const isWarEnabled = () => existsSync(warFlagPath());
100
+ export const setWarEnabled = (on) => {
101
+ ensureDir(stateDir());
102
+ if (on) writeFileSync(warFlagPath(), '');
103
+ else rmSync(warFlagPath(), { force: true });
104
+ };
105
+ export const readWarLevel = () => {
106
+ try {
107
+ const v = parseFloat(readFileSync(warLevelPath(), 'utf8'));
108
+ return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0.5;
109
+ } catch {
110
+ return 0.5;
111
+ }
112
+ };
113
+ export const setWarLevel = (v) => {
114
+ const n = Math.min(1, Math.max(0, Number(v)));
115
+ ensureDir(stateDir());
116
+ writeFileSync(warLevelPath(), String(n));
117
+ return n;
118
+ };
119
+
93
120
  // --- VOICEVOX base prosody -------------------------------------------------
94
121
  // User-tunable BASE scales for the VOICEVOX read-out — the values used at the
95
122
  // NORMAL tone; tsundere tones nudge from here. Written by the menu bar sliders /
package/src/war.mjs ADDED
@@ -0,0 +1,118 @@
1
+ // War mode: skin the spoken read-out as a military operations room. A separate
2
+ // axis from tsundere — the WAR LEVEL sets the situation, the tsundere level (if
3
+ // on) sets the operator's 好感度 (affection), and the combination picks the line:
4
+ //
5
+ // war level min → 平時 (peacetime, calm radio chatter)
6
+ // mid → 戦闘中 / 第一種戦闘配置 (general quarters, urgent)
7
+ // max → 危機的状況 (no slack — short, shouted)
8
+ // affection dere (warm) ⇄ normal ⇄ tsun (harsh) — flavors every band
9
+ //
10
+ // Deterministic, offline, SFW. Like tsundere.mjs, only the spoken text is
11
+ // wrapped; the desktop banner stays factual.
12
+
13
+ import { axisFor } from './tsundere.mjs';
14
+
15
+ // War situation band from the 0–1 level.
16
+ export const band = (level) => {
17
+ const v = Number.isFinite(level) ? level : 0.5;
18
+ if (v < 0.34) return 'peace';
19
+ if (v < 0.67) return 'combat';
20
+ return 'crisis';
21
+ };
22
+
23
+ // Crisis shouts at full volume; combat is raised; peace is normal. Urgency (tier)
24
+ // nudges it a little more. Multiplies the user's volume.
25
+ const BAND_VOL = { peace: 1.0, combat: 1.18, crisis: 1.4 };
26
+ const TIER_VOL = { T3: 1.12, T2: 1.04, T1: 1, T0: 0.98 };
27
+ export const volumeMul = (level, tier) => (BAND_VOL[band(level)] || 1) * (TIER_VOL[tier] || 1);
28
+
29
+ // A VOICEVOX prosody nudge per band (combat/crisis = faster, sharper). Combined
30
+ // on top of the user's base scales by effectiveProsody below.
31
+ const BAND_PROSODY = {
32
+ peace: { speed: 0.98, pitch: 0.0, intonation: 1.0 },
33
+ combat: { speed: 1.1, pitch: 0.0, intonation: 1.2 },
34
+ crisis: { speed: 1.22, pitch: 0.02, intonation: 1.35 },
35
+ };
36
+ export const effectiveProsody = (level, base = {}) => {
37
+ const t = BAND_PROSODY[band(level)] || BAND_PROSODY.peace;
38
+ const b = { speed: 1, pitch: 0, intonation: 1, ...base };
39
+ return { speed: b.speed * t.speed, pitch: b.pitch + t.pitch, intonation: b.intonation * t.intonation };
40
+ };
41
+
42
+ // BANK[lang][band][tone] = [lines]. `{body}` keeps the task gist so it stays
43
+ // informative. Crisis lines are short and shouted; peace lines are calm.
44
+ const BANK = {
45
+ ja: {
46
+ peace: {
47
+ tsun: [
48
+ '司令部より各局。{body}。…別に労ってるわけじゃないけど、引き続き警戒を怠るな。',
49
+ '状況、異常なし。{body}だ。気を抜くんじゃないわよ、当然でしょ。',
50
+ '定時報告。{body}。…ふん、これくらい当たり前。次も抜かりなくね。',
51
+ ],
52
+ normal: [
53
+ '司令部より入電。{body}。現状、戦線は静穏。警戒態勢を維持する。',
54
+ '通信。{body}。各員、配置のまま待機。以上。',
55
+ '定時連絡。{body}。状況に変化なし、平常運転だ。',
56
+ ],
57
+ dere: [
58
+ '司令部より各局へ。{body}だよ。落ち着いてるね、いい調子。少し休んでも大丈夫。',
59
+ '報告ありがと。{body}。今は穏やかだから、ゆっくりいこう?',
60
+ '通信。{body}。順調だね。…無理しないで、そばで見てるから。',
61
+ ],
62
+ },
63
+ combat: {
64
+ tsun: [
65
+ '第一種戦闘配置!{body}よ。ぼーっとしてないで持ち場につきなさい!',
66
+ '総員戦闘配置。{body}。…ヘマしたら承知しないからね、急いで!',
67
+ '戦闘開始。{body}だ。手が止まってるわよ、さっさと動く!',
68
+ ],
69
+ normal: [
70
+ '第一種戦闘配置。{body}。総員、対応急げ。',
71
+ '戦闘配置につけ。{body}。各局、状況を共有し対処せよ。',
72
+ '交戦中。{body}。手順どおり、迅速に。',
73
+ ],
74
+ dere: [
75
+ '第一種戦闘配置だよ!{body}。大丈夫、一緒に乗り切ろう、急いで!',
76
+ '戦闘配置。{body}。落ち着いて、でも急いで。…ちゃんと支えるから。',
77
+ '交戦中だよ。{body}。焦らないで、でも手は止めないで!',
78
+ ],
79
+ },
80
+ crisis: {
81
+ tsun: ['緊急!{body}!早く!', '被弾!{body}!何やってんの、急いで!', '危機的状況!{body}!もたもたしない!'],
82
+ normal: ['緊急事態!{body}!対応急げ!', '警報!{body}!即応せよ!', '危機!{body}!直ちに対処!'],
83
+ dere: ['緊急だよ!{body}!お願い、急いで!', '危ない、{body}!すぐ動こう、今すぐ!', '大変、{body}!一緒に、早く!'],
84
+ },
85
+ },
86
+ en: {
87
+ peace: {
88
+ tsun: ['Command, all stations. {body}. …Not that I care, but stay sharp.', 'Status nominal. {body}. Don’t slack off.'],
89
+ normal: ['Command. {body}. Lines quiet, holding posture.', 'Comms. {body}. All hands, maintain station.'],
90
+ dere: ['Command to all. {body}. Calm out there — nice. Take a breather.', 'Report received. {body}. Steady. Don’t overdo it.'],
91
+ },
92
+ combat: {
93
+ tsun: ['General quarters! {body}. Stop dawdling, to your posts!', 'Battle stations. {body}. Don’t mess this up — move!'],
94
+ normal: ['General quarters. {body}. All hands, respond.', 'Engaged. {body}. By the numbers, quickly.'],
95
+ dere: ['Battle stations! {body}. We’ve got this — hurry!', 'Engaged. {body}. Easy, but keep moving. I’ve got you.'],
96
+ },
97
+ crisis: {
98
+ tsun: ['Emergency! {body}! Now!', 'We’re hit! {body}! Move it!'],
99
+ normal: ['Emergency! {body}! Respond now!', 'Alert! {body}! Immediate action!'],
100
+ dere: ['Emergency! {body}! Please, hurry!', 'It’s bad — {body}! Move, now!'],
101
+ },
102
+ },
103
+ };
104
+
105
+ export const isLangSupported = (lang) => !!BANK[lang];
106
+
107
+ // Wrap `body` as a war read-out. `affectionEff` is the tsundere effective level
108
+ // (0 デレ – 1 ツン); when tsundere is off, pass 0.5 for a neutral operator.
109
+ // `rot` rotates the phrase choice so repeats vary.
110
+ export const wrap = (body, level, affectionEff = 0.5, lang = 'ja', rot = 0) => {
111
+ const bank = BANK[lang];
112
+ if (!bank || !body) return body;
113
+ const tone = axisFor(affectionEff); // tsun | normal | dere
114
+ const cell = (bank[band(level)] || bank.peace);
115
+ const arr = cell[tone] || cell.normal || ['{body}'];
116
+ const phrase = arr[((rot % arr.length) + arr.length) % arr.length];
117
+ return phrase.replace('{body}', body);
118
+ };