Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ Once set up, Capacitor runs silently in the background. Every Claude Code (and C
- **Repository context** — git repo, branch, and PR linkage
- **In-agent upgrade prompts** — in Claude Code sessions, when the server is running a newer kcap release than the local CLI, additional context is injected into the session so the agent can offer the user an upgrade via `kcap update`. The stderr `kcap` update hint continues to fire for direct command-line use, and every request also carries the CLI's version to the server so it can surface its own out-of-date banner/notification (see [`kcap update`](#other-commands) for the full picture, including how `update_check: false` turns all of this off).
- **SessionStart context injection** — at every session start the server injects top evaluation-derived fact clusters for the current repo into the session's context. The injected block is split into two sections: `## Known patterns` (repo/project facts relevant to any reader) and `## Guidance from past sessions` (agent-targeted action items derived from prior eval suggestions with `audience: "agent"`). Delivered for every supported harness (Claude Code, Codex CLI, GitHub Copilot CLI, Gemini CLI, AWS Kiro CLI, Google Antigravity, Pi, OpenCode, and Cursor's `cursor-agent`): Claude reads it from its hook response, while the other eight fetch `GET /api/repositories/{hash}/guidelines` alongside the team-memory index and emit both in one combined block, through the same per-harness delivery seam as that index (see the next bullet). Opt out by setting `disable_session_guidelines: true` in `~/.config/kcap/config.json` or via `kcap config set disable_session_guidelines true`.
- **SessionStart team-memory index** — at every session start (Claude Code, Codex CLI, GitHub Copilot CLI, Gemini CLI, AWS Kiro CLI, Google Antigravity, Pi, OpenCode, and Cursor CLI's `cursor-agent` — see the capability matrix below for the full per-harness rollout) `kcap` also fetches a compact index of durable [team memories](#memory-mcp-server-for-agents) visible for the current repo/machine and appends a `## Team memory` block to the session's injected context (`additionalContext` for Claude, Codex, Copilot, and Gemini, `additional_context` for Cursor, raw stdout for Kiro, `injectSteps`/`userMessage` for Antigravity, system-prompt append via the kcap Pi extension for Pi, and system-entry append via the kcap OpenCode plugin for OpenCode): one `slug [scope]: description` line per memory, grouped **Org / Team / Yours**, with a nudge to call `get_memory` / `search_memories` for full content. The `[scope]` tag annotates the memory's home scope — a project memory shows `[project: <slug>]`, a repo one `[repo]`, and org-scoped memories stay untagged. Only the index is injected — never the bodies — so the cost stays roughly flat as the pool grows (mirrors a local `MEMORY.md`). Best-effort and fail-open (a slow or failed fetch injects nothing, never blocking the hook), and only ever injected once per conversation. Opt out with `disable_memory_index: true` in `~/.config/kcap/config.json` or `kcap config set disable_memory_index true`.
- **SessionStart team-memory index** — at every session start (Claude Code, Codex CLI, GitHub Copilot CLI, Gemini CLI, AWS Kiro CLI, Google Antigravity, Pi, OpenCode, and Cursor CLI's `cursor-agent` — see the capability matrix below for the full per-harness rollout) `kcap` also fetches a compact index of durable [team memories](#memory-mcp-server-for-agents) visible for the current repo/machine and appends a `## Team memory` block to the session's injected context (`additionalContext` for Claude, Codex, Copilot, and Gemini, `additional_context` for Cursor, raw stdout for Kiro, `injectSteps`/`userMessage` for Antigravity, system-prompt append via the kcap Pi extension for Pi, and system-entry append via the kcap OpenCode plugin for OpenCode): one `slug [scope]: description` line per memory, grouped **Org / Team / Yours**, with a nudge to call `get_memory` / `search_memories` for full content. The `[scope]` tag annotates the memory's home scope — a project memory shows `[project: <slug>]`, a repo one `[repo]`, and org-scoped memories stay untagged. Above the list, a lead-in names the projects the current repo belongs to and the slug to pass on `save_memory`'s `project` argument (`This repo belongs to project "capacitor" (Kurrent Capacitor). Save learnings that span its repos with project: "capacitor".`) — one line per project, and nothing at all for a repo in no project or a server that does not report them. The lead-in is injected even when the repo has no memories yet, which is when the agent most needs the slug. Only the index is injected — never the bodies — so the cost stays roughly flat as the pool grows (mirrors a local `MEMORY.md`). Best-effort and fail-open (a slow or failed fetch injects nothing, never blocking the hook), and only ever injected once per conversation. Opt out with `disable_memory_index: true` in `~/.config/kcap/config.json` or `kcap config set disable_memory_index true`.
- **SessionStart work-items nudge** — at every session start (Claude Code, Codex CLI, GitHub Copilot CLI, Gemini CLI, AWS Kiro CLI, Google Antigravity, Pi, OpenCode, and Cursor CLI's `cursor-agent`) `kcap` appends a short `## Work items` block carrying the current session id and a reminder to register the session with its work item via the [`kcap-workitems` MCP tools](#work-items-mcp-server-for-agents) (`declare_work_item`) and to declare structure as it is discovered (`declare_work_breakdown` for a parent→parts split, `declare_work_relation` for a `blocks`/`blocked_by` dependency). It rides the same per-harness delivery seam as the team-memory index, is composed independently of that index (so it never affects the index's once-per-session lease), and is shown only when `kcap-workitems` is actually registered for the harness. Opt out with `disable_workitems_nudge: true` in `~/.config/kcap/config.json` or `kcap config set disable_workitems_nudge true`.
- **SessionStart coordination notices** — at every session start (Claude Code / the generic route only) `kcap` advertises a `coordination_notices` capability on its `/hooks/session-start` request, and when the server has pending coordination notices for you — a heads-up that other people have in-flight work that may overlap yours (work-overlap / work-item adjacency) — it appends a `## Coordination notices` block to the session's injected context (`additionalContext`), one short line per notice (bounded, with a `+N more in the notification centre` tail when there are more). The same notices always reach the in-app notification centre and Slack regardless; this block just surfaces the most relevant few directly in the agent's context at the moment you start. Best-effort and fail-open (a missing or malformed field injects nothing, never blocking the hook), and the capability is advertised only on a live session start — never from `kcap import`/backfill. Opt out with `disable_coordination_notices: true` in `~/.config/kcap/config.json` or `kcap config set disable_coordination_notices true`; when set, the capability is not sent at all, so the notices stay in the notification centre / Slack only.
- **Crash resilience** — if a `kcap` command hits an unexpected error it records the exception (with stack trace) to `~/.config/kcap/crash.log` (honours `KCAP_CONFIG_DIR`; size-capped) and exits cleanly instead of aborting. Hook and detached-generator commands the coding agent spawns **fail open** (exit 0, nothing surfaced to the agent); other commands exit non-zero with a one-line stderr message pointing at the log.
Expand Down
23 changes: 23 additions & 0 deletions docs/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ diff. `CLAUDE.md` holds the invariants; `docs/superpowers/specs/` holds the full
Not release notes. Each entry is written as of the change that produced it and is not revised as the
code moves on; where an entry disagrees with the code, the code wins.

## The SessionStart index names the repo's projects

An agent can only land a memory at project scope by passing a slug, and nothing in a session told it
which project the cwd repo is in — so cross-repo learnings went to org, the only cross-repo scope
reachable without one. The index fragment now opens with a line per project naming that slug.

**The projects ride the index call, negotiated by `include=projects`.** The body was a bare array and
a CLI that predates this drops the whole fragment when it is not one, so the shape could not simply
change. A sibling endpoint or a superseding one costs a second round trip inside a hook budget that
is already tight on Cursor and Claude; a response header cannot carry a non-ASCII project name
without an encoding nobody else in the wire needs. The parameter costs nothing and degrades in both
directions: an older server ignores it and answers with the array, a newer one answers with an
object, and the CLI decides which it got by sniffing the opening token. Sniffing rather than
attempting the array parse matters — a failed deserialize is indistinguishable from a corrupt body,
which is retried, and an old server would never answer differently.

**The parameter goes out even with no repo resolved.** It declares that this CLI can read the object
body, not that a repo is in hand; making it conditional would leave the server answering one request
with two shapes.

**Projects alone are enough to emit a fragment**, where entries alone are not. A project holding no
memories yet is exactly the state the agent is being asked to fix, and it cannot without the slug.

## The flow can enable the daemon as a service

`kcap daemon service ensure` existed with no caller in the product. Making the browser able to ask for it
Expand Down
46 changes: 39 additions & 7 deletions src/Capacitor.Cli/MemoryIndexEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,27 @@ namespace Capacitor.Cli;

/// <summary>
/// builds the SessionStart "team memory" index
/// fragment from the <c>GET /api/memories/index</c> response body: a JSON array of
/// fragment from the <c>GET /api/memories/index</c> response: entries of
/// <c>{memory_id, slug, audience, description, kind, scope_kind, project_slug}</c>,
/// already capped and ranked by the server.
/// already capped and ranked by the server, plus the cwd repo's projects.
/// <para>
/// Entries are grouped by <c>audience</c> (org → team → user), preserving the
/// server's order within each group, and rendered as <c>slug [scope]: description</c>
/// — one line per memory — under a lead-in that tells the agent to call
/// — one line per memory — under a heading that tells the agent to call
/// <c>get_memory</c> / <c>search_memories</c> for full content. The <c>[scope]</c> tag
/// annotates the memory's home scope (a project-scoped memory shows <c>[project: {slug}]</c>,
/// a repo one <c>[repo]</c>, org-scoped and older servers render untagged). Bodies are
/// NEVER injected: this mirrors a local <c>MEMORY.md</c> index so the injected token cost
/// stays roughly flat as the pool grows.
/// </para>
/// <para>
/// Above that heading sits a lead-in naming the projects the cwd repo belongs to — the slug an
/// agent needs on the place axis of a save, which nothing else in the session tells it. A repo in
/// no project (or an older server, which reports none) gets no lead-in, and projects alone are
/// enough to emit a fragment: a project with no memories yet is exactly when the agent most needs
/// its slug.
/// </para>
/// <para>
/// Returns <c>null</c> when disabled, empty, or malformed — the caller
/// (<c>SessionStartAdditionalContext</c>) drops null fragments, so a failed or empty
/// fetch emits nothing (fail-open, same as guideline injection).
Expand All @@ -40,7 +47,9 @@ static class MemoryIndexEmitter {

static readonly HashSet<string> Kinds = new(StringComparer.Ordinal) { "preference", "feedback", "project", "reference" };

public static string? BuildFragment(IEnumerable<SessionStartMemoryEntry> entries) {
public static string? BuildFragment(
IEnumerable<SessionStartMemoryEntry> entries,
IEnumerable<SessionStartMemoryProject>? projects = null) {
var org = new List<string>();
var team = new List<string>();
var user = new List<string>();
Expand All @@ -59,9 +68,11 @@ static class MemoryIndexEmitter {
case "user": user.Add(line); break;
}
}
if (org.Count == 0 && team.Count == 0 && user.Count == 0) return null;
var leadIn = BuildLeadIn(projects);
if (org.Count == 0 && team.Count == 0 && user.Count == 0)
return leadIn is null ? null : FragmentMarker + "\n" + leadIn;

var prefix = FragmentMarker + "\n## Team memory\n" +
var prefix = FragmentMarker + "\n" + (leadIn is null ? "" : leadIn + "\n\n") + "## Team memory\n" +
"Durable memories for this repo/context. Call `get_memory <slug>` for the full content of any entry, or `search_memories` to find more.";
var sb = new StringBuilder(prefix);
var currentBytes = Encoding.UTF8.GetByteCount(prefix);
Expand All @@ -71,7 +82,11 @@ static class MemoryIndexEmitter {
return sb.ToString();
}

/// <param name="indexNode">The <c>/api/memories/index</c> response body parsed as a <see cref="JsonNode"/> (expected: a JSON array).</param>
/// <summary>Renders the bare-array index body — the form a server that does not honour
/// <c>include=projects</c> answers with, which carries no projects and so no lead-in. Anything
/// but an array yields null; the object form is read by
/// <see cref="SessionStartMemory.SessionStartMemoryContextProvider"/>, which knows which shape it asked for.</summary>
/// <param name="indexNode">The response body parsed as a <see cref="JsonNode"/>.</param>
/// <param name="disabled">True when the user set <c>disable_memory_index</c> on their active profile.</param>
public static string? BuildFragment(JsonNode? indexNode, bool disabled) {
if (disabled) return null;
Expand Down Expand Up @@ -110,6 +125,23 @@ static bool AppendBoundedGroup(StringBuilder sb, string heading, List<string> li
return true;
}

// One line per project, each naming the slug twice: once as the repo's membership and once as the
// literal argument value, so an agent that reads only the second half still has what it must pass.
static string? BuildLeadIn(IEnumerable<SessionStartMemoryProject>? projects) {
if (projects is null) return null;
var lines = new List<string>();
foreach (var project in projects) {
if (lines.Count == SessionStartMemoryConstants.MaxProjects) break;
var slug = Normalize(project.Slug, 128);
if (slug.Length == 0) continue;
var name = Normalize(project.Name, 128);
var label = name.Length == 0 || name == slug ? "" : $" ({name})";
lines.Add($"This repo belongs to project \"{slug}\"{label}. " +
$"Save learnings that span its repos with project: \"{slug}\".");
}
return lines.Count == 0 ? null : string.Join('\n', lines);
}

// Annotates the memory's home scope after the slug. A project-scoped memory applies across the
// project's repos, so it carries the resolved slug; a repo memory is tagged plainly; org (the
// broadest home, and the fallback for an older server that sends no scope) renders untagged.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,57 @@ public async Task<SessionStartMemoryContextResult> FetchWithScopeAsync(
if (outcome.Body is null)
return new SessionStartMemoryContextResult(SessionStartMemoryDisposition.RetryableFailure, RetryAfter: outcome.RetryAfter);

var entries = JsonSerializer.Deserialize(outcome.Body,
SessionStartMemoryJsonContext.Default.SessionStartMemoryEntryArray);
if (entries is null) return SessionStartMemoryContextResult.Retry;
if (entries.Length == 0) return SessionStartMemoryContextResult.Empty;
var fragment = MemoryIndexEmitter.BuildFragment(entries);
var index = ParseIndex(outcome.Body);
if (index is null) return SessionStartMemoryContextResult.Retry;
var entries = index.Entries ?? [];
var projects = index.Projects ?? [];
if (entries.Length == 0 && projects.Length == 0) return SessionStartMemoryContextResult.Empty;
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
var fragment = MemoryIndexEmitter.BuildFragment(entries, projects);
return fragment is null
? SessionStartMemoryContextResult.Empty
: new SessionStartMemoryContextResult(SessionStartMemoryDisposition.Ready, fragment);
}

/// <summary>
/// Reads the index body in either shape. A server that predates <c>include=projects</c> ignores
/// the parameter and answers with the bare entry array; one that honours it answers with an
/// object carrying the same entries plus the repo's projects. The shape is decided by sniffing
/// the opening token, not by attempting one parse and falling back: a failed deserialize is
/// indistinguishable from a corrupt body, which is a retryable failure, and the retry schedule
/// has no attempt ceiling — so an old server would be polled indefinitely for an answer it is
/// never going to give differently.
/// </summary>
static SessionStartMemoryIndexResponse? ParseIndex(byte[] body) {
var reader = new Utf8JsonReader(body);
if (!reader.Read()) return null;
return reader.TokenType switch {
JsonTokenType.StartArray => JsonSerializer.Deserialize(body,
SessionStartMemoryJsonContext.Default.SessionStartMemoryEntryArray) is { } entries
? new SessionStartMemoryIndexResponse(entries, null)
: null,
// An object carrying neither member is not an empty index, it is not an index at all:
// treating it as one would report a successful empty fetch and SPEND the once-per-session
// lease, so no later callback of that session ever asks again. Empty arrays are a real
// answer and stay distinct from absent ones.
JsonTokenType.StartObject => JsonSerializer.Deserialize(body,
SessionStartMemoryJsonContext.Default.SessionStartMemoryIndexResponse) is { } response &&
(response.Entries is not null || response.Projects is not null)
? response
: null,
_ => null
};
}

/// <summary>
/// <c>include=projects</c> rides every call, with or without a resolved repo: it declares that
/// THIS CLI can read the object body, not that a repo is in hand. Making it conditional would
/// give the server two shapes to answer one request with.
/// </summary>
internal static string BuildUrl(string baseUrl, SessionStartMemoryScope scope) {
var query = new List<string>();
if (scope.RepoHash is not null) query.Add("repo=" + Uri.EscapeDataString(scope.RepoHash));
if (scope.MachineTag is not null) query.Add("machine=" + Uri.EscapeDataString(scope.MachineTag));
return baseUrl.TrimEnd('/') + "/api/memories/index" + (query.Count == 0 ? "" : "?" + string.Join('&', query));
query.Add("include=projects");
return baseUrl.TrimEnd('/') + "/api/memories/index?" + string.Join('&', query);
}
}
Loading