* fix(setup): guard plugin deps on completeness, not node_modules existence
`ensurePluginDependencies()` decided whether to run `bun install` by asking
whether `node_modules/` existed. A tree that is merely present — but short
of the declared closure — satisfied that check and permanently skipped
repair on every subsequent Setup run.
Two trigger paths, and the second needs no corruption at all:
1. An install interrupted mid-fetch (network timeout, OOM, registry 5xx)
leaves `node_modules/` behind incomplete.
2. A tree that was complete *for the version that created it*. `zod` was
added to plugin deps after some users had already installed; their
node_modules has been incomplete ever since, and no upgrade heals it
because the stale tree is gitignored and gets re-seeded into each new
cache version.
The worker then dies at boot on `Cannot find module 'zod/v3'` while memory
search keeps working — `mcp-server.cjs` bundles zod (build-hooks.js:519
hard-fails if it ever externalizes it) while `worker-service.cjs` has 19
external zod requires. So the plugin looks alive while capture is dead.
One reporter lost ~4 months of capture with no visible symptom.
Guard on completeness instead: every key of `package.json` `dependencies`
must resolve, with a `<dep>/package.json` fallback for bin-only packages
like tree-sitter-cli (gh #2730), plus the zod subpaths the worker requires.
This mirrors `verifyCriticalModules` (src/npx-cli/install/setup-runtime.ts:245),
which already applies exactly this contract on the npx install path but was
never reachable from the Setup hook. It cannot be imported here — this
script is standalone and dependency-free — so the probe is inlined and the
two are cross-referenced.
Two consequences fall out:
- The post-failure `rmSync` of node_modules is removed. It existed only
because the existence guard would otherwise block retry forever; the
completeness guard re-detects the gap on the next run, so deleting bought
nothing while actively destroying a partial tree that still powers search.
- A zero exit from `bun install` is no longer trusted. It can exit 0 with a
failed integrity check, so the closure is re-probed afterwards and the
diagnostic reports what actually resolves.
The install diagnostic now names the unresolvable modules, which is what
stops this failure mode from being silent.
Fixes#3755. Refs #3604 (plan-16), #2730.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NT5K64VU4a7Kbc36oTVjyc
* fix(setup): keep the completeness probe inside the plugin's own node_modules
Greptile P1 on #3872, and it was right.
`require.resolve(dep, { paths: [nodeModulesPath] })` reads as tree-scoped
but is not. `paths` only seeds Node's lookup; resolution then walks every
ancestor directory and always consults the global folders
($HOME/.node_modules, $PREFIX/lib/node). Plugin roots live at
~/.claude/plugins/cache/thedotmack/claude-mem/<version>/, so a copy of a
dependency anywhere above them — or installed globally — answered for the
plugin's own.
Verified against the committed probe: a plugin whose node_modules is
completely EMPTY, with a valid zod one directory up, exits 0 with no output
and never runs the install. That is the #3755 bug reintroduced by the fix
for it, and it would have shipped silently.
Presence is now checked by statting `<node_modules>/<dep>/package.json`
directly, which cannot escape the tree. This is also the signal
scripts/check-postinstall-allowlist.js:75-78 already uses, and it handles
scoped names (split into path segments) and bin-only packages like
tree-sitter-cli (package.json present, no entry point — gh #2730) without
the bare-name/fallback dance.
zod's subpaths still need real resolution, since they are `exports` entries
that a present directory does not guarantee. Those are now accepted only
when the resolved file lands inside the plugin's own zod directory. Both
sides are realpath'd before comparison: bun can materialise node_modules
entries as links into a shared store, and Node returns the real path of
what it resolved, so a literal comparison would report a healthy linked
install as missing and loop the install forever. Containment uses
path.relative rather than string prefixing so a sibling like zod-extra is
not mistaken for being inside zod.
Regression test added; it fails against the previous commit's probe.
Note for follow-up: verifyCriticalModules (setup-runtime.ts:245) has the
same ancestor/global escape. It is far less dangerous there — a post-install
assertion that fails loud, not the gate deciding whether repair runs at all
— but worth tightening.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NT5K64VU4a7Kbc36oTVjyc
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE_MEM_OPENROUTER_MODEL has always accepted an array, and
normalizeOpenRouterModel has always comma-joined it into a single `model`
string. OpenRouter rejects that: there is no model id containing a comma, so
the joined form is never anything a user asked for. Meanwhile OpenRouter's
native `models` fallback array — the mechanism that actually expresses "try A,
fall back to B" — was never used. That is #3829 item 3.
The first configured entry becomes `model` and the rest become the fallback
array, in priority order. A comma- or whitespace-separated STRING is the same
mistake typed a different way, so it is split too; blanks and repeats are
dropped, since a repeat would spend a fallback slot re-trying the model that
just failed. A non-string scalar still resolves to the shipped default, as
before.
Per OpenRouter's documented shape, `models` REPLACES `model` rather than
accompanying it, so the body carries one or the other and never both. The
request body moves into an exported buildOpenRouterRequestBody so that shape is
assertable without a network round trip — getting it wrong would fail silently
at exactly the moment failover was supposed to help.
`models` is sent only to openrouter.ai. A custom gateway reached through
CLAUDE_MEM_OPENROUTER_BASE_URL speaks plain OpenAI, where an unknown body field
is a 400 — the same reason `usage: { include: true }` is already gated. Such a
gateway now gets the first model instead of a rejected comma-joined string.
A single configured model is untouched: same `model` field, same body, no
`models` key. That is every install that has not opted in.
This addresses only item 3 of #3829. The per-attempt timeout and max_tokens
knobs (items 1-2, and #3794 / #3808 / #3796) and the unbounded observer history
(item 4) are separate. Note that the default this still falls back to,
xiaomi/mimo-v2-flash:free, is the deprecated id #3662 is about.
Co-authored-by: nasif-naseef <naseef771@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
When the worker daemon dies out-of-band (crash, taskkill /F without /T), its chroma-mcp sidecar chain (uvx -> uv -> python) survives holding the inherited listening socket. The port stays LISTENING under the dead worker PID and every launcher refuses to start ('Port already in use, refusing to start duplicate' / 'Port in use but worker not responding to health checks') forever — hooks thrash until a human tree-kills the sidecar chain by hand (plan-15 #3603; reproduced 2026-09-07 and again by the probe that shaped this fix).
Changes:
- src/shared/port-reclaim.ts: detect a listener whose owning PID is dead and kill the surviving sidecar chain. Targets are found two ways: walking the parent chain down from the dead PID (Windows preserves parent links after death), plus a full-table scan matching the sidecar's --data-dir argument for the broken-chain case where uvx/uv exited on pipe EOF and only chroma-mcp/python survive one or two links below a dead PID. Every kill carries the start token from the same table read that discovered the target; a live owner is never touched.
- daemon duplicate gate + ensureWorkerStarted: reclaim the ghost before refusing to start, and clear the spawn cooldown when the reclaim succeeded (the cooldown's reason is gone).
- HealthMonitor: bounded (5s) HTTP probes so a ghost listener — which accepts TCP but never responds — cannot hang the liveness checks forever.
- Windows integration gate (worker-ghost-port-recovery): a detached fixture worker with a real chroma chain is killed out-of-band; the production ensureWorkerStarted must reclaim the port and start a replacement. Fails on main, where no reclaim exists.
- Unit tests for the parser, the ownership fingerprint and every reclaim decision branch.
Claude Code stores per-profile credentials on macOS under
"Claude Code-credentials-<sha256(configDir)[:8]>"; only the default
~/.claude profile uses the bare name. The reader used one fixed constant,
so a worker running under a named CLAUDE_CONFIG_DIR read the default
item, and the spawned SDK subprocess inherited whatever CLAUDE_CONFIG_DIR
the worker's own environment carried.
- paths.ts: DEFAULT_CLAUDE_CONFIG_DIR (literal default, env-independent)
- SettingsDefaultsManager / SettingsRoutes / docs: new setting
CLAUDE_MEM_CLAUDE_CONFIG_DIR (empty = fall through to env, then default),
allowlisted and validated on POST /api/settings
- oauth-token.ts: resolveEffectiveClaudeConfigDir (setting > env > default,
tilde expanded, trailing separator stripped), deriveMacKeychainServiceName,
readMacOsKeychain(serviceName, execImpl) with an injectable exec seam,
threaded through readClaudeOAuthToken; darwin branch only — Windows/Linux
readers stay on the bare name (their per-profile scheme is unverified)
- EnvManager.ts: buildIsolatedEnv stamps the effective dir onto the SDK
subprocess env only; getAuthMethodDescription names the profile, never
the token
Tests: 102 pass / 0 fail across the five affected files (71 on the clean
base); tsc clean; full suite unchanged versus the clean base.
Claude-Session: https://claude.ai/code/session_01RJEKSU5eVwnvBf4irjtP4E
Co-authored-by: Gwen Ives <gwen.ives@nyecorp.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
A search returns the newest of up to 100 semantic candidates rather than
the most relevant ones, so an older observation that matches precisely
loses to a newer, vaguely related one — including when the query is the
older observation's own verbatim text. Raising the limit surfaces it
again, which shows it was a candidate all along and was dropped by the
date cut rather than by relevance.
hybridSemanticHydrate asks Chroma for 100 matches, which arrive in
distance order, then hydrates them with orderBy 'date_desc'. That
re-sorts the ranking away before the limit is applied. The same file
already does this correctly in performChromaSemanticSearch, which
hydrates with orderBy 'relevance' — the mode that preserves the
caller-provided id order.
Use 'relevance' at the three call sites that pass ids straight from
Chroma: searchObservations, getTimelineByQuery, and
searchChromaForTimeline. The last one selects the single timeline
anchor, where 'date_desc' picked the most recent candidate rather than
the top-ranked one, disagreeing with the FTS fallback directly beneath
it that returns the best match.
Closes#3876
In Codex, memory is never re-injected after a context compaction. The
SessionStart hook that runs `hook codex context` matches only
"startup|resume", so after a manual /compact or an auto-compaction the
hook does not fire and the session continues with an empty context. A
long-running Codex session loses all injected memory at its first
compaction and never gets it back.
Codex emits four SessionStart sources, not two — SessionStartSource in
codex-rs/hooks/src/events/session_start.rs is Startup, Resume, Clear,
Compact. clear and compact both hand the model a fresh context, which is
exactly when the injection has to run. The Claude Code config already
matches "startup|clear|compact" for the same reason.
Add the two missing sources to the matcher. The hook command is
unchanged and does not branch on source, so it injects on compact and
clear exactly as it already does on startup and resume.
Closes#3862
waitForSlot captured CLAUDE_MEM_MAX_CONCURRENT_AGENTS once per waiter, so a
settings raise never released a parked generator, and a provider change only
logged "will switch after current generator finishes" — which a parked
generator never does (the idle monitor only runs once a slot is held). Live
result: six of seven sessions parked behind a cap of 1, and reverting the
provider setting processed nothing until a worker restart dropped ~426 items.
- process-registry: waitForSlot(number | () => number, signal?, sessionId?)
re-invokes the getter on every recheck; slotWaiters carry sessionId /
parkedSince / warnedParked; getParkedSlotWaiterCount and
isSessionParkedForSlot exported; one WARN per waiter parked > 3 min
- ClaudeProvider: pass a settings-reading getter plus the session id
- SessionRoutes: a provider change aborts a PARKED generator
(abortReason 'provider_switch'), awaits its exit chain, then restarts on
the new provider via the shared admitAndStartGenerator path; mid-response
generators unchanged; ensureGeneratorRunning serialised per sessionDbId
(promise-map mutex) to close the concurrent double-start race;
normalizeAbortReason + enum listings + telemetry docs gain provider_switch
- GeneratorExitHandler: provider_switch preserves the buffer like quota
- DataRoutes: /api/processing-status gains parkedSessions (additive)
- tests: parked-waiter release/FIFO/cap, provider-switch-while-parked,
exit-handler, processing-status, double-start serialisation; shared
singleton guards for the process registry and the quota-cooldown breaker,
wired at top level in every file that drives them
Tests: 48 pass / 0 fail across the six affected files (800/0 under
--rerun-each=20); tsc clean; full suite's failing set unchanged versus the
clean base. A rare cross-test timeout in the provider-switch suite is
disclosed in the PR body.
Claude-Session: https://claude.ai/code/session_01RJEKSU5eVwnvBf4irjtP4E
Co-authored-by: Gwen Ives <gwen.ives@nyecorp.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* fix(worker): honor field optimizer deadline and compact Edit observer view
Plumb an AbortSignal through the field-compression path so a field-level
timeout can cancel the real provider request and stop retries instead of
leaking a background attempt.
- field-optimizer: withTimeout now creates an AbortController and hands its
signal to the compressor; on timeout it aborts the in-flight work.
- OpenAICompatibleProvider/GeminiProvider/OpenRouterProvider/ClaudeProvider:
thread the signal into query/compressField and forward it to withRetry or
the SDK query, disabling retries when a caller-supplied signal is present.
- field-optimizer: add a conservative compactEditOutput view for Edit tool
outcomes that strips redundant originalFile and patch lines while keeping
the canonical oldString/newString/hunk locations intact; the raw tool
payload stored in the DB is unchanged.
- Add tests/worker/field-deadline-wire.test.ts and
tests/worker/edit-observer-view.test.ts.
* test(worker): make field-deadline-wire test event-driven for Bun 1.2.14
The fixed 450 ms assertion on fails on Bun 1.2.14
because res.close does not fire within that window when the request body
is consumed and no headers are sent. The socket/keep-alive handling in
that version leaves the response object alive.
Observe the request's own abort/error and socket-close events instead.
These are runtime-agnostic and fire reliably on both Bun 1.2.14 and
Bun 1.4.1. Keep and assertions, so
retries and cancellation coverage are unchanged.
P2: https://github.com/thedotmack/claude-mem/pull/3939#discussion_r3965682436
* fix(worker): align Edit size checks with optimizer serialization
`runBatchQuery()` shells out with `tree-sitter query -p <grammar-dir>`, and
`-p/--grammar-path` is documented as implying `--rebuild`: the CLI recompiled the
grammar from C on every invocation. Measured with tree-sitter-cli 0.26.9 on Linux
x86_64, one `smart_outline` over a 933-line TypeScript file cost 734/711/704 ms
across three runs, nearly all of it the compile. The artifact the CLI wrote to
its own cache was rewritten each time and never read back.
Grammars are now built once into <data-dir>/tree-sitter-libs and queried with
`-l <lib> --lang-name <language>`. The same three runs become 733/16/12 ms and
return the identical 30 symbols; the first call still pays one build.
Because `parseFilesBatch()` issues one CLI call per language group, the old cost
was a fixed ~0.7s per language per tool call regardless of batch size, paid alike
by smart_outline, smart_search and smart_unfold, and repeatedly by anything that
walks a repository.
The artifact lives in the data dir rather than in node_modules: an update
replaces node_modules wholesale, and those directories belong to the installer.
Staleness is an mtime comparison against src/parser.c and the optional scanner —
npm and bun both stamp installed files with the install time, so a plugin update
invalidates the previous artifact on its own. The check is re-run per call rather
than memoized, since four stats cost nothing next to the spawn they guard and a
memo would pin a long-lived MCP server to the grammar that was current at boot.
Every failure path falls back to the previous `-p` invocation: a build that does
not compile, or a grammar whose language function is not named after our language
key. The opt-out is remembered per language, so a mismatch costs one extra spawn
once instead of two per batch forever. All 24 shipped grammars were verified to
bind under their existing GRAMMAR_PACKAGES key, including the irregular ones
(typescript, tsx, kotlin, php, scss, sql, markdown).
Fixes#3926
Co-authored-by: rorar <rorar@users.noreply.github.com>
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
Rebased from PR #3946 (L4XB) onto main after #3938 merged.
An unparseable lock (typically a 0-byte file left by a crash mid-write) has no
owner PID to probe, so isChromaWriterLockLive() is unreachable and vector sync
stays dead until someone deletes the file by hand.
Once the file's mtime is older than a short grace period (10s — long enough for
a concurrent writer to finish writing its payload), remove it and retry, mirroring
the existing dead-PID path. A freshly written unreadable lock is still refused
exactly as before.
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Lukas Buck <L4XB@users.noreply.github.com>
When Chroma refuses writes, backfillKind() kept walking every remaining
row: each row's first batch failed, logged the same "Batch add failed"
error and moved on. On a large store that is one identical error line per
row per run (8.9M lines / 3.8 GB in three days in #3928) with nothing ever
advancing.
Count consecutive failed rows and give up the run after three, logging a
single summary with the remaining row count; a later successful row resets
the streak. The pipeline skips the other kinds once a run is aborted.
Nothing is lost: failed rows keep their pending marks or stay above the
watermark and the next backfill retries them.
Fixes#3928
Cherry-picked from #3952 (AntonioCoppe). Fixes#3940, fixes#3951.
Rebuilt bundles to 13.24.5, added cross-hook recycle persistence, strengthened pre-build CI version checks.
CorpusBuilder.build() passed filter.types as `type`, but SearchOrchestrator only recognizes `obs_type` via normalizeParams. Route selected observation types through `obs_type` so phase-one search applies the filter before hydration.
Verified: 61 tests pass across knowledge + search suites (143 assertions).
Some local observers echo the field label into the value and emit
`<title>[**title**: Example observation]</title>`. extractField() only
trims whitespace, so the whole wrapper was persisted as the title.
Unwrap exactly that complete, nonempty wrapper at parse time so the stored
title (and everything derived from it, such as the content hash) sees the
plain text. Partial forms and legitimately bracketed titles are unchanged.
Fixes#3907
Persist unsuccessful version recycles across hooks, retry after the bundle changes, and rebuild shipped workers. Check committed bundle versions before CI rebuilds them.
Refs #3940
* feat(telemetry): emit skill_invoked for bundled Skill and slash use
Track which first-party plugin/skills people invoke, on which host, without
collecting args, prompt bodies, or third-party skill names.
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
* fix(telemetry): avoid */ inside skill-id file comment
A plugin/skills/*/ example closed the block comment and broke bun test parse.
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
* feat(grok-bot): Phase 1 Memory JIT — rich timeline index via #3953 transport
Compile an editable CCS L1 markdown bucket into a slide-off timeline
INDEX (one observation per line, ID kept) and land it through
zz-claude-mem-inject.md → host Memory mid-attach. mtime-stable rewrite
when the fact block is unchanged. Pilot allowlist remains the default;
AGENT_IDS=* stays optional.
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
* fix(grok-bot): stamp CCS L1 bucket at ccs/seats/<id>/TIMELINE.md
Concrete default is <agentDataRoot>/ccs/seats/<agentId>/TIMELINE.md so
bots can edit it. PRIVATE.md is a reserved sibling (never written).
house/ and groups/ stay reserved for later inherit. L1 pilot remains
the Orifice/Grok Memory allowlist.
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
The v7 rebuild that drops the UNIQUE constraint from session_summaries
recreated the table from a fixed v7 column list. Fresh installs stamp
every migration at once, so a database whose base schema already carried
v11's discovery_tokens next to the v7 constraint lost the column in that
rebuild, and the version-gated v11 guard never re-added it: every
summary write failed with "no column named discovery_tokens" from then
on while observations kept being captured.
The rebuild now carries every live column outside its v7 list over
(type and default included) and copies them along, and
ensureDiscoveryTokensColumn re-checks the live tables regardless of the
stamped version.
Fixes#3890
The worker's inject header carries the time it was fetched. Keeping it in the
fact block made every poll a content change, so the host saw a "memory changed"
delta and re-announced the section on the next turn for a timestamp and nothing
else. Facts already carry their own date; strip the clock and the refresh pass
goes to `unchanged` until observations actually move.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0181Cyg5g9ee7QvPynfh7iV7
GrokBotAwarenessPusher gets needles into an agent's memory folder, but it
writes them as `- <date> [awareness] ...`, which the sand host's fact grammar
(`/^-\s+\((\d{4}-\d{2}-\d{2})\)\s+(.+?)\s*$/`) does not match — those lines
never reach the prompt. Nothing else pulls session context in automatically,
so a Grok seat only sees claude-mem when the model remembers to call a tool.
This adds a standalone watcher that closes that gap using only APIs that
already exist:
worker GET /api/context/inject (projects / platformSource only)
-> host fact grammar, in a file the shim owns:
agents/<id>/memory/log/zz-claude-mem-inject.md
-> the host's own WatchedDirectory + frozen-section machinery
(getFrozenSectionUpdatesForTurn -> promptWithInstructionsUpdate)
attaches the delta to the agent's next cold / non-resume turn.
No host patch, no new memory API, no listening socket (outbound HTTP to the
local worker only), and no full frozen-head rebuild.
Two details the host forced:
- Tier. The host ranks recalled log facts by
`log2(importance) + createdAt / 30d`, and importance comes only from a line
prefix (`[episode] ` 1.5, `[note] ` 0.5, else 1). log2(1.5) is worth ~17.5
days of recency, so on a seat whose log is already full of episode summaries
a plain-tier line loses every slot in the 4000-char recall budget and never
renders at all. Episode tier is the default; `CLAUDE_MEM_GROK_BOT_INJECT_TIER`
can drop it to plain/note on a quieter seat.
- Line budget. Every emitted line spends its own length out of that same
4000-char budget, evicting the agent's own memory, so the default is 2 lines:
project, stats and the freshest observation IDs, plus how to fetch the rest.
Gated off by default and allowlisted per agent id. Writes are atomic, refuse
any path outside the agent's memory/log, and refuse any filename but the one
the shim owns — the host owns YYYY-MM.md and profile.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0181Cyg5g9ee7QvPynfh7iV7
The live sync paths (syncObservation, syncSummary, syncUserPrompt) declined
to bump the watermark when a write failed, but recorded nothing. The
watermark is a monotonic high-water mark, so the next row that did write
moved it past every row that had failed before, and recovery only looks at
`id > watermark` plus the pending list: those rows were never indexed
again. Any transient Chroma outage turned into silent, permanent loss of
vector coverage the moment it cleared (#3917: 1,900 observations).
Mark the row pending on a failed or partial write, exactly like
backfillKind() already does, and clear its pending mark when a later live
write of the same row lands. The next backfill then retries it through
the pending list.
Fixes#3917
* docs(ccs-align): Phase 3 verify/sign-off — mark Phases 0-2 shipped, honest 'what this is not', worker-lag ops MISS
Phase 3 closes the plan loop with no new runtime surface:
- SKILL.md: heading + status now reflect Phases 0-2 shipped (#3934/#3935/#3936)
and Phase 3 = verify/sign-off; add explicit 'What this is NOT' (no compiler,
no brainbeat product, no attention trough, no Focus/mouth, no second LFG
writer, no history rewrite); record running-worker 13.24.1 vs repo version lag
as an ops MISS to roll up (worker restart is a hard forbid for this seat).
- plan: status PLAN ONLY -> PASSED & SHIPPING (Phases 0-2 merged; Phase 3 verify).
Defaults table and history preserved.
- Address the human as Alex.
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
* chore: bump version to 13.24.5
PATCH bump for CCS Align Phase 3 sign-off (D10 — skill + plan ship, no
product claim). CHANGELOG.md left untouched (generated).
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
* feat(ccs-align): Phase 2 rules alignment — house → project → seat conflict walk
Walk house → project → seat layers, detect four conflict classes
(SHADOW_HOUSE, DENY_ALLOW, DRIFT, CLOCK_HEADER), emit an append-only
rules-report.md, and optionally apply SHADOW_HOUSE leaf patches
when CLAUDE_MEM_CCS_ALIGN_PATCH_SHADOWS=true.
- CcsAlignRulesWalker.ts: cascade rules checklist (not a parser),
layer walk with MISS recording, conflict detection, atomic report
append, gated shadow patch with forbidden-target guards
- 26 tests covering all four conflict classes, patch on/off,
standing/always/never safety, MISS on absent paths, append-only
report, edge cases
- SKILL.md updated with Phase 2 docs, conflict table, programmatic
usage, cadence note (every 6th hour), and verification greps
Implements plans/2026-09-09-ccs-align.md §2.1–2.3.
No Focus, no attention trough, no .cas compiler, no history deletes.
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
* chore: bump version to 13.24.4
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
Phase 1 of the CCS Align plan of record (plans/2026-09-09-ccs-align.md §1.1–1.4):
Exclude marks filter the *compiled* middle cache — the diary / SQLite stay
authoritative. A mark records observation ids and tool-use ids; the
grab→append→replace pipeline drops marked records so they never appear in
the compiled middle.jsonl. Unmarking + rebuild restores them from the diary
on the next pull. DELETE /api/observation/:id remains FORBIDDEN.
Implements:
- ExcludeMark type + exclude-marks.json schema (v:1)
- readExcludeMarks / writeExcludeMarks / addExcludeMark / removeExcludeMark
- buildExcludeSet for the atomic pipeline
- appendMiddleCacheRecordsAtomic now filters by exclude set
- landObservationsInMiddleCache loads marks and applies them
- rebuildMiddleCache for unmark+rebuild path
- Skill updated for Phase 1: exclude-marks section, layer-4 get_tool_uses
warning (mark-time only), viewer isolation, unmark+rebuild, purge tools
- 11 new Phase 1 tests: mark drop, diary present, tool ids never in
middle.jsonl, viewer isolation, unmark+rebuild, exclude-marks round-trip,
buildExcludeSet, secure-isolation reason, corrupt marks fail-closed,
marked ids skipped on ingest
Hard forbids verified:
- No DELETE /api/observation (compile-time omit, not tombstone)
- No LFG/Orifice [awareness] writes
- No profile.md touch
- No sixth processAgentResponse consumer
- No CHANGELOG hand-edit
- No 'Az' in user-facing strings
PATCH bump: 13.24.2 → 13.24.3
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
* feat(ccs-align): seat-owned middle cache helper + settings (Phase 0)
Copy the #3931 atomic append primitive (appendAwarenessLineAtomic /
awarenessLineBody / formatAwarenessLine) into a seat helper with the three
locked Phase 0 changes: tag [ccs-align], seat-owned path root
~/.claude-mem/ccs-align/<viewerId>/, and a middle.jsonl store. Grab -> append
-> replace is atomic (temp + renameSync), deduped by observation id and by
date-excluded body. Path safety refuses profile.md, agents/**/memory/log, and
any write outside the seat root; the lander never throws into the caller.
Adds CLAUDE_MEM_CCS_ALIGN_{ENABLED,VIEWER_IDS,TRIGGER_TYPES,PATCH_SHADOWS}
defaults (needle types copied from the Grok list per D6).
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
* docs(skill): add ccs-align SKILL.md with hourly Worker Watch runbook
Phase 0 breathing slice: resolve worker port (timeline-report snippet),
prefer GET /api/health, pull search -> timeline -> get_observations, land
observations in the seat middle cache, update cursor.json. Documents the
Appendix A hourly cycle, settings, hard forbids, and later-phase stubs.
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
* test(ccs-align): middle-cache format/needle/append/dedupe/path-safety
Copies the #3931 pusher test patterns: [ccs-align] format + 500-char
truncate, needle match, atomic append, id/body dedupe across days, path
safety (never profile.md / agents/memory/log / outside root), never-throw,
D2 append-only fallback, and cursor.json round-trip.
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
* chore: bump version to 13.24.2 (PATCH — CCS Align Phase 0 code ships)
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
* chore: sync marketplace.json + plugin/package.json to 13.24.2
Version Consistency CI flagged .claude-plugin/marketplace.json still at
13.24.1 after the 13.24.2 bump. sync-plugin-manifests.js does not stamp
marketplace.json or plugin/package.json, so bring both source files in line
with root package.json. The worker-service.cjs stamp is regenerated by
'npm run build' in CI before the test runs.
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
Timeline depth default is 10 in SearchManager, not 3 in the MCP
blurb. Worker liveness is GET /api/health on Server.ts. Dedupe key
on the #3931 helper ignores the date. Docs lag; the seat copies code.
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
Phased make-plan for the standing CCS Align seat: Phase 0 talks to the
local worker and lands observations in a seat-owned middle cache
(grab-append-replace, append-only fallback). Later phases cover exclude
marks and house→project→seat rules. Defaults locked so Prioritizer can
PASS without new answers tonight. Plan only — no /do.
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
Daily published $/MTok input+output for CMEM-relevant models
(2026-07-01 through 2026-09-08), plus work-day popularity guesses
and a five-model July–September sample. Join observation tokens to
the CSV; do not use OpenRouter generation or activity APIs.
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
@clack/prompts 1.8 types isCancel as unique CANCEL_SYMBOL. select/multiselect
still return generic symbol, so the existing cancel guards no longer narrow.
Treat leftover symbols as cancel. Pre-existing on main; needed for this PR CI.
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
Carry agentId from transcript watches through ingest, watch agents that
already have a memory tree, and append needle observations as dated
[awareness] fact lines into the pilot agent's Grok Bot memory log.
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
Raw tool I/O had no durable home. `pending_messages` is the generation
queue -- rows are claimed, summarized, and deleted -- so once an
observation existed the original tool_input/tool_response were gone.
Adds `tool_uses` (schema v51) as a by-reference side index for those
bodies, written from the one ingest choke point both the PostToolUse hook
route and the transcript-watch processor already share. The JSONL
transcripts and `src/services/transcripts/*` remain the spine and are
untouched.
Schema (Receipt freeze 2026-09-06): UNIQUE(content_session_id,
tool_use_id), nullable `or_generation_id` / `or_session_id` as join keys
back to an OpenRouter spend line, and deliberately no cost_usd/micros --
this table carries tool identity, dollars stay on the OR stamp. No FK on
session_db_id/observation_id: a FK there can abort the constructor
migration chain (#3378), and observation_id is linked late by design.
Write path dual-writes alongside -- never instead of -- the
pending_messages enqueue, and swallows its own failures so an observation
is never lost to a backup-index error. `toolUseId` now actually reaches
the worker from hooks: it was missing from NormalizedHookInput and every
hook adapter, so only the transcript path supplied it. ResponseProcessor
links the batch's claimed ids to the first stored observation.
Read path is progressive-disclosure layer 4: POST /api/tool-uses/batch
requires explicit ids (never a full-table scan), GET /api/tool-uses
returns a cheap index shape with size hints and never the payloads, and
the `get_tool_uses` MCP tool is described as a last resort. claude-mem's
own read tools are skipped by the writer -- without that, every call to
get_tool_uses would store the bodies it just returned.
Payloads are stored in-row with a 64 KB soft cap and a UTF-8-safe
truncation marker; content_hash is computed over the original.
Join contract for Receipt: RECEIPT-JOIN.md
Claude-Session: https://claude.ai/code/session_01QgdJ6gExgBirDoAxknt94m
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The Observation TV section ended at the LAN: a token, a non-loopback bind, and
a second device on your own network. Watching from a coffee shop was simply not
described, and the natural next question — "so do I open a port?" — had no
answer here.
It does now, and the answer is no. Off-LAN viewing is cmem.ai Pro at
/tv, it rides the cloud sync your worker already pushes, and the box never
accepts an inbound connection for it. CLAUDE_MEM_TV_TOKEN and
CLAUDE_MEM_WORKER_HOST belong to the LAN path and do nothing for it, so they
stay at their defaults. No tunnel is needed, and none is recommended.
The part that needed saying twice: the hosted page shows titles and nothing
else, AND cloud sync uploads your narratives and full prompt text. Those are
two different statements. Someone who reads only the first will conclude that
only titles ever leave their machine, which is wrong, so both sit in the same
subsection rather than one here and one three pages away.
Also notes the one rendering difference a user would otherwise hit and assume
was a bug: an observation with no title shows on the LAN TV, which falls back
to the subtitle, and does not show on the phone, which never receives one.
Docs only. No settings key, no code.
Claude-Session: https://claude.ai/code/session_01NevXJ2xL9PtiKEsCMR4SJT
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): observation TV — fullscreen fading titles off the existing SSE stream
Adds a standalone, dependency-free page that consumes the same /stream the
React viewer does and plays each observation's title as a fullscreen fading
card. Live arrivals play first; a seeded backlog from /api/observations cycles
while the worker is idle, so the screen is never blank.
Picture-in-picture without a broadcast library: Document PiP (Chromium) moves
the real DOM into the floating window so the CSS fades keep running, and
everywhere else — including iOS Safari, the phone case — the card is painted
to a canvas whose captureStream() feeds a muted video into native PiP.
Served two ways: express.static already exposes plugin/ui, so /tv.html works
with no route change, and a /tv alias is cached at boot the same way
viewer.html is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6QPdnPducVehMwCM2HYNC
* docs(plans): observation TV read-only broadcast + shared-secret token
Phased plan for the locked 2026-09-05 decision: expose Observation TV to a
second device on the LAN without exposing the rest of the worker.
The worker has no request authentication anywhere; its only defence is the
loopback bind, and the codebase says so out loud (ServerService.ts:129-131).
So CLAUDE_MEM_WORKER_HOST=0.0.0.0 today does not put the TV on the LAN, it
puts GET /api/settings — which returns the user's Gemini and OpenRouter API
keys in plaintext — on the LAN, alongside the settings writer, the row
deletes, bulk import, and better-auth's key issuance.
The design is one guard middleware mounted at position zero in the Server
constructor, the only spot that covers /api/auth/*, /api/admin/*, the static
mount, and every route registered later. It is a no-op for loopback and, for
non-loopback requests, default-deny with a four-path exact-match allowlist
behind a new CLAUDE_MEM_TV_TOKEN. An empty token means the guard is never
mounted, so every existing install — including the documented Docker 0.0.0.0
setup — is byte-identical to today.
Phase 0 is written out rather than delegated: ~45 routes inventoried with
file:line, the copy-ready patterns named (requireLocalhost, parseBearerToken,
safeEqualHex, the securityHeaders opt-in precedent), and five traps recorded,
including that SettingsDefaultsManager.get() cannot see settings.json and that
the worker never calls finalizeRoutes() so the guard must write its own
responses. Appendix B lists every rejected option with its reason —
cloudflared first among them.
Plan only. Nothing implemented.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMh2GZST1UgKDSML17qCmh
* feat(worker): read-only Observation TV broadcast behind CLAUDE_MEM_TV_TOKEN
The worker's HTTP surface (45+ routes) has no request authentication; the
loopback bind is its only defence. So setting CLAUDE_MEM_WORKER_HOST=0.0.0.0 —
which the Docker docs tell people to do — puts GET /api/settings (provider API
keys in plaintext), POST /api/admin/restart, DELETE /api/observation/:id,
POST /api/import and better-auth on the LAN.
Add one guard middleware, mounted at position zero in the Server constructor —
the only spot that covers /api/auth/*, /api/admin/*, the static mount and every
route registered later, including routes that do not exist yet. It is a no-op
for loopback and, for non-loopback requests, default-deny with an exact-match
four-path allowlist behind a shared secret:
/tv, /tv.html, /stream, GET /api/observations
A GET/HEAD method gate kills every mutation; non-allowlisted paths get 404 so a
scanner is not told which routes exist; the token is compared constant-time and
accepted as Authorization: Bearer, X-Api-Key, or ?token= (the query form exists
only because EventSource cannot set headers). The token is never logged.
Empty token means the guard is never mounted, so every existing install behaves
exactly as before and CLAUDE_MEM_WORKER_HOST keeps its 127.0.0.1 default. A
boot-time SECURITY warning fires when the host is non-loopback with no token —
warn, not refuse, so the documented Docker deployment keeps working.
Also fixes createCorsMiddleware forwarding next(new Error('CORS not allowed')):
the worker never calls finalizeRoutes(), so that reached Express's default
handler and returned a 500 HTML stack trace with absolute filesystem paths —
newly reachable from the LAN. It now writes its own 403 JSON.
tv.html carries the token through to both of its calls, and cards now show
platform_source with a per-source accent colour in both the DOM and canvas
render paths.
No new dependencies. 38 tests in tests/server/tv-remote-guard.test.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xcn8Gf6ACkfDqLYaULAj2k
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>