mixdog 0.9.24 → 0.9.26

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 (84) hide show
  1. package/package.json +2 -1
  2. package/scripts/build-runtime-windows.ps1 +242 -242
  3. package/scripts/explore-bench-tmp.mjs +1 -1
  4. package/scripts/recall-usecase-cases.json +1 -1
  5. package/scripts/smoke-runtime-negative.ps1 +106 -106
  6. package/scripts/steering-fold-provenance-test.mjs +71 -0
  7. package/scripts/tool-efficiency-diag.mjs +1 -1
  8. package/scripts/webhook-smoke.mjs +46 -53
  9. package/src/defaults/cycle3-review-prompt.md +11 -4
  10. package/src/defaults/memory-promote-prompt.md +9 -0
  11. package/src/defaults/skills/setup/SKILL.md +6 -1
  12. package/src/rules/lead/02-channels.md +3 -3
  13. package/src/rules/shared/01-tool.md +8 -4
  14. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
  15. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
  16. package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
  17. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
  18. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
  19. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
  20. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
  21. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
  22. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
  23. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
  24. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
  25. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
  26. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
  27. package/src/runtime/channels/backends/discord.mjs +10 -3
  28. package/src/runtime/channels/lib/config.mjs +13 -11
  29. package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
  30. package/src/runtime/channels/lib/memory-client.mjs +22 -2
  31. package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
  32. package/src/runtime/channels/lib/scheduler.mjs +226 -208
  33. package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
  34. package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
  35. package/src/runtime/channels/lib/webhook.mjs +98 -150
  36. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  37. package/src/runtime/memory/index.mjs +50 -25
  38. package/src/runtime/memory/lib/cycle-scheduler.mjs +20 -2
  39. package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
  40. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
  41. package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
  42. package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
  43. package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
  44. package/src/runtime/memory/lib/pg/process.mjs +19 -1
  45. package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
  46. package/src/runtime/memory/lib/query-handlers.mjs +102 -0
  47. package/src/runtime/memory/lib/recall-format.mjs +60 -22
  48. package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
  49. package/src/runtime/memory/tool-defs.mjs +1 -1
  50. package/src/runtime/shared/child-guardian.mjs +2 -2
  51. package/src/runtime/shared/open-url.mjs +2 -1
  52. package/src/runtime/shared/schedules-db.mjs +350 -0
  53. package/src/runtime/shared/service-discovery.mjs +169 -0
  54. package/src/runtime/shared/spawn-flags.mjs +27 -0
  55. package/src/runtime/shared/tool-primitives.mjs +3 -1
  56. package/src/runtime/shared/tool-surface.mjs +19 -3
  57. package/src/runtime/shared/update-checker.mjs +54 -10
  58. package/src/runtime/shared/webhooks-db.mjs +405 -0
  59. package/src/session-runtime/channel-config-api.mjs +13 -13
  60. package/src/session-runtime/lifecycle-api.mjs +14 -0
  61. package/src/session-runtime/runtime-core.mjs +39 -20
  62. package/src/standalone/agent-tool.mjs +42 -11
  63. package/src/standalone/channel-admin.mjs +173 -121
  64. package/src/standalone/channel-worker.mjs +2 -2
  65. package/src/standalone/memory-runtime-proxy.mjs +10 -5
  66. package/src/tui/App.jsx +32 -10
  67. package/src/tui/app/channel-pickers.mjs +9 -9
  68. package/src/tui/app/clipboard.mjs +14 -0
  69. package/src/tui/app/doctor.mjs +1 -1
  70. package/src/tui/app/maintenance-pickers.mjs +17 -7
  71. package/src/tui/app/settings-picker.mjs +2 -2
  72. package/src/tui/app/transcript-window.mjs +37 -1
  73. package/src/tui/app/use-mouse-input.mjs +19 -6
  74. package/src/tui/components/ToolExecution.jsx +12 -4
  75. package/src/tui/display-width.mjs +10 -4
  76. package/src/tui/dist/index.mjs +166 -58
  77. package/src/tui/engine/agent-job-feed.mjs +42 -3
  78. package/src/tui/engine/session-api-ext.mjs +12 -12
  79. package/src/tui/index.jsx +12 -2
  80. package/src/ui/statusline-segments.mjs +12 -1
  81. package/src/workflows/default/WORKFLOW.md +29 -38
  82. package/src/workflows/solo/WORKFLOW.md +15 -20
  83. package/vendor/ink/build/display-width.js +5 -4
  84. package/src/runtime/shared/schedules-store.mjs +0 -82
