The agent-sdk branch hardcoded maxTokens: 4096, ignoring the maxTokens
variable computed from env['MAX_TOKENS']. Other branches already use
the computed value. Make agent-sdk match.
Follow-up to CodeRabbit review on #188. detectProvider's agent-sdk
fallback at L93-97 hardcoded maxTokens: 4096, ignoring the maxTokens
variable computed from env["MAX_TOKENS"] at L45. Every other provider
branch (minimax, anthropic, gemini, openrouter) uses the computed
value; the hardcoded one was the odd one out. Replace with the
maxTokens identifier for consistency.
Rejecting the two lockfile-related findings: .gitignore on this branch
lists package-lock.json / pnpm-lock.yaml / yarn.lock, so the workflow
comments ("Lockfiles are gitignored at the repo level") are accurate
and the two-step install (npm install --package-lock-only then npm ci)
is the correct pattern. Reverting to a single npm ci would fail CI
because there is no committed lockfile to install from.
All six findings verified against current code on this branch; all valid.
1. observe.ts firstPrompt (L206-211) — raw.userPrompt is typed as
optional string but populated from payload.data which is unknown at
runtime; a non-string truthy value (array, object) would crash at
.replace(). Guard with typeof === 'string' before sanitizing, and
skip the update when the trimmed value is empty.
2. replay.ts deriveCrystalAndLessons — generateId('lesson') /
generateId('crystal') produced fresh IDs on every import, so
re-importing the same JSONL duplicated lessons and crystals. Switch
to fingerprintId:
- lessons keyed by fingerprintId('lesson', content.toLowerCase()) +
merge on existing (bump reinforcements, append sessionId to
sourceIds, union tags).
- crystals keyed by fingerprintId('crystal', sessionId) + preserve
original createdAt and sourceActionIds when upserting.
3. viewer/index.html crystals filter (L2686-2691) — search haystack
previously joined raw lesson IDs (lesson_xxx), so lesson text never
matched. Resolve each ID via state.crystals.lessonMap and include
resolved content in the haystack; also fold in filesAffected and
project so the search feels complete.
4. image-quota-cleanup.ts (L56-68) — when getImageRefCount threw, the
refCount variable stayed at its default 0 and the code happily
proceeded to deleteImage(). That could delete a still-referenced
image on any transient KV error. Fail-closed: log and return from
the withKeyedLock callback on error so deleteImage is never reached
without a confirmed refCount === 0.
5. types.ts / disk-size-manager.ts — add a StateScope interface that
maps KV.state scope keys to their value types
(system:currentDiskSize: number), export StateScopeKey, and have
disk-size-manager use StateScope[typeof DISK_SIZE_KEY] in kv.get /
kv.set generics instead of the ad-hoc <number>.
6. tsdown.config.ts + package.json — onnxruntime-node / onnxruntime-web
are not imported directly; they come in transitively via
@xenova/transformers. Keep them in tsdown's external array (bundling
them breaks the relative .node binding paths in dist/) and add both
to package.json optionalDependencies alongside @xenova/transformers
so the optional/lazy nature is explicit. Expand the tsdown comment
to reference the new optionalDependencies entries and name the exact
src files that lazy-import them.
812/812 tests pass.
Findings verified against current code on this branch; all four valid.
1. config.ts loadFallbackConfig (L281) — user could set
FALLBACK_PROVIDERS=agent-sdk and bypass the AGENTMEMORY_ALLOW_AGENT_SDK
gate added to detectProvider. Filter it out at the fallback layer too,
with the same warning pointing at the opt-in flag.
2. summarize.ts (L87-92) — the empty_provider_response branch returned
without recording failure metrics or a diagnostic log, unlike the
parse/validation paths. Record the same metricsStore failure event and
log provider name, prompt size, system size, and observation count so
empty responses are visible in telemetry.
3. providers/agent-sdk.ts (L14-45) — setting
process.env.AGENTMEMORY_SDK_CHILD = '1' without restoring it caused
every subsequent .query() in the same parent process to hit the
short-circuit guard and return '' (classified as a SDK child it is
not). Capture prev, set in try, restore in finally (delete if prev
was undefined). Child processes spawned during the for-await loop
still inherit the marker because env is inherited at spawn time; we
only restore after the loop completes.
4. plugin/scripts/sdk-guard-DI1NUOS9.mjs — tsdown extracted the shared
guard helper into a hashed chunk. Hash rotates on every rebuild and
churns the diff. Stopped using the shared module from hooks entirely
and inlined the 6-line guard function into each hook .ts file
instead. sdk-guard.ts stays in the tree because the unit tests cover
it directly. Deleted the tracked hashed .mjs and confirmed no new
chunk is emitted.
Also applied the CI two-step install (npm install --package-lock-only
then npm ci) on this branch, matching #184. Without it, npm ci fails
because lockfiles are gitignored.
Tests: 74 files / 819 tests pass.
Reported: a user with no provider API key and AGENTMEMORY_AUTO_COMPRESS=false
(which they believed protected them) hit unbounded recursion — Stop hook
POSTs /agentmemory/summarize, handler calls provider.summarize(), agent-sdk
provider spawns @anthropic-ai/claude-agent-sdk query(), which creates a full
CC-style child session that reads ~/.claude/settings.json, registers the
same plugin hooks, and fires its own Stop -> another child -> loop. ~579
ghost 'entrypoint: sdk-ts' sessions accumulated in a few minutes, draining
Claude Pro tokens.
#149 only added a stderr warning. AGENTMEMORY_AUTO_COMPRESS gated /compress
but never /summarize, so users who followed the warning's implied guidance
still got hit. Fix the loop at every layer:
1. config.ts detectProvider
- Treat empty-string provider keys (ANTHROPIC_API_KEY=) as unset; they
previously passed the truthiness check identically to a real key.
- Stop defaulting to agent-sdk. When no key is set, return a 'noop'
provider config and warn. Agent-sdk fallback now requires an explicit
AGENTMEMORY_ALLOW_AGENT_SDK=true opt-in with a loud second warning.
2. providers/noop.ts (new) + providers/index.ts
- NoopProvider implements MemoryProvider and returns empty strings for
compress and summarize so callers can detect .name === 'noop' and
short-circuit without spawning anything.
- Add ProviderType 'noop' and wire it through createBaseProvider.
3. providers/agent-sdk.ts
- Before spawning query(), check process.env.AGENTMEMORY_SDK_CHILD === '1'
and return '' instead of recursing. Set the env var to '1' before the
spawn so any child process (including the Agent SDK session's hooks)
inherits it.
4. hooks/sdk-guard.ts (new) + all 12 hook scripts
- Shared isSdkChildContext(payload) checks both AGENTMEMORY_SDK_CHILD=1
and payload.entrypoint === 'sdk-ts' (CC writes this into the stdin
jsonl for SDK-spawned sessions). Every hook script now bails early
when that returns true, so even if one guard layer fails the others
break the loop.
5. functions/summarize.ts
- Short-circuit with {success:false, error:'no_provider'} when
provider.name === 'noop' — never reach .summarize().
- Treat an empty provider response as empty_provider_response instead
of trying to parse it.
Tests: 74 files / 819 tests pass (+7 new in stop-hook-recursion-guard.test.ts).
Defense in depth means any ONE of the five layers breaks the loop.
- Crystals tab: add header card explaining what crystals are; redesign
each card with narrative, pills (project/session/counts), tool badges,
files list, and resolved lesson content (fetched via /lessons and
mapped by id) instead of showing raw lesson UUIDs.
- Lessons tab: add header card explaining the rule/confidence/decay
model so users understand the low-confidence auto-import tag.
- Sessions tab: clicking a session now fetches its observations and
renders a full detail panel — preview callout, 4-card grid
(observations / tools / files / duration), top tool bar chart,
activity type breakdown, file list, metadata block, actions row.
- Pull in lessons when loading crystals so auto-generated lessons are
actually readable in context.
import-jsonl now runs lightweight heuristic extraction on imported
sessions so the Lessons and Crystals tabs populate from replay data
instead of staying empty until a user manually invokes the MCP tools.
- Lessons: regex-match imperative patterns in user/assistant text
(always/never/don't/prefer/avoid/caveat) and save top 20 per session
with low confidence (0.4) and an 'auto-import' tag so they're clearly
derived rather than hand-curated.
- Crystals: one crystal per imported session — narrative = first user
prompt or observation preview, keyOutcomes = distinct tool names,
filesAffected = extracted file paths, lessons = derived lesson IDs.
- Actions stay deliberately empty (user-initiated goals, not tool logs).
- Semantic/procedural still require consolidation pipeline summaries.
- import-jsonl: run synthetic compression + BM25 index per observation
(previously raw obs never got indexed; search/summaries never saw them)
- import-jsonl + observe: capture first userPrompt as session.firstPrompt
so sessions list shows what each session was about, not just an ID
- api::audit: wrap bare array in { entries, success } — viewer expected
{ entries } shape and was rendering empty despite populated data
- api::replay::sessions: call kv.list directly instead of trigger hop
(was timing out at 10s+ with 600+ sessions)
- viewer sessions tab: show firstPrompt preview under each session
- viewer memories tab: fix strength display (7 * 100 = 700%; now scaled
properly for both 0-1 and 0-10 representations, clamped to 100)
- viewer memories tab: add explainer card describing what memories are
- viewer WS: add 5s connect timeout so stuck WS upgrades fall through
to polling mode instead of showing CONNECTING... forever
Policy: never commit lock files. Downstream developers and CI
regenerate them at install time. Committing them creates churn on
every transitive-dep bump and masks real dependency changes in PRs.
The v0.9.1 dist was dead on arrival. Three independent bugs layered:
1) `getContext` was re-imported during PR #111 conflict resolution in
three files (compress.ts, disk-size-manager.ts, image-quota-cleanup.ts).
iii-sdk v0.11 dropped that export — the shim in src/logger.ts
documents the fix. Tests mocked iii-sdk so `npm test` passed, but
`node dist/cli.mjs` crashed on first import. Removed the imports,
switched to the module-level logger.
2) The two PR #111 files also used the
`registerFunction({ id, description }, handler)` shape. iii-sdk
0.11.2 only accepts `registerFunction(id, handler, options?)`. The
dist imports succeeded but the runtime registration threw "not a
function" on the test mock. Collapsed to the flat form.
3) `KV.state` was dropped from src/state/schema.ts during the same
conflict resolution, which broke the disk-size-manager's persistence
key. Restored `state: "mem:state"` — used only by this manager.
4) tsdown was inlining onnxruntime-node and @xenova/transformers into
dist/. That rewrites the relative require() inside
`onnxruntime-node/dist/binding.js` so it can't find
`../bin/napi-v3/darwin/arm64/onnxruntime_binding.node` from dist/.
Even without AGENTMEMORY_IMAGE_EMBEDDINGS the module graph still
evaluated the bundled binding.js on startup and blew up. Added both
packages (plus onnxruntime-web and the two Anthropic SDKs) to
`external:` in tsdown.config.ts. Bundle shrank from 6.1 MB to
1.9 MB. The CLIP / local embedding providers lazy-load them from
node_modules where relative paths work.
5) Bumped iii-sdk 0.11.0 → 0.11.2 to match the API currently shipped
(Logger / durable:subscriber / durable:publisher / TriggerAction.void).
6) test/multimodal.test.ts used the old `{ id, description }` mock
shape — rewrote the four registerFunction mocks to match the real
`(id, cb)` signature. 812/812 pass.
End-to-end smoke test with AGENTMEMORY_SLOTS=true AGENTMEMORY_REFLECT=true:
- livez → 200 ok
- GET /slots → all 8 defaults seeded into correct scopes (persona /
user_preferences / tool_guidelines in global; rest in project)
- POST /slot (invalid sizeLimit / unknown scope) → 400 with specific
error
- POST /slot/append overflow → 413 with currentSize + sizeLimit
- POST /slot/reflect on empty session → no-op
- GET /audit → every slot write + reflect emits an audit row
- POST /vision-search without flag → 503 disabled
## Concurrency
- mem::slot-create now wraps the duplicate check + set + audit inside
withKeyedLock. Previously a racing slot-append could create a slot
that slot-create assumed was absent.
- mem::slot-delete acquires the same slot-level lock writers use, so
an in-flight append/replace cannot resurrect a deleted slot.
- mem::slot-reflect now reads pending_items / session_patterns /
project_context INSIDE their per-slot locks, computes the new
content under the lock, and writes back under the same lock.
The old path read-then-locked-then-wrote, which could clobber a
concurrent slot-replace on the same label.
## Scope + validation
- mem::slot-create duplicate check is now scope-local via
readSlotInScope(). A project slot with the same label as a global
slot is allowed and shadows the global on read (matches the read
precedence). Before, create rejected any label that already existed
in either scope.
- validateScope() returns null on unknown scope values; handler
returns "scope must be 'project' or 'global'" 400 instead of
silently defaulting.
- validateSizeLimit() returns null on non-integers, negatives, or
values > 20000; handler returns "sizeLimit must be an integer
between 1 and 20000" 400 instead of silently falling back to
DEFAULT_SIZE_LIMIT.
- Timestamp captured once (ts) then used for createdAt + updatedAt,
both in the create path and in seedDefaults.
## HTTP surface
- api::slot-create now rejects bad types up front:
content / description must be strings, pinned must be a boolean,
scope must be "project" or "global", sizeLimit must be a positive
integer <= 20000. Malformed inputs get a 400 with a specific error
message instead of being silently dropped.
## MCP surface
- memory_slot_create accepts both boolean and string-boolean forms
for pinned (MCP clients bind either depending on their schema
wrapper). Same for scope, which now also accepts explicit "project".
## Other cleanup
- event::session::stopped uses isReflectEnabled() helper instead of
poking process.env directly. triggerVoid call wrapped in try/catch
with a warn log so a reflect failure doesn't surface as an
unhandled rejection.
- src/index.ts boot banner computes MCP tool count via
getAllTools().length instead of hardcoding 44 — banner now tracks
the actual registry size.
- README counts bumped: "all 43 memory tools" → "all 50 memory tools"
(x2), "### 44 Tools" → "### 50 Tools", "Extended tools (44 total" →
"(50 total", "646 tests" → "800 tests" (x2).
## Tests
- test/slots.test.ts: shadow test now goes through the public handler
API instead of direct kv.set. Two new cases: invalid sizeLimit
(over max, negative, non-integer) rejected; unknown scope rejected.
- Full suite: 802/802 pass (+2 new).
Introduces memory slots as a new first-class memory primitive, orthogonal
to observations / memories / graph. A slot is a labelled, size-limited,
agent-editable chunk of context. 8 defaults seed on first run
(persona, user_preferences, tool_guidelines, project_context, guidance,
pending_items, session_patterns, self_notes). Agent reads/writes via
MCP tools; pinned slots serialise cleanly into SessionStart context.
Functions (`src/functions/slots.ts`):
- mem::slot-list
- mem::slot-get
- mem::slot-create
- mem::slot-append (refuses writes over sizeLimit)
- mem::slot-replace (refuses content over sizeLimit)
- mem::slot-delete
- mem::slot-reflect (used by idle reflection)
REST (`src/triggers/api.ts`):
- GET /agentmemory/slots
- GET /agentmemory/slot?label=...
- POST /agentmemory/slot
- POST /agentmemory/slot/append
- POST /agentmemory/slot/replace
- DELETE /agentmemory/slot?label=...
- POST /agentmemory/slot/reflect
MCP tools (`src/mcp/tools-registry.ts`, `src/mcp/server.ts`):
- memory_slot_list
- memory_slot_get
- memory_slot_create
- memory_slot_append
- memory_slot_replace
- memory_slot_delete
event::session::stopped fires mem::slot-reflect via triggerVoid when
AGENTMEMORY_REFLECT=true. The reflector reads the last N observations,
extracts TODO-flavoured titles into pending_items, counts error /
command types into session_patterns, and records touched files in
project_context. Fire-and-forget: does not block summarise.
Slots carry a scope field ("project" | "global"). Global slots live at
KV.globalSlots ("mem:slots:global") — one brain across all projects for
things like persona / user_preferences / tool_guidelines. Project-
scoped slot with the same label shadows the global one on read.
- AGENTMEMORY_SLOTS=true — enable the whole primitive
- AGENTMEMORY_REFLECT=true — enable Stop-hook reflection (requires SLOTS)
Both default OFF. Matches the opt-in convention of auto-compress (#138),
inject-context (#143), vision embeddings (#179).
44 MCP tools → 50 across README (stats badge, install instructions,
config block) and plugin/.claude-plugin/plugin.json description. New
AGENTMEMORY_SLOTS and AGENTMEMORY_REFLECT entries in the config block.
test/slots.test.ts — 12 new cases: default seeding by scope, label
validation, create/get/append/replace/delete round-trip, duplicate-
create rejection, sizeLimit enforcement on append + replace, project
shadows global, listPinnedSlots + renderPinnedContext, reflect no-op
on empty session, reflect updates pending_items / session_patterns /
project_context from TODO + error + file signals.
Full suite: 800/800 pass (+12 new). Build clean.
The Stats component rendered "0" as initial text for every metric, then
animated up to the real target via IntersectionObserver on scroll. For
real users on a real browser, the animation fires and the numbers
count up correctly. For anything that doesn't scroll — SEO crawlers,
headless automation, link previewers, screenshot bots — the page
permanently shows 0% retrieval, 0 MCP tools, 0 hooks, 0 tests.
Fix: initial SSR text is the target value (44, 12, 809, 95.2%, 92%, 0).
The count animation still plays on scroll because the RAF tick in the
IntersectionObserver callback overwrites the text unconditionally. No
behaviour change for human users — just stops crawlers seeing zeros.
Also ignore .gstack/ (local session data from the gstack browse skill).
The landing page kept reverting to "V0.0.0" on every release. Root
cause: website/lib/meta.ts resolved repo files at runtime via
import.meta.url, and after Next.js compiles server components the URL
resolves inside .next/server/. `../..` from there lands in the build
cache, not at the repo root, so the package.json read fails silently
and `pkg?.version ?? "0.0.0"` kicks in.
- website/scripts/gen-meta.mjs: new build-time script. Runs from
website/scripts/, walks one level up to find the real repo root,
reads package.json + src/mcp/tools-registry.ts +
src/triggers/api.ts + src/types.ts + test/ and writes
website/lib/generated-meta.json. Fails loud if version is missing
(no silent 0.0.0 ever again).
- website/package.json: `predev` and `prebuild` hooks run gen-meta.
- website/lib/meta.ts: collapsed from 117 lines of runtime file
resolution to a static JSON import. Next.js bundles the values
directly into the server component output.
- website/lib/generated-meta.json: seeded with current values so
editors don't complain about the missing import target.
Verified locally: `.next/server/app/index.html` now contains
"ZERO EXTERNAL DATABASES · v0.9.1" instead of "v0.0.0".
CodeRabbit major finding on mem::vision-embed: an authenticated caller
could POST { "imageRef": "/etc/passwd" } and the CLIP provider would
happily readFile that path and store its vector. Same vector on the
queryImageRef path of mem::vision-search. Arbitrary filesystem probing
via a REST endpoint.
- src/functions/vision-search.ts: both handlers now require the
incoming ref to satisfy isManagedImagePath() and to exist in
KV.imageRefs. Either check failing short-circuits with a specific
error. Only paths that went through the managed image store (and
still have at least one live reference) can be embedded or used as
a query target.
- src/functions/vision-search.ts: topK is now normalised before
clamping. `Math.max(1, NaN)` is NaN, so `slice(0, NaN)` returned
nothing for malformed input. Coerce to finite integer, default 10,
then clamp to [1, 50].
- test/vision-search.test.ts: imageRefs use the managed path, seed
KV.imageRefs before each embed, and add coverage for
managed-path rejection, unregistered-ref rejection, queryImageRef
rejection, and NaN/fractional topK clamp. 10/10 pass locally, 798
in the full suite.
- src/functions/image-refs.ts: delete imageEmbeddings BEFORE imageRefs
and drop the silent .catch() so transient KV errors surface. Stops
orphan searchable embeddings when the ref delete fails halfway.
- src/functions/vision-search.ts: record an audit row for every
successful mem::vision-embed write (new "vision_embed" op). State-
changing functions must leave an audit trail per the repo policy.
- src/functions/vision-search.ts: mem::vision-search now wraps raw
queryImageBase64 into a data: URL before handing to embedImage, so
the provider contract stays "data: URL or filesystem path".
- src/providers/embedding/clip.ts: drop the raw-base64 heuristic in
loadImage. The old regex whitelisted "/" in the char class but the
!src.includes("/") guard rejected any realistic base64 blob,
falling through to readFile and throwing ENOENT. Now: data: URL or
filesystem path only; callers with raw base64 pass a data: URL.
Also drop unused RawImage.read type and collapse the redundant
Float32Array copy in embedImage.
- src/triggers/api.ts: both handlers now whitelist fields with
asNonEmptyString and parseOptionalPositiveInt before constructing
the payload. Matches api::observe / api::search style, stops
forwarding the raw req.body, clamps topK at the boundary.
- src/types.ts: add "vision_embed" to AuditEntry.operation union and
rename EmbeddingProvider.embedImage's param to src (matches the
implementation now that raw base64 isn't accepted).
- README.md, plugin/.claude-plugin/plugin.json: document
AGENTMEMORY_IMAGE_EMBEDDINGS and bump tool count string.
Full suite still 794/794.
Builds on PR #111's image store. Adds a dedicated visual embedding
path so agents can search screenshots by natural language or by
similar image, without routing through VLM-describe → BM25-text.
## Additions
- `EmbeddingProvider.embedImage?(src)` slot (non-breaking, optional)
- `ClipEmbeddingProvider` — CLIP ViT-B/32 via `@xenova/transformers`
(already a dep). 512d, normalized. Supports base64, data URL, and
file paths. Lazy model load; same pattern as the existing local
text provider.
- `createImageEmbeddingProvider()` gated on
`AGENTMEMORY_IMAGE_EMBEDDINGS=true` — opt-in, matches the
auto-compress/context-injection opt-in pattern from #138 and #143.
- `KV.imageEmbeddings` scope keyed by imageRef path.
- `mem::vision-embed` + `mem::vision-search` functions. Cosine
similarity, brute-force over the stored set (fine at image-memory
scales; ANN can come later).
- `POST /agentmemory/vision-embed` + `POST /agentmemory/vision-search`
HTTP triggers with auth + 400/503 error shapes.
- MCP tool `memory_vision_search` exposed via the running-server
handler. Accepts queryText, queryImageRef, queryImageBase64, topK,
sessionId.
- Observe pipeline: when an image is stored and the flag is on,
fires `mem::vision-embed` via triggerVoid so embedding happens off
the critical path.
- `decrementImageRef` sweeps the embedding too when refcount hits
zero, so forget/evict/retention stop orphaning vectors.
## Why
Mem0/Zep/Letta are text-only in 2026. AgenticVision ships CLIP but
needs manual capture. agentmemory already auto-captures via hooks —
bolting CLIP onto that pipeline makes it the only memory system
where the agent sees what it sees and can cross-modal retrieve
without manual invocation.
Full plan: local spec at ~/specs-backup/agentmemory-multimodal-plan.md.
## Tests
- test/vision-search.test.ts: 6 new cases covering embed→store, text
query ranking, image-to-image query, sessionId filter, provider-
absent error, missing-query rejection.
- Full suite: 794/794 pass (+6 new).
Resolves conflicts in:
- src/state/schema.ts (merged imageRefs + accessLog)
- src/functions/auto-forget.ts (kept audit, reordered ref decrement)
- src/functions/compress.ts (collapsed iii-sdk imports, s/ctx.logger/logger/)
- src/functions/evict.ts (kept audit + deleteAccessLog, deferred ref
decrement until after delete succeeds)
- src/functions/retention.ts (kept source-aware delete routing, fetch mem
from resolved scope, defer ref decrement)
- src/triggers/api.ts (kept main's registerFunction/trigger shape)
- src/types.ts (kept superset of AuditEntry.operation union)
qodo-ai action-required findings:
- mem::forget now decrements image refs for every deleted memory and
observation (including the session-wipe branch), which stops sensitive
screenshots leaking to ~/.agentmemory/images/ after user-initiated
forget.
- Eviction / auto-forget now decrement refs only after the KV delete
actually succeeds. The old order (decrement-then-delete) could
desync ref counts when the delete threw.
Follow-on fixes:
- buildSyntheticCompression now carries modality and imageData through
from raw to compressed, so the default zero-LLM path doesn't drop
image metadata the viewer and governance paths rely on.
- Test harness updated for the current registerFunction (name, cb)
signature and iii-sdk partial-mock pattern — 11/11 multimodal
tests pass, full suite 788/788.
Closes#64.
Review on #178:
- startPolling now counts ticks and every 6 ticks (~60s) resets the
retry counters and re-invokes connectWs() iff there's no live ws
instance. If iii-stream eventually binds 3112, the viewer promotes
itself back to live WS without a page reload.
- startDashboardAutoRefresh's 30s timer now skips when pollTimer is
active — polling already refreshes the dashboard on its own
interval, no need for a second pass 5s later.
- The `wsRetries >= WS_MAX_RETRIES` guard in connectWs is now load-
bearing: it protects the retry sequence itself, and the polling loop
resets the counter before the next probe so attempts resume cleanly.
iii v0.11.1's iii-stream worker doesn't actually bind the configured
3112 port (lsof confirms only 3111/3113/49134 listen), so the viewer's
ws://localhost:3112 connection can never succeed. The old code retried
forever and kept the header flashing "reconnecting..." red.
Behavior now:
- WS_MAX_RETRIES dropped from 10 to 4 (~20s of retries instead of
~90s).
- After retries exhaust, startPolling() kicks in and refreshes the
dashboard/memories/sessions/activity view on a 10s interval via the
existing REST endpoints. Badge reads "polling · 10s" in neutral
grey.
- Label during retry loop changed from "reconnecting..." to
"connecting..." to avoid the alarm red state on first load.
- WS reconnecting if it ever succeeds stops the poll timer.
## Health: heap_tight note vs alert
CHANGELOG v0.9.0 (#160) said the sub-floor heap_tight marker is a
"non-alerting note attached to the snapshot — visibility without the
false positive", but the implementation still pushed it into the same
alerts[] array as real critical/degraded conditions. Viewer rendered
it under a red "Alerts (1)" card on fresh installs (~108 MB RSS is
below the 512 MB floor).
- thresholds.ts now returns { status, alerts, notes }, with heap_tight
routed to notes[].
- HealthSnapshot gains optional notes: string[].
- Viewer: new "Notes (N)" card in neutral color next to Alerts. Heap
gauge only reddens when RSS is above the 512 MB floor — below the
floor, heap percent is informational.
- Test suite updated to assert heap_tight lives in notes, not alerts.
## Demo readiness probe
`npx @agentmemory/agentmemory demo` 404'd on /agentmemory/session/start
when iii-engine was up but the agentmemory worker hadn't registered
(e.g., right after a shutdown that left iii lingering). isEngineRunning
probed `/` and accepted any response, including iii's own 404.
Split the probe: isEngineRunning still probes root (used by main/start
flow to detect whether to spawn iii), and a new isAgentmemoryReady
probes /agentmemory/livez with res.ok. runDemo now uses the stricter
probe with a specific error explaining the distinction.
Addresses review findings on #174:
- Livez probe now captures probe.status + body snippet on non-OK
responses and the exception message on fetch throws, then prints the
distinct reason: "unreachable (...)" vs "reachable but unhealthy
(HTTP 503: ...)". No more flat "not running" for every failure mode.
- Import success check now requires json.success === true instead of
treating a missing success field as success. A 2xx empty body used
to fall through and print "imported 0 file(s)" — it now reports
"response missing success field" and exits non-zero.
- Fallback releases URL pointed at iii-dev/iii-engine, which doesn't
exist. Corrected to iii-hq/iii/releases/latest — the same URL used
in README and the demo command.
## import-jsonl
Two failure modes surfaced as "Unexpected end of JSON input":
- Livez probe only caught fetch throws, not non-OK responses. A stray
service on port 3111 would pass the probe silently, then POST would
404 with an empty body and crash on res.json().
- res.json() is unforgiving: an empty body, HTML error page, or proxy
response exits with a cryptic parse error instead of the actual HTTP
status.
Now:
- Probe checks res.ok before continuing, with a start-the-server hint.
- Import reads body as text, parses only if non-empty, and surfaces
HTTP 401 (secret mismatch) and 404 (old server predating v0.8.13)
with actionable guidance.
## upgrade
cargo install iii-engine fails because iii-engine isn't on crates.io,
and requireSuccess exited the whole upgrade before Docker ever ran.
Switch to the official installer used everywhere else in the docs and
demo command:
curl -fsSL https://install.iii.dev/iii/main/install.sh | sh
Installer failure is treated as optional — Docker pull still runs and
the final note points at the Docker + release-binary fallbacks.
- src/version.ts: collapse literal union to single `VERSION = "0.9.0"`;
the historical enum was regenerated on every release and served no
runtime purpose. Export-import keeps its supportedVersions set, which
is the actual compat contract.
- src/viewer/index.html: replace hardcoded `v0.7.0` brand badge with
`__AGENTMEMORY_VERSION__` placeholder.
- src/viewer/document.ts: substitute the placeholder with VERSION at
render time (same mechanism as the CSP nonce).
- README.md: "New in v0.8.2" banner was two releases stale. Replace with
v0.9.0 highlights linking to CHANGELOG.
Dashboard viewer called these three list endpoints but only POST
/relations existed and /semantic + /procedural were never registered,
so the frontend received 404/405 and rendered empty dashboard cards
even when data was present.
Adds:
- api::semantic-list GET /agentmemory/semantic -> { semantic }
- api::procedural-list GET /agentmemory/procedural -> { procedural }
- api::relations-list GET /agentmemory/relations -> { relations }
Integration tests assert each endpoint returns 200 with an array.
Reported by Jay Ferguson running agentmemory with Hermes.
Mobile (375px) had ~55px horizontal scroll caused by grid tracks
defaulting to min-content across nowrap text in Install, AgentInstall,
and CommandCenter sections. Replace implicit/`1fr` tracks with
`minmax(0, 1fr)` so tracks can shrink below their content and let
`overflow: hidden` / `text-overflow: ellipsis` actually clip.
Also tightens padding, font-size, and letter-spacing at <=640px for
Install boxes, AgentInstall snippet head/code/copy row, CommandCenter
tabs and launch strip, and Stats tiles so dense uppercase content stops
crowding itself at mobile widths.
Adds app/opengraph-image.tsx and app/twitter-image.tsx using
next/og ImageResponse, statically prerendered at build (no edge
runtime) so socials get a real 1200x630 preview instead of the blank
card the site was shipping before.
- testsPassing no longer hardcoded to 777. New countTestCases() walks
the test/ tree and counts it()/test() call sites at build. Prerender
now shows the live count (currently 795) instead of a stale literal.
- restEndpoints regex tightened from /api_path:/ to
/config:\s*\{\s*api_path:\s*"/. Comments, example strings, and
docstrings no longer inflate the count.
- Fallback defaults consolidated into one exported DEFAULT_META
constant. Each count applies its fallback in exactly one place (at
the return, if the safeCountMatches result is 0). No more double
defaults.
- Hook counting no longer divides quote characters by 2. It parses
the HookType union body, splits on '|', and filters to string-
literal members — robust to inline comments or quotes elsewhere in
the union.
- Stats, Features, and LiveTerminal props are now required and take
their fallbacks from DEFAULT_META via getProjectMeta(). No
per-component duplication.
- Stats effect resets data-done on every rerun so a meta change at
build replays the count animation against the new target instead
of stranding a stale value.
- CODE_OF_CONDUCT.md: add an external escalation fallback chain for
reports that concern the only active Maintainer or where every
listed Maintainer recuses. Routes to the Contributor Covenant
ombudsperson, a future hosting foundation's conduct committee, or
GitHub Trust & Safety — so no report dead-ends.
- SECURITY.md: drop the 'do not encrypt' advice. GHSA is the
preferred channel; email is now framed as an encrypted-only
fallback with explicit GPG / SSH key publication URLs for
reporters who need end-to-end confidentiality.
- SECURITY.md: grammar fix — 'after a new minor is released'.
- CONTRIBUTING.md: replace brittle '777+ tests' with 'the full test
suite'; keep the integration-test exception.
- GOVERNANCE.md: replace placeholder link label with a plain
'release process' label.
- MAINTAINERS.md: drop the contributor shortlist — premature to
name future Maintainer candidates before they've been through the
formal process. Keep the contributor-graph link for transparency.