mixdog 0.9.24 → 0.9.25
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/package.json +2 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/explore-bench-tmp.mjs +1 -1
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/src/defaults/cycle3-review-prompt.md +11 -4
- package/src/defaults/memory-promote-prompt.md +9 -0
- package/src/rules/lead/02-channels.md +3 -3
- package/src/runtime/channels/backends/discord.mjs +10 -3
- package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
- package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -1
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
- package/src/tui/dist/index.mjs +37 -3
- package/src/tui/engine/agent-job-feed.mjs +42 -3
- package/src/workflows/default/WORKFLOW.md +29 -38
- package/src/workflows/solo/WORKFLOW.md +15 -20
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
7
|
-
|
|
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.
|
|
@@ -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.
|
|
@@ -435,14 +435,21 @@ class DiscordBackend {
|
|
|
435
435
|
const msg = await ch.messages.fetch(messageId);
|
|
436
436
|
if (msg.attachments.size === 0) return [];
|
|
437
437
|
const results = [];
|
|
438
|
+
// Optional filter bounds what gets fetched (e.g. image/* only) so a
|
|
439
|
+
// caller after images never pulls unrelated large attachments.
|
|
440
|
+
const filter = typeof opts.filter === "function" ? opts.filter : null;
|
|
438
441
|
for (const att of msg.attachments.values()) {
|
|
439
|
-
const
|
|
440
|
-
results.push({
|
|
442
|
+
const meta = {
|
|
441
443
|
id: att.id,
|
|
442
|
-
path,
|
|
443
444
|
name: safeAttName(att),
|
|
444
445
|
contentType: att.contentType ?? "unknown",
|
|
445
446
|
size: att.size
|
|
447
|
+
};
|
|
448
|
+
if (filter && !filter(meta)) continue;
|
|
449
|
+
const path = await this.downloadSingleAttachment(att, opts);
|
|
450
|
+
results.push({
|
|
451
|
+
...meta,
|
|
452
|
+
path
|
|
446
453
|
});
|
|
447
454
|
}
|
|
448
455
|
return results;
|
|
@@ -10,6 +10,9 @@ import { isNetworkError, retryOnNetwork } from "./network-retry.mjs";
|
|
|
10
10
|
// preserving): serial inbound queue, backend.onMessage transcript (re)bind +
|
|
11
11
|
// steal logic, and handleInbound voice-transcription + parent notify. Bound to
|
|
12
12
|
// live runtime getters and shared primitives.
|
|
13
|
+
function isImageAttachment(contentType) {
|
|
14
|
+
return typeof contentType === "string" && contentType.toLowerCase().startsWith("image/");
|
|
15
|
+
}
|
|
13
16
|
export function createInboundHandler({
|
|
14
17
|
getBackend,
|
|
15
18
|
getConfig,
|
|
@@ -283,6 +286,46 @@ async function handleInbound(msg, route, options = {}) {
|
|
|
283
286
|
}
|
|
284
287
|
}
|
|
285
288
|
const hasVoiceAtt = voiceAtts.length > 0;
|
|
289
|
+
// ── Inbound image attachments → downloaded local paths (vision) ──────
|
|
290
|
+
// Mirror the voice path: download image/* attachments to the inbox and
|
|
291
|
+
// hand their local paths downstream so the agent session can attach them
|
|
292
|
+
// as real image content blocks. A short per-attempt timeout bounds the
|
|
293
|
+
// serial inboundQueue against a slow/broken image; on failure we degrade
|
|
294
|
+
// to the metadata-only marker (attMeta below) instead of dropping.
|
|
295
|
+
const imageAtts = msg.attachments.filter((a) => isImageAttachment(a.contentType));
|
|
296
|
+
let imagePaths = [];
|
|
297
|
+
if (imageAtts.length > 0) {
|
|
298
|
+
try {
|
|
299
|
+
const files = (await retryOnNetwork(
|
|
300
|
+
() => getBackend().downloadAttachment(msg.chatId, msg.messageId, {
|
|
301
|
+
timeoutMs: 20_000,
|
|
302
|
+
filter: (a) => isImageAttachment(a.contentType),
|
|
303
|
+
}),
|
|
304
|
+
{ label: "image.download" }
|
|
305
|
+
)) || [];
|
|
306
|
+
imagePaths = imageAtts
|
|
307
|
+
.map((a) => files.find((df) => df.id === a.id) ?? null)
|
|
308
|
+
.filter(Boolean)
|
|
309
|
+
.map((f) => f.path)
|
|
310
|
+
.filter((p) => typeof p === "string" && p.length > 0);
|
|
311
|
+
if (imagePaths.length > 0) {
|
|
312
|
+
process.stderr.write(`mixdog: inbound images downloaded (${imagePaths.length})\n`);
|
|
313
|
+
}
|
|
314
|
+
} catch (err) {
|
|
315
|
+
const netFail = isNetworkError(err);
|
|
316
|
+
process.stderr.write(`mixdog: image.download error${netFail ? " (network, retries exhausted)" : ""}: ${err}\n`);
|
|
317
|
+
const marker = netFail
|
|
318
|
+
? "[attachment: image download failed (network)]"
|
|
319
|
+
: "[attachment: image download failed]";
|
|
320
|
+
text = text ? `${text} ${marker}` : marker;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// An image-only message can arrive with empty text; channelNotificationModelContent
|
|
324
|
+
// drops empty content (runtime-core), which would strip meta.image_paths and lose
|
|
325
|
+
// the image. Give it a non-empty marker so the notification (and its images) flow.
|
|
326
|
+
if (imagePaths.length > 0 && !String(text || "").trim()) {
|
|
327
|
+
text = "[image]";
|
|
328
|
+
}
|
|
286
329
|
const attMeta = msg.attachments.length > 0 && !hasVoiceAtt ? {
|
|
287
330
|
attachment_count: String(msg.attachments.length),
|
|
288
331
|
attachments: msg.attachments.map((a) => `${a.name} (${a.contentType}, ${(a.size / 1024).toFixed(0)}KB)`).join("; ")
|
|
@@ -300,7 +343,8 @@ async function handleInbound(msg, route, options = {}) {
|
|
|
300
343
|
...route.sourceLabel ? { source_label: route.sourceLabel } : {}
|
|
301
344
|
} : {},
|
|
302
345
|
...attMeta,
|
|
303
|
-
...msg.imagePath ? { image_path: msg.imagePath } : {}
|
|
346
|
+
...msg.imagePath ? { image_path: msg.imagePath } : {},
|
|
347
|
+
...imagePaths.length > 0 ? { image_paths: JSON.stringify(imagePaths) } : {}
|
|
304
348
|
};
|
|
305
349
|
const notificationContent = messageBody;
|
|
306
350
|
sendNotifyToParent("notifications/claude/channel", {
|
|
@@ -333,7 +333,13 @@ export function createCycleScheduler(deps) {
|
|
|
333
333
|
try {
|
|
334
334
|
let c3Options = {
|
|
335
335
|
coalescedRetry: true,
|
|
336
|
-
onCoalescedSuccess: async () => {
|
|
336
|
+
onCoalescedSuccess: async (result) => {
|
|
337
|
+
// Only a real, error-free pass persists success; a run that returned
|
|
338
|
+
// an error (LLM/unparseable) must not stamp last_success_at.
|
|
339
|
+
if (result?.error) { markCycleDone('cycle3', false, result.error); return }
|
|
340
|
+
await setCycleLastRun('cycle3', Date.now())
|
|
341
|
+
markCycleDone('cycle3', true)
|
|
342
|
+
},
|
|
337
343
|
}
|
|
338
344
|
if (typeof c3Options?.callLlm !== 'function') {
|
|
339
345
|
c3Options = { ...c3Options, callLlm: getCycle3CallLlm() }
|
|
@@ -498,6 +504,16 @@ export function createCycleScheduler(deps) {
|
|
|
498
504
|
// so clear the marker (health/backlog reset to this process's state).
|
|
499
505
|
_cycleRunning = null
|
|
500
506
|
_writeCycleStateFile()
|
|
507
|
+
// Hydrate health success timestamps from the persisted per-cycle last-run
|
|
508
|
+
// meta. Without this, a restart re-inits _cycleHealth to last_success_at=0
|
|
509
|
+
// and the state file reports 0 until the next run — for cycle3 that is up
|
|
510
|
+
// to 24h later, so a genuinely-successful cycle3 looks like it never ran.
|
|
511
|
+
Promise.resolve(getCycleLastRun()).then((last) => {
|
|
512
|
+
if (last?.cycle1 > 0 && !_cycleHealth.cycle1.last_success_at) _cycleHealth.cycle1.last_success_at = last.cycle1
|
|
513
|
+
if (last?.cycle2 > 0 && !_cycleHealth.cycle2.last_success_at) _cycleHealth.cycle2.last_success_at = last.cycle2
|
|
514
|
+
if (last?.cycle3 > 0 && !_cycleHealth.cycle3.last_success_at) _cycleHealth.cycle3.last_success_at = last.cycle3
|
|
515
|
+
_writeCycleStateFile()
|
|
516
|
+
}).catch(() => {})
|
|
501
517
|
_scheduleNextCheck()
|
|
502
518
|
_startupTimeout = setTimeout(() => { void _runCheckCyclesGuarded() }, 30_000)
|
|
503
519
|
}
|
|
@@ -11,6 +11,65 @@ const LLM_JUDGE_CAP = 20
|
|
|
11
11
|
|
|
12
12
|
const TRANSIENT_PROMOTE_CATEGORIES = new Set(['task', 'issue'])
|
|
13
13
|
|
|
14
|
+
// Category grades the pipeline trusts outright: user/cycle1 tagged durable
|
|
15
|
+
// knowledge. These are NEVER content-scanned — a curated constraint like
|
|
16
|
+
// "95% pass threshold" or a rule that cites a metric must still promote.
|
|
17
|
+
const DURABLE_TRUSTED_CATEGORIES = new Set(['rule', 'constraint', 'decision', 'preference', 'goal'])
|
|
18
|
+
|
|
19
|
+
// Deterministic (non-LLM) signature for transient work-state: status/review
|
|
20
|
+
// churn and benchmark/measurement RESULT snapshots. Narrow on purpose — it
|
|
21
|
+
// requires snapshot phrasing (a measurement verb or an explicit result-metric
|
|
22
|
+
// noun), NOT a bare percentage, so SLO/threshold constraints don't trip it.
|
|
23
|
+
// Applied only to non-durable categories (e.g. 'fact'), catching benchmark
|
|
24
|
+
// snapshots mis-tagged as durable-ish. The gate prompt only *discourages*
|
|
25
|
+
// these; this is the structural enforcement.
|
|
26
|
+
const TRANSIENT_SNAPSHOT_RE = new RegExp(
|
|
27
|
+
[
|
|
28
|
+
'terminal-bench',
|
|
29
|
+
'\\bbenchmark(ed|ing|\\s+(run|result|score))\\b',
|
|
30
|
+
'pass@\\d',
|
|
31
|
+
'\\btokens?\\s?\\/\\s?s(ec)?\\b',
|
|
32
|
+
'status\\s+snapshot',
|
|
33
|
+
'review(er)?\\s+(validation\\s+)?cycles?',
|
|
34
|
+
'in[-\\s]progress',
|
|
35
|
+
'\\bWIP\\b',
|
|
36
|
+
'\\b(scored|achieved|measured|reached)\\b[^.]{0,40}\\d+(\\.\\d+)?\\s?%',
|
|
37
|
+
'\\d+(\\.\\d+)?\\s?%\\s+(pass\\s+rate|accuracy|score|throughput|latency)',
|
|
38
|
+
].join('|'),
|
|
39
|
+
'i',
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
// Structural promotion gate: is this row transient content (task/issue chatter
|
|
43
|
+
// or a status/benchmark snapshot) that must not be promoted pending→active?
|
|
44
|
+
// task/issue block by category. Durable knowledge categories are trusted and
|
|
45
|
+
// skip the content scan. Everything else (e.g. 'fact') is content-scanned so
|
|
46
|
+
// snapshots mis-categorized as durable are still blocked.
|
|
47
|
+
export function isTransientPromotion(row) {
|
|
48
|
+
if (!row) return false
|
|
49
|
+
const cat = String(row.category ?? '').toLowerCase()
|
|
50
|
+
if (TRANSIENT_PROMOTE_CATEGORIES.has(cat)) return true
|
|
51
|
+
if (DURABLE_TRUSTED_CATEGORIES.has(cat)) return false
|
|
52
|
+
return TRANSIENT_SNAPSHOT_RE.test(`${row.element ?? ''} ${row.summary ?? ''}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Strip pending→active promotions for transient content out of the status
|
|
56
|
+
// batch BEFORE the cap clamp. Blocked rows are simply dropped from the batch,
|
|
57
|
+
// so they stay pending (held for re-confirmation on a later cycle) — never
|
|
58
|
+
// archived here, and reviewed_at is still bumped via the caller's reviewedIds.
|
|
59
|
+
export function blockTransientPromotions(statusBatch, rowsById) {
|
|
60
|
+
if (!statusBatch?.length) return { batch: statusBatch ?? [], blocked: 0 }
|
|
61
|
+
let blocked = 0
|
|
62
|
+
const batch = statusBatch.filter((item) => {
|
|
63
|
+
if (item.was_pending && item.new_status === 'active'
|
|
64
|
+
&& isTransientPromotion(rowsById.get(Number(item.entry_id)))) {
|
|
65
|
+
blocked += 1
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
return true
|
|
69
|
+
})
|
|
70
|
+
return { batch, blocked }
|
|
71
|
+
}
|
|
72
|
+
|
|
14
73
|
// After the gate, cap how many pending→active promotions may land in one batch.
|
|
15
74
|
// Overflow stays pending (not archived). Tiebreak: durable category before
|
|
16
75
|
// task/issue, then score DESC, then older last_seen_at, then id ASC.
|
|
@@ -11,7 +11,7 @@ import { backfillCoreEmbeddings, nominateCoreCandidates, CORE_SUMMARY_MAX } from
|
|
|
11
11
|
import { markCycleRequest, consumeCycleRequests, resolveCoalesceMaxDrains, scheduleCoalescedCycleRetry, makeCycleRequestSignature, resolveCoalesceMaxRetries } from './memory-cycle-requests.mjs'
|
|
12
12
|
import { __mixdogMemoryLog, throwIfAborted } from './memory-cycle2-shared.mjs'
|
|
13
13
|
import {
|
|
14
|
-
applyBatchStatusVerdicts, clampPendingPromotions, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
|
|
14
|
+
applyBatchStatusVerdicts, clampPendingPromotions, blockTransientPromotions, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
|
|
15
15
|
} from './memory-cycle2-mutations.mjs'
|
|
16
16
|
import {
|
|
17
17
|
CYCLE2_ACTIVE_TARGET_CAP, CYCLE2_ACTIVE_MIN_FLOOR, loadCurrentRulesDigest, runUnifiedGate, sonnetCascade,
|
|
@@ -54,6 +54,7 @@ function mergeCycle2Results(a, b) {
|
|
|
54
54
|
rejected_verb: Number(a.rejected_verb || 0) + Number(b.rejected_verb || 0),
|
|
55
55
|
merge_rejected: Number(a.merge_rejected || 0) + Number(b.merge_rejected || 0),
|
|
56
56
|
missing_core_summary: Number(a.missing_core_summary || 0) + Number(b.missing_core_summary || 0),
|
|
57
|
+
promotion_blocked: Number(a.promotion_blocked || 0) + Number(b.promotion_blocked || 0),
|
|
57
58
|
core_embedding_backfill: Number(a.core_embedding_backfill || 0) + Number(b.core_embedding_backfill || 0),
|
|
58
59
|
core_candidates_nominated: Number(a.core_candidates_nominated || 0) + Number(b.core_candidates_nominated || 0),
|
|
59
60
|
rescore: mergeNestedNumeric(a.rescore, b.rescore),
|
|
@@ -211,6 +212,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
|
|
|
211
212
|
updated: 0, kept: 0, rejected_verb: 0,
|
|
212
213
|
merge_rejected: 0,
|
|
213
214
|
missing_core_summary: 0,
|
|
215
|
+
promotion_blocked: 0,
|
|
214
216
|
core_embedding_backfill: 0,
|
|
215
217
|
core_candidates_nominated: 0,
|
|
216
218
|
rescore: { updated: 0 },
|
|
@@ -523,8 +525,13 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
|
|
|
523
525
|
}
|
|
524
526
|
guardedBatch.push(item)
|
|
525
527
|
}
|
|
528
|
+
// Structural transient block runs BEFORE the cap clamp: task/issue chatter
|
|
529
|
+
// and status/benchmark snapshots can never promote pending→active, so they
|
|
530
|
+
// are stripped here (held pending) regardless of remaining cap slots.
|
|
531
|
+
const blockRes = blockTransientPromotions(guardedBatch, rowsById)
|
|
532
|
+
stats.promotion_blocked = blockRes.blocked
|
|
526
533
|
const activeCountForClamp = Math.max(0, activeCount - reservedDemotions)
|
|
527
|
-
const clampRes = clampPendingPromotions(
|
|
534
|
+
const clampRes = clampPendingPromotions(blockRes.batch, rowsById, activeCountForClamp, activeTargetCap)
|
|
528
535
|
stats.promotion_clamped = clampRes.clamped
|
|
529
536
|
const batchRes = await applyBatchStatusVerdicts(db, clampRes.batch, nowMs)
|
|
530
537
|
stats.promoted += batchRes.promoted
|
|
@@ -702,7 +709,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
|
|
|
702
709
|
` core_backfill=${stats.core_embedding_backfill}` +
|
|
703
710
|
` active=${activeCount}/${activeTargetCap} review_active=${reviewActiveRows ? 1 : 0}` +
|
|
704
711
|
` | gate promoted=${stats.promoted} archived=${stats.archived}` +
|
|
705
|
-
` promotion_clamped=${stats.promotion_clamped}` +
|
|
712
|
+
` promotion_clamped=${stats.promotion_clamped} promotion_blocked=${stats.promotion_blocked}` +
|
|
706
713
|
` updated=${stats.updated} kept=${stats.kept}` +
|
|
707
714
|
` rejected_verb=${stats.rejected_verb} merge_rejected=${stats.merge_rejected}` +
|
|
708
715
|
` missing_core=${stats.missing_core_summary}` +
|