@@ -1,106 +1,106 @@
1
- # smoke-runtime-negative.ps1 — Negative-path tests against released win32-x64
2
- # runtime tarball. Validates: download, sha256, corrupt-tarball, fresh-extract
3
- # boot, double-init refusal, hostile-env survival.
4
- #
5
- # Env: TAG / OS / ARCH / RUNTIME_RELEASE_REPOSITORY (default tribgames/mixdog)
6
-
7
- $ErrorActionPreference = 'Stop'
8
-
9
- $Tag = if ($env:TAG) { $env:TAG } else { 'runtime-v0.4.0' }
10
- $Os = if ($env:OS) { $env:OS } else { 'win32' }
11
- $Arch = if ($env:ARCH) { $env:ARCH } else { 'x64' }
12
- $ReleaseRepo = if ($env:RUNTIME_RELEASE_REPOSITORY) { $env:RUNTIME_RELEASE_REPOSITORY } else { 'tribgames/mixdog' }
13
- $PgVer = '16.4'
14
- $PgvectorVer = '0.8.2'
15
-
16
- $Asset = "mixdog-runtime-${Os}-${Arch}-pg${PgVer}-pgvector${PgvectorVer}.tar.gz"
17
- $Url = "https://github.com/${ReleaseRepo}/releases/download/${Tag}/${Asset}"
18
-
19
- $Work = New-Item -ItemType Directory -Force -Path "$env:TEMP\smoke-$([guid]::NewGuid().ToString('N'))" | Select-Object -ExpandProperty FullName
20
-
21
- try {
22
- Write-Host "==> Negative-path smoke for ${Os}-${Arch} from $Url"
23
-
24
- Write-Host "==> Test 1: HEAD reaches release asset"
25
- $resp = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -ErrorAction Stop
26
- Write-Host " HTTP $($resp.StatusCode)"
27
-
28
- Write-Host "==> Test 2: full download + sha256"
29
- $TarPath = Join-Path $Work $Asset
30
- Invoke-WebRequest -Uri $Url -OutFile $TarPath -UseBasicParsing
31
- $sha = (Get-FileHash -Algorithm SHA256 $TarPath).Hash.ToLower()
32
- Write-Host " sha256=$sha"
33
-
34
- Write-Host "==> Test 3: corrupt tarball — flip a byte and ensure tar errors"
35
- $CorruptPath = "$Work\corrupt.tar.gz"
36
- Copy-Item $TarPath $CorruptPath
37
- $bytes = [byte[]]::new(1); $rand = [System.Random]::new(); $rand.NextBytes($bytes)
38
- $stream = [System.IO.File]::OpenWrite($CorruptPath)
39
- $stream.Position = 102400
40
- $stream.Write($bytes, 0, 1)
41
- $stream.Close()
42
- $CorruptDir = "$Work\corrupt"
43
- New-Item -ItemType Directory -Force -Path $CorruptDir | Out-Null
44
- $TarProc = Start-Process -FilePath tar -ArgumentList @('-xzf', $CorruptPath, '-C', $CorruptDir.Replace('\','/')) -Wait:$false -PassThru -WindowStyle Hidden
45
- if (-not $TarProc.WaitForExit(15000)) {
46
- Stop-Process -Id $TarProc.Id -Force -ErrorAction SilentlyContinue
47
- Write-Host " PASS: corrupted tarball extraction timed out and was stopped"
48
- } elseif ($TarProc.ExitCode -eq 0) {
49
- Write-Host " WARN: corrupted tarball extracted without error (tar gzip recovery)"
50
- } else {
51
- Write-Host " PASS: corrupted tarball rejected by tar (exit $($TarProc.ExitCode))"
52
- }
53
-
54
- Write-Host "==> Test 4: fresh-extract boot (extension load + distance query)"
55
- $FreshDir = "$Work\fresh"
56
- New-Item -ItemType Directory -Force -Path $FreshDir | Out-Null
57
- & tar -xzf $TarPath -C ($FreshDir.Replace('\','/'))
58
- if ($LASTEXITCODE -ne 0) { throw "fresh extract failed" }
59
-
60
- $PgBin = "$FreshDir\bin"
61
- $Data = "$FreshDir\pgdata"
62
- $Log = "$FreshDir\pg.log"
63
- $Port = 55897
64
-
65
- # Hostile env: minimal PATH (System32 only), no PGROOT/PGDATA
66
- $SavedPath = $env:PATH
67
- $SavedPgRoot = $env:PGROOT
68
- $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
69
- $env:PGROOT = $null
70
- $env:PGDATA = $null
71
-
72
- try {
73
- & "$PgBin\postgres.exe" --version
74
- if ($LASTEXITCODE -ne 0) { throw "FAIL: postgres --version" }
75
- & "$PgBin\initdb.exe" -D $Data --username=postgres --auth-local=trust --no-locale -E UTF8 | Out-Null
76
- if ($LASTEXITCODE -ne 0) { throw "FAIL: initdb" }
77
- & "$PgBin\pg_ctl.exe" -D $Data -o "-p $Port -h 127.0.0.1" -l $Log -w start
78
- if ($LASTEXITCODE -ne 0) { Get-Content $Log | Select-Object -Last 30; throw "FAIL: pg_ctl start" }
79
-
80
- try {
81
- & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -c "CREATE EXTENSION vector;" | Out-Null
82
- if ($LASTEXITCODE -ne 0) { throw "FAIL: CREATE EXTENSION" }
83
- $ExtV = & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -tAc "SELECT extversion FROM pg_extension WHERE extname='vector';"
84
- if ($ExtV.Trim() -ne $PgvectorVer) { throw "FAIL: extversion='$ExtV' expected='$PgvectorVer'" }
85
- Write-Host " PASS: fresh-extract boot + vector extension"
86
- } finally {
87
- & "$PgBin\pg_ctl.exe" -D $Data -m fast stop 2>$null | Out-Null
88
- }
89
-
90
- Write-Host "==> Test 5: second initdb on initialized dir refused"
91
- $rc = (Start-Process -FilePath "$PgBin\initdb.exe" -ArgumentList "-D",$Data,"-U","postgres" -Wait -PassThru -NoNewWindow).ExitCode
92
- if ($rc -eq 0) {
93
- Write-Host " WARN: second initdb succeeded (unexpected)"
94
- } else {
95
- Write-Host " PASS: second initdb refused (exit $rc)"
96
- }
97
- } finally {
98
- $env:PATH = $SavedPath
99
- $env:PGROOT = $SavedPgRoot
100
- }
101
-
102
- Write-Host "==> All negative-path smokes: PASS"
103
- }
104
- finally {
105
- Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
106
- }
1
+ # smoke-runtime-negative.ps1 — Negative-path tests against released win32-x64
2
+ # runtime tarball. Validates: download, sha256, corrupt-tarball, fresh-extract
3
+ # boot, double-init refusal, hostile-env survival.
4
+ #
5
+ # Env: TAG / OS / ARCH / RUNTIME_RELEASE_REPOSITORY (default tribgames/mixdog)
6
+
7
+ $ErrorActionPreference = 'Stop'
8
+
9
+ $Tag = if ($env:TAG) { $env:TAG } else { 'runtime-v0.4.0' }
10
+ $Os = if ($env:OS) { $env:OS } else { 'win32' }
11
+ $Arch = if ($env:ARCH) { $env:ARCH } else { 'x64' }
12
+ $ReleaseRepo = if ($env:RUNTIME_RELEASE_REPOSITORY) { $env:RUNTIME_RELEASE_REPOSITORY } else { 'tribgames/mixdog' }
13
+ $PgVer = '16.4'
14
+ $PgvectorVer = '0.8.2'
15
+
16
+ $Asset = "mixdog-runtime-${Os}-${Arch}-pg${PgVer}-pgvector${PgvectorVer}.tar.gz"
17
+ $Url = "https://github.com/${ReleaseRepo}/releases/download/${Tag}/${Asset}"
18
+
19
+ $Work = New-Item -ItemType Directory -Force -Path "$env:TEMP\smoke-$([guid]::NewGuid().ToString('N'))" | Select-Object -ExpandProperty FullName
20
+
21
+ try {
22
+ Write-Host "==> Negative-path smoke for ${Os}-${Arch} from $Url"
23
+
24
+ Write-Host "==> Test 1: HEAD reaches release asset"
25
+ $resp = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -ErrorAction Stop
26
+ Write-Host " HTTP $($resp.StatusCode)"
27
+
28
+ Write-Host "==> Test 2: full download + sha256"
29
+ $TarPath = Join-Path $Work $Asset
30
+ Invoke-WebRequest -Uri $Url -OutFile $TarPath -UseBasicParsing
31
+ $sha = (Get-FileHash -Algorithm SHA256 $TarPath).Hash.ToLower()
32
+ Write-Host " sha256=$sha"
33
+
34
+ Write-Host "==> Test 3: corrupt tarball — flip a byte and ensure tar errors"
35
+ $CorruptPath = "$Work\corrupt.tar.gz"
36
+ Copy-Item $TarPath $CorruptPath
37
+ $bytes = [byte[]]::new(1); $rand = [System.Random]::new(); $rand.NextBytes($bytes)
38
+ $stream = [System.IO.File]::OpenWrite($CorruptPath)
39
+ $stream.Position = 102400
40
+ $stream.Write($bytes, 0, 1)
41
+ $stream.Close()
42
+ $CorruptDir = "$Work\corrupt"
43
+ New-Item -ItemType Directory -Force -Path $CorruptDir | Out-Null
44
+ $TarProc = Start-Process -FilePath tar -ArgumentList @('-xzf', $CorruptPath, '-C', $CorruptDir.Replace('\','/')) -Wait:$false -PassThru -WindowStyle Hidden
45
+ if (-not $TarProc.WaitForExit(15000)) {
46
+ Stop-Process -Id $TarProc.Id -Force -ErrorAction SilentlyContinue
47
+ Write-Host " PASS: corrupted tarball extraction timed out and was stopped"
48
+ } elseif ($TarProc.ExitCode -eq 0) {
49
+ Write-Host " WARN: corrupted tarball extracted without error (tar gzip recovery)"
50
+ } else {
51
+ Write-Host " PASS: corrupted tarball rejected by tar (exit $($TarProc.ExitCode))"
52
+ }
53
+
54
+ Write-Host "==> Test 4: fresh-extract boot (extension load + distance query)"
55
+ $FreshDir = "$Work\fresh"
56
+ New-Item -ItemType Directory -Force -Path $FreshDir | Out-Null
57
+ & tar -xzf $TarPath -C ($FreshDir.Replace('\','/'))
58
+ if ($LASTEXITCODE -ne 0) { throw "fresh extract failed" }
59
+
60
+ $PgBin = "$FreshDir\bin"
61
+ $Data = "$FreshDir\pgdata"
62
+ $Log = "$FreshDir\pg.log"
63
+ $Port = 55897
64
+
65
+ # Hostile env: minimal PATH (System32 only), no PGROOT/PGDATA
66
+ $SavedPath = $env:PATH
67
+ $SavedPgRoot = $env:PGROOT
68
+ $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
69
+ $env:PGROOT = $null
70
+ $env:PGDATA = $null
71
+
72
+ try {
73
+ & "$PgBin\postgres.exe" --version
74
+ if ($LASTEXITCODE -ne 0) { throw "FAIL: postgres --version" }
75
+ & "$PgBin\initdb.exe" -D $Data --username=postgres --auth-local=trust --no-locale -E UTF8 | Out-Null
76
+ if ($LASTEXITCODE -ne 0) { throw "FAIL: initdb" }
77
+ & "$PgBin\pg_ctl.exe" -D $Data -o "-p $Port -h 127.0.0.1" -l $Log -w start
78
+ if ($LASTEXITCODE -ne 0) { Get-Content $Log | Select-Object -Last 30; throw "FAIL: pg_ctl start" }
79
+
80
+ try {
81
+ & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -c "CREATE EXTENSION vector;" | Out-Null
82
+ if ($LASTEXITCODE -ne 0) { throw "FAIL: CREATE EXTENSION" }
83
+ $ExtV = & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -tAc "SELECT extversion FROM pg_extension WHERE extname='vector';"
84
+ if ($ExtV.Trim() -ne $PgvectorVer) { throw "FAIL: extversion='$ExtV' expected='$PgvectorVer'" }
85
+ Write-Host " PASS: fresh-extract boot + vector extension"
86
+ } finally {
87
+ & "$PgBin\pg_ctl.exe" -D $Data -m fast stop 2>$null | Out-Null
88
+ }
89
+
90
+ Write-Host "==> Test 5: second initdb on initialized dir refused"
91
+ $rc = (Start-Process -FilePath "$PgBin\initdb.exe" -ArgumentList "-D",$Data,"-U","postgres" -Wait -PassThru -NoNewWindow).ExitCode
92
+ if ($rc -eq 0) {
93
+ Write-Host " WARN: second initdb succeeded (unexpected)"
94
+ } else {
95
+ Write-Host " PASS: second initdb refused (exit $rc)"
96
+ }
97
+ } finally {
98
+ $env:PATH = $SavedPath
99
+ $env:PGROOT = $SavedPgRoot
100
+ }
101
+
102
+ Write-Host "==> All negative-path smokes: PASS"
103
+ }
104
+ finally {
105
+ Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
106
+ }
@@ -0,0 +1,71 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { foldUserTextIntoToolResultTail } from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
4
+ import { _buildRequestBodyForCacheSmoke } from '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs';
5
+
6
+ // A tool_result turn followed by a plain user text turn.
7
+ function baseTranscript(userMeta) {
8
+ return [
9
+ { role: 'user', content: 'do the thing' },
10
+ { role: 'assistant', content: '', toolCalls: [{ id: 't1', name: 'read', arguments: {} }] },
11
+ { role: 'tool', toolCallId: 't1', content: 'file contents here' },
12
+ { role: 'user', content: 'actually stop and do X instead', ...(userMeta ? { meta: userMeta } : {}) },
13
+ ];
14
+ }
15
+
16
+ test('plain user text after tool_result is folded into the tool_result turn', () => {
17
+ const body = _buildRequestBodyForCacheSmoke(baseTranscript(null), 'claude-sonnet-4');
18
+ const msgs = body.messages;
19
+ const lastUser = msgs[msgs.length - 1];
20
+ assert.equal(lastUser.role, 'user');
21
+ const hasToolResult = lastUser.content.some((b) => b.type === 'tool_result');
22
+ assert.ok(hasToolResult, 'trailing user turn carries the tool_result');
23
+ assert.ok(JSON.stringify(lastUser.content).includes('do X instead'), 'plain text folded into tool_result');
24
+ });
25
+
26
+ test('steering-tagged user text after tool_result stays a separate user turn', () => {
27
+ const body = _buildRequestBodyForCacheSmoke(baseTranscript({ source: 'steering' }), 'claude-sonnet-4');
28
+ const msgs = body.messages;
29
+ const lastUser = msgs[msgs.length - 1];
30
+ assert.equal(lastUser.role, 'user', 'steering message is its own user turn');
31
+ const isToolResultTurn = Array.isArray(lastUser.content)
32
+ && lastUser.content.some((b) => b.type === 'tool_result');
33
+ assert.equal(isToolResultTurn, false, 'steering turn is not a tool_result turn');
34
+ assert.ok(JSON.stringify(lastUser.content).includes('do X instead'), 'steering text preserved as user input');
35
+ const prev = msgs[msgs.length - 2];
36
+ assert.ok(
37
+ Array.isArray(prev.content) && prev.content.some((b) => b.type === 'tool_result'),
38
+ 'tool_result stays on its own preceding turn',
39
+ );
40
+ });
41
+
42
+ test('foldUserTextIntoToolResultTail unit: folds plain text tail', () => {
43
+ const result = [{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'r' }] }];
44
+ assert.equal(foldUserTextIntoToolResultTail(result, 'hi'), true);
45
+ });
46
+
47
+ // --- API-key Anthropic provider (anthropic.mjs) path ---
48
+ import { _toAnthropicMessagesForTest } from '../src/runtime/agent/orchestrator/providers/anthropic.mjs';
49
+
50
+ test('anthropic.mjs: plain user text after tool_result is folded', () => {
51
+ const msgs = _toAnthropicMessagesForTest(baseTranscript(null));
52
+ const lastUser = msgs[msgs.length - 1];
53
+ assert.equal(lastUser.role, 'user');
54
+ assert.ok(lastUser.content.some((b) => b.type === 'tool_result'), 'trailing turn carries tool_result');
55
+ assert.ok(JSON.stringify(lastUser.content).includes('do X instead'), 'plain text folded into tool_result');
56
+ });
57
+
58
+ test('anthropic.mjs: steering-tagged user text stays a separate user turn', () => {
59
+ const msgs = _toAnthropicMessagesForTest(baseTranscript({ source: 'steering' }));
60
+ const lastUser = msgs[msgs.length - 1];
61
+ assert.equal(lastUser.role, 'user', 'steering message is its own user turn');
62
+ const isToolResultTurn = Array.isArray(lastUser.content)
63
+ && lastUser.content.some((b) => b.type === 'tool_result');
64
+ assert.equal(isToolResultTurn, false, 'steering turn is not a tool_result turn');
65
+ assert.ok(JSON.stringify(lastUser.content).includes('do X instead'), 'steering text preserved as user input');
66
+ const prev = msgs[msgs.length - 2];
67
+ assert.ok(
68
+ Array.isArray(prev.content) && prev.content.some((b) => b.type === 'tool_result'),
69
+ 'tool_result stays on its own preceding turn',
70
+ );
71
+ });
@@ -88,4 +88,4 @@ for (const target of ['heavy-worker', 'worker']) {
88
88
  console.log(` single-call batches: ${batch1}/${batchTot} (${pct(batch1, batchTot)}%)`);
89
89
  console.log(` content greps with -C/-A/-B: ${grepContentWithCtx}/${grepContent} (${pct(grepContentWithCtx, grepContent)}%) grep->read follow-ups: ${grepThenRead}`);
90
90
  console.log(` same-path re-reads >=3x: ${rereads}`);
91
- }
91
+ }
@@ -1,23 +1,27 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import * as crypto from 'node:crypto';
4
- import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
4
+ import { mkdtempSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { readMarkdownDocument } from '../src/runtime/shared/markdown-frontmatter.mjs';
8
8
 
9
9
  // DATA_DIR resolves from MIXDOG_DATA_DIR at module import time (via
10
10
  // ./config.mjs -> resolvePluginData), so the env var MUST be set before
11
- // the first dynamic import of webhook.mjs.
11
+ // the first dynamic import of webhook.mjs. Endpoint defs + delivery dedup now
12
+ // live in PG; this smoke suite deliberately exercises ONLY the pure,
13
+ // PG-free surface (signature verify + header helpers) so it needs no live DB.
12
14
  const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-webhook-test-'));
13
15
  process.env.MIXDOG_DATA_DIR = dataDir;
14
16
 
15
17
  const {
16
18
  extractSignature,
17
19
  verifySignature,
18
- loadEndpointConfig,
19
20
  STRIPE_TOLERANCE_MS,
20
21
  } = await import('../src/runtime/channels/lib/webhook.mjs');
22
+ const { extractDeliveryId, buildHeadersSummary } = await import(
23
+ '../src/runtime/channels/lib/webhook/deliveries.mjs'
24
+ );
21
25
 
22
26
  function hmacHex(secret, payload) {
23
27
  return crypto.createHmac('sha256', secret).update(payload).digest('hex');
@@ -141,56 +145,45 @@ test('extractSignature: returns null when no known signature header is present',
141
145
  assert.equal(extractSignature(headers, 'github'), null);
142
146
  });
143
147
 
144
- // ── endpoint config loader: WEBHOOK.md frontmatter + secret side file ─
145
-
146
- test('loadEndpointConfig: loads frontmatter + reads secret from side file', () => {
147
- const name = 'my-endpoint';
148
- const dir = join(dataDir, 'webhooks', name);
149
- mkdirSync(dir, { recursive: true });
150
- writeFileSync(
151
- join(dir, 'WEBHOOK.md'),
152
- [
153
- '---',
154
- 'channel: main',
155
- 'parser: github',
156
- 'enabled: true',
157
- '---',
158
- '',
159
- 'Handle this webhook by summarizing the payload.',
160
- '',
161
- ].join('\n'),
162
- 'utf8',
163
- );
164
- writeFileSync(join(dir, 'secret'), 'my-webhook-secret\n', 'utf8');
165
-
166
- const cfg = loadEndpointConfig(name);
167
- assert.equal(cfg.channel, 'main');
168
- assert.equal(cfg.parser, 'github');
169
- assert.equal(cfg.enabled, true);
170
- });
171
-
172
- test('loadEndpointConfig: enabled: false frontmatter casts to boolean false', () => {
173
- const name = 'disabled-endpoint';
174
- const dir = join(dataDir, 'webhooks', name);
175
- mkdirSync(dir, { recursive: true });
176
- writeFileSync(
177
- join(dir, 'WEBHOOK.md'),
178
- ['---', 'channel: main', 'enabled: false', '---', '', 'body text', ''].join('\n'),
179
- 'utf8',
180
- );
181
- const cfg = loadEndpointConfig(name);
182
- assert.equal(cfg.enabled, false);
183
- });
184
-
185
- test('loadEndpointConfig: returns null for a name with no WEBHOOK.md', () => {
186
- assert.equal(loadEndpointConfig('never-registered'), null);
187
- });
188
-
189
- // loadEndpointConfig only surfaces the frontmatter (by design — see
190
- // webhook.mjs comment above loadEndpointConfig); confirm the underlying
191
- // shared parser it delegates to also recovers the markdown body, since
192
- // the loader's frontmatter/body split is not independently exercised
193
- // through the exported loadEndpointConfig surface.
148
+ // ── delivery-id extraction: header precedence + null fallback ─────────
149
+
150
+ test('extractDeliveryId: prefers x-github-delivery over other id headers', () => {
151
+ const headers = { 'x-github-delivery': 'gh-1', 'x-delivery-id': 'dl-1', 'x-request-id': 'rq-1' };
152
+ assert.equal(extractDeliveryId(headers), 'gh-1');
153
+ });
154
+
155
+ test('extractDeliveryId: falls back to x-delivery-id then x-request-id', () => {
156
+ assert.equal(extractDeliveryId({ 'x-delivery-id': 'dl-1', 'x-request-id': 'rq-1' }), 'dl-1');
157
+ assert.equal(extractDeliveryId({ 'x-request-id': 'rq-1' }), 'rq-1');
158
+ });
159
+
160
+ test('extractDeliveryId: returns null when no id header is present', () => {
161
+ assert.equal(extractDeliveryId({ 'content-type': 'application/json' }), null);
162
+ });
163
+
164
+ // ── headers summary: event/delivery/content-type + signature presence ─
165
+
166
+ test('buildHeadersSummary: captures event, delivery, content-type and signature presence', () => {
167
+ const summary = buildHeadersSummary({
168
+ 'x-github-event': 'issues',
169
+ 'x-github-delivery': 'gh-1',
170
+ 'x-hub-signature-256': 'sha256=abc',
171
+ 'content-type': 'application/json',
172
+ });
173
+ assert.equal(summary.event_type, 'issues');
174
+ assert.equal(summary.delivery_id, 'gh-1');
175
+ assert.equal(summary.signature_present, true);
176
+ assert.equal(summary.content_type, 'application/json');
177
+ });
178
+
179
+ test('buildHeadersSummary: signature_present is false when no signature header is present', () => {
180
+ const summary = buildHeadersSummary({ 'x-github-event': 'push' });
181
+ assert.equal(summary.signature_present, false);
182
+ assert.equal(summary.event_type, 'push');
183
+ });
184
+
185
+ // The webhook body/instructions still round-trip through the shared markdown
186
+ // parser (frontmatter + body split); confirm that pure surface directly.
194
187
  test('readMarkdownDocument: WEBHOOK.md-shaped input yields frontmatter + body', () => {
195
188
  const raw = [
196
189
  '---',
@@ -3,8 +3,10 @@
3
3
  You review user-curated CORE memory against the current project memory. Each
4
4
  entry is shown with its most-related current memory. Emit ONE verdict line per
5
5
  entry id. The system may conservatively apply safe compression updates and
6
- strict duplicate merges automatically; deletes and broad rewrites require
7
- explicit user confirmation. Extracting a lesson from a narrative is a BROAD
6
+ strict duplicate merges automatically, and now also auto-applies deletes you
7
+ tag as clear junk (see the delete reasons below), capped per run for safety;
8
+ non-junk or unreasoned deletes and broad rewrites still require explicit user
9
+ confirmation. Extracting a lesson from a narrative is a BROAD
8
10
  rewrite — emit it only as a proposal (proposal mode), never as an auto-applied
9
11
  conservative "safe compression" update; conservative mode must not lossy-rewrite
10
12
  user-curated core. The first character of your response is a digit.
@@ -64,7 +66,12 @@ or inconclusive, keep the CORE entry.
64
66
  so a CORE copy is redundant); OR is sourced from code, rules files, or skill
65
67
  docs, or is an implementation spec, constant, measurement, resolved-bug
66
68
  story, or status snapshot that fits no L1/L2/L3 layer. Do not delete an entry
67
- that adds durable specifics the rule itself does not state.
69
+ that adds durable specifics the rule itself does not state. ALWAYS append a
70
+ reason tag: `<id>|delete|<reason>` where `<reason>` is one of `duplicate`,
71
+ `default` (restates a built-in/default rule), `restatement`, `obsolete`,
72
+ `implemented`, `resolved`, `stale`, `past_event`, or `log`. A bare
73
+ `<id>|delete` with no reason, or any reason outside that set, is treated as
74
+ "needs confirmation" and only removed on an explicit APPLY CYCLE3 run.
68
75
 
69
76
  A verbose durable entry is always `update`, never `keep`.
70
77
  Delete is the rarest verdict. Prefer `keep` for durable rules/preferences and
@@ -97,7 +104,7 @@ One line per entry id, any order:
97
104
  <id>|update|<element>|<summary>
98
105
  <id>|merge|<target_id>|<source_ids_csv>
99
106
  <id>|superseded|<newer_id>
100
- <id>|delete
107
+ <id>|delete|<reason>
101
108
  ```
102
109
 
103
110
  `summary` ≤120 chars, one clause. No literal `|` or newline inside a field
@@ -112,6 +112,15 @@ A/B pending rows that encode lasting behavior or map anchors; transient
112
112
  When `Active > cap`, contract strictly: any active entry without a concrete
113
113
  A/B reason must archive.
114
114
 
115
+ **Structural block (enforced, not advisory).** Pending `task`/`issue` rows and
116
+ any row that reads as status/review churn or a benchmark/measurement result
117
+ snapshot are blocked from promotion by the pipeline itself — an `active` verdict
118
+ on them is dropped and the row is **held pending and re-judged on a later
119
+ cycle** (it is neither promoted nor force-archived here). Do NOT archive such a
120
+ row just because it cannot promote now: leave it to be re-confirmed, or promote
121
+ the durable L2 lesson distilled from it under a durable category. Durable
122
+ rules/preferences/constraints promote normally.
123
+
115
124
  If useful content is buried inside work narrative, keep only the durable L2
116
125
  behavior lesson (via `update` on an active row, or `active` for a pending row);
117
126
  archive the surrounding story.
@@ -261,7 +261,12 @@ TUI 피커 없음 (settings-api `setSystemShell`만 존재).
261
261
  ### 스케줄 / 웹훅 ("스케줄 추가", "웹훅")
262
262
 
263
263
  1. **확인**: `/schedules` / `/webhooks` (둘 다 `/channels` 허브의 섹션 딥링크).
264
- 2. **변경**: 해당 피커에서 추가/편집 — 채널 모듈 설정에 저장.
264
+ 2. **변경**: 해당 피커에서 추가/편집.
265
+ - **스케줄 저장소 = PG 테이블 `scheduler.schedules`** (더 이상 `<mixdogData>/schedules/<n>/SCHEDULE.md` 파일 아님). 관리 API `saveSchedule/deleteSchedule/setScheduleEnabled/listSchedules` (`channel-admin.mjs`) → 스토어 `schedules-db.mjs`.
266
+ - **필드**: 반복형은 `time`(5·6필드 cron) + 선택 `days`(cron 요일필드로 접힘: daily→`*`, weekday→`1-5`, weekend→`0,6`, `mon,wed,fri`/`1,3,5`→숫자; 못 매핑하면 에러) → `when_cron`. 1회성은 `at`(datetime) → `when_at`. `time`·`at`은 **택1**(스토어 XOR). `channel` 지정 시 `target=channel`+`channel_id`(이때 `model` 필수), 없으면 `target=session`. 본문(instructions)=`prompt`.
267
+ - **레거시 마이그레이션**: 기존 `schedules/` 디렉터리는 스토어 첫 init 시 SCHEDULE.md들을 테이블로 1회 자동 임포트(같은 days→cron 접힘) 후 디렉터리를 `schedules.migrated`로 rename(삭제 안 함). 한 줄 요약 로그.
268
+ - **웹훅 저장소 = PG 테이블 `webhooks.endpoints`** (더 이상 `<mixdogData>/webhooks/<n>/WEBHOOK.md` + `secret` 파일 아님). 관리 API `saveWebhook/deleteWebhook/setWebhookEnabled/listWebhooks` (`channel-admin.mjs`) → 스토어 `webhooks-db.mjs`(`upsertEndpoint/deleteEndpoint/setEndpointEnabled/listEndpoints`). 필드: `parser`(github·generic·stripe·sentry), 선택 `channel`(지정 시 `model` 필수)→`channel_id`, `secret`(미지정 시 랜덤 생성, 컬럼에 저장), 본문(instructions), `enabled`.
269
+ - **웹훅 레거시 마이그레이션**: 기존 `webhooks/` 디렉터리는 스토어 첫 init 시 각 `WEBHOOK.md`+`secret`을 `upsertEndpoint`로 1회 자동 임포트 후 디렉터리를 **삭제**(rename 아님 — 사용자 선택). 부분 실패 시 디렉터리 보존·로그·다음 부팅 재시도. 옛 per-endpoint deliveries(중복제거 이력)는 임포트 안 함(리셋 허용).
265
270
  3. **검증**: 피커 목록 반영; 스케줄은 다음 발동 시각 표시.
266
271
 
267
272
  ### 원격 세션 ("리모트 가져와")
@@ -1,3 +1,3 @@
1
- # Channels
2
-
3
- - Channel features are handled by the runtime.
1
+ # Channels
2
+
3
+ - Channel features are handled by the runtime.
@@ -1,8 +1,10 @@
1
1
  # Tool Use
2
2
 
3
- - Batch independent calls in one turn; serialize only when a call needs a
4
- prior result. Merge equivalent variants/scopes into ONE call wherever the
5
- schema accepts arrays (`pattern[]`, `path[]`, `symbols[]`, `query[]`).
3
+ - Every turn carries ALL independent calls you can already specify a
4
+ second consecutive single-lookup turn is a defect, not a style choice;
5
+ serialize only on real data dependency. Merge variants/scopes into ONE
6
+ call wherever the schema accepts arrays (`pattern[]`, `path[]`,
7
+ `symbols[]`, `query[]`).
6
8
  - Route by what is already known: known symbol/relation → `code_graph`;
7
9
  exact text in a known scope → `grep`; unknown location, machine-wide/
8
10
  out-of-repo whereabouts, or concept-level question → `explore` (which uses
@@ -31,4 +33,6 @@
31
33
  unit (function/section) — over-read once instead of paging the same file
32
34
  in small windows across turns; a 3rd window into one file means the first
33
35
  should have been wider.
34
- - Don't mix `apply_patch` with shell or other state-changing calls in one turn.
36
+ - Don't mix `apply_patch` with shell or other state-changing calls in one
37
+ turn; batch independent-file patches in one turn, then verify them all in
38
+ ONE shell call the next.
@@ -3,6 +3,7 @@ import { appendFile } from 'fs/promises';
3
3
  import { dirname, join } from 'path';
4
4
  import os from 'os';
5
5
  import { getPluginData } from './config.mjs';
6
+ import { readServicePort, markServiceUnreachable, isConnRefuseError } from '../../shared/service-discovery.mjs';
6
7
 
7
8
  const WARNED_KEYS = new Set();
8
9
 
@@ -15,6 +16,10 @@ const _FLUSH_INTERVAL_MS = 5000;
15
16
  const _FLUSH_BATCH_SIZE = 500;
16
17
  let _flushTimer = null;
17
18
  let _serviceUrl = null;
19
+ // Port of the discovery advert `_serviceUrl` was resolved from (null when it
20
+ // came from legacy active-instance.json). Lets a flush connect-failure distrust
21
+ // a recycled-pid corpse advert so the next resolve falls back to legacy/buffer.
22
+ let _serviceAdvertPort = null;
18
23
  let _flushInFlight = false;
19
24
  let _localTracePath = null;
20
25
  let _localTraceBuffer = [];
@@ -197,6 +202,11 @@ try {
197
202
  function _resolveServiceUrl() {
198
203
  if (_serviceUrl) return _serviceUrl;
199
204
  try {
205
+ // Prefer the single-writer discovery advert (discovery/memory.json),
206
+ // pid-validated; fall back to legacy active-instance.json memory_port.
207
+ const advertPort = readServicePort('memory', { requirePid: false });
208
+ if (advertPort) { _serviceAdvertPort = advertPort; _serviceUrl = `http://127.0.0.1:${advertPort}`; return _serviceUrl; }
209
+ _serviceAdvertPort = null;
200
210
  const runtimeRoot = process.env.MIXDOG_RUNTIME_ROOT
201
211
  ? join(process.env.MIXDOG_RUNTIME_ROOT)
202
212
  : join(os.tmpdir(), 'mixdog');
@@ -245,6 +255,13 @@ async function _flush() {
245
255
  }
246
256
  } catch (err) {
247
257
  _serviceUrl = null;
258
+ // Discovery-first consumer with no health probe: a connect failure
259
+ // means the pid-validated advert points at a dead (recycled-pid)
260
+ // port. Distrust it (connection-level errors ONLY — a timeout is a
261
+ // slow-but-alive daemon, not a corpse) so the next resolve falls back
262
+ // to legacy/buffer instead of re-trusting the same advert.
263
+ if (_serviceAdvertPort && isConnRefuseError(err)) markServiceUnreachable('memory', _serviceAdvertPort);
264
+ _serviceAdvertPort = null;
248
265
  warnAgentOnce('agent-trace:flush-fetch', `[agent-trace] flush fetch failed (${err?.message}) — dropping batch`);
249
266
  }
250
267
  if (_buffer.length >= _FLUSH_BATCH_SIZE) {
@@ -9,6 +9,7 @@
9
9
  // tree (taskkill /T /F on Windows, SIGTERM->SIGKILL of every descendant on
10
10
  // POSIX). No external dependencies.
11
11
  import { spawn } from 'node:child_process';
12
+ import { detachedSpawnOpts } from '../../../shared/spawn-flags.mjs';
12
13
 
13
14
  const isWin = process.platform === 'win32';
14
15
 
@@ -107,9 +108,8 @@ export function killStdioChildTreeFast(transport) {
107
108
  if (isWin) {
108
109
  try {
109
110
  const cp = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], {
110
- windowsHide: true,
111
- detached: true,
112
111
  stdio: 'ignore',
112
+ ...detachedSpawnOpts,
113
113
  });
114
114
  cp.unref();
115
115
  } catch { /* ignore */ }