diff --git a/README.md b/README.md index fdf9ef2cf0..1f850467c2 100644 --- a/README.md +++ b/README.md @@ -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: ]`, 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: ]`, 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. diff --git a/docs/CHANGES.md b/docs/CHANGES.md index 0a48fa056b..e75d7717ae 100644 --- a/docs/CHANGES.md +++ b/docs/CHANGES.md @@ -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 diff --git a/src/Capacitor.Cli/MemoryIndexEmitter.cs b/src/Capacitor.Cli/MemoryIndexEmitter.cs index d3e5be93f6..4371f17035 100644 --- a/src/Capacitor.Cli/MemoryIndexEmitter.cs +++ b/src/Capacitor.Cli/MemoryIndexEmitter.cs @@ -6,13 +6,13 @@ namespace Capacitor.Cli; /// /// builds the SessionStart "team memory" index -/// fragment from the GET /api/memories/index response body: a JSON array of +/// fragment from the GET /api/memories/index response: entries of /// {memory_id, slug, audience, description, kind, scope_kind, project_slug}, -/// already capped and ranked by the server. +/// already capped and ranked by the server, plus the cwd repo's projects. /// /// Entries are grouped by audience (org → team → user), preserving the /// server's order within each group, and rendered as slug [scope]: description -/// — 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 /// get_memory / search_memories for full content. The [scope] tag /// annotates the memory's home scope (a project-scoped memory shows [project: {slug}], /// a repo one [repo], org-scoped and older servers render untagged). Bodies are @@ -20,6 +20,13 @@ namespace Capacitor.Cli; /// stays roughly flat as the pool grows. /// /// +/// 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. +/// +/// /// Returns null when disabled, empty, or malformed — the caller /// (SessionStartAdditionalContext) drops null fragments, so a failed or empty /// fetch emits nothing (fail-open, same as guideline injection). @@ -40,7 +47,9 @@ static class MemoryIndexEmitter { static readonly HashSet Kinds = new(StringComparer.Ordinal) { "preference", "feedback", "project", "reference" }; - public static string? BuildFragment(IEnumerable entries) { + public static string? BuildFragment( + IEnumerable entries, + IEnumerable? projects = null) { var org = new List(); var team = new List(); var user = new List(); @@ -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 ` for the full content of any entry, or `search_memories` to find more."; var sb = new StringBuilder(prefix); var currentBytes = Encoding.UTF8.GetByteCount(prefix); @@ -71,7 +82,11 @@ static class MemoryIndexEmitter { return sb.ToString(); } - /// The /api/memories/index response body parsed as a (expected: a JSON array). + /// Renders the bare-array index body — the form a server that does not honour + /// include=projects answers with, which carries no projects and so no lead-in. Anything + /// but an array yields null; the object form is read by + /// , which knows which shape it asked for. + /// The response body parsed as a . /// True when the user set disable_memory_index on their active profile. public static string? BuildFragment(JsonNode? indexNode, bool disabled) { if (disabled) return null; @@ -110,6 +125,23 @@ static bool AppendBoundedGroup(StringBuilder sb, string heading, List 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? projects) { + if (projects is null) return null; + var lines = new List(); + 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. diff --git a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryContextProvider.cs b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryContextProvider.cs index 64b3d1bb38..e12bb426d5 100644 --- a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryContextProvider.cs +++ b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryContextProvider.cs @@ -45,20 +45,57 @@ public async Task 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; + var fragment = MemoryIndexEmitter.BuildFragment(entries, projects); return fragment is null ? SessionStartMemoryContextResult.Empty : new SessionStartMemoryContextResult(SessionStartMemoryDisposition.Ready, fragment); } + /// + /// Reads the index body in either shape. A server that predates include=projects 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. + /// + 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 + }; + } + + /// + /// include=projects 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. + /// internal static string BuildUrl(string baseUrl, SessionStartMemoryScope scope) { var query = new List(); 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); } } diff --git a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryContracts.cs b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryContracts.cs index 16d6640696..2445713a08 100644 --- a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryContracts.cs +++ b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryContracts.cs @@ -27,6 +27,19 @@ internal sealed record SessionStartMemoryEntry( [property: JsonPropertyName("scope_kind")] string? ScopeKind = null, [property: JsonPropertyName("project_slug")] string? ProjectSlug = null); +/// A project the cwd repo is confirmed to belong to: the slug an agent passes on the +/// place axis of a save, and the human name it is known by. +internal sealed record SessionStartMemoryProject( + [property: JsonPropertyName("slug")] string? Slug, + [property: JsonPropertyName("name")] string? Name); + +/// The /api/memories/index body a server answers with when the request carries +/// include=projects. Unmapped members are skipped, so a later server may add fields here +/// without a CLI that predates them dropping the whole fragment. +internal sealed record SessionStartMemoryIndexResponse( + [property: JsonPropertyName("entries")] SessionStartMemoryEntry[]? Entries, + [property: JsonPropertyName("projects")] SessionStartMemoryProject[]? Projects); + internal sealed record SessionStartMemoryContextRequest( string BaseUrl, string? Cwd, @@ -83,6 +96,9 @@ internal static class SessionStartMemoryConstants { public const int MaxMetadataBytes = 16384; public const int MaxResponseBytes = 256 * 1024; public const int MaxEntries = 200; + // The lead-in competes with the memory list for the same fragment budget, so a repo reported + // in an implausible number of projects cannot crowd the memories out. + public const int MaxProjects = 8; public const int MaxFragmentBytes = 24 * 1024; public const int NormalRecordCap = 50_000; public const int TotalEntryCap = 55_000; diff --git a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryJsonContext.cs b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryJsonContext.cs index 75f0ab9104..2e05607cb2 100644 --- a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryJsonContext.cs +++ b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryJsonContext.cs @@ -8,6 +8,7 @@ namespace Capacitor.Cli.SessionStartMemory; DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = false)] [JsonSerializable(typeof(SessionStartMemoryEntry[]))] +[JsonSerializable(typeof(SessionStartMemoryIndexResponse))] [JsonSerializable(typeof(GuidelinesResponse))] [JsonSerializable(typeof(SessionStartMemoryStoreRecord))] [JsonSerializable(typeof(SessionStartMemoryStoreMetadata))] diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs index 6c34ab917a..b285b0cffe 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs @@ -393,6 +393,29 @@ public async Task session_start_with_an_empty_memory_index_array_emits_nothing() await Assert.That(stdout).IsEqualTo(""); } + [Test, NotInParallel] + public async Task session_start_carries_the_projects_lead_in_into_additional_context() { + // End of the whole path: the CLI asks for projects, the server answers in the object shape, + // and the lead-in reaches the harness envelope. The index is deliberately EMPTY, so the + // projects are the only thing keeping the hook from writing nothing at all. + using var fx = new Fixture(Config.Root); + fx.RespondJson = "{}"; + fx.MemoryIndexBody = """{"entries":[],"projects":[{"slug":"capacitor","name":"Kurrent Capacitor"}]}"""; + + var sid = Guid.NewGuid().ToString("N"); + var (exit, stdout) = await RunCapturingStdoutAsync(() => + fx.HandleAsync($$"""{"hook_event_name":"SessionStart","session_id":"{{sid}}","cwd":"/tmp","source":"startup"}""")); + + await Assert.That(exit).IsEqualTo(0); + var query = fx.MemoryIndexQuery; + await Assert.That(query).Contains("include=projects"); + var ctx = JsonNode.Parse(stdout)!["hookSpecificOutput"]!["additionalContext"]!.GetValue(); + await Assert.That(ctx).Contains( + "This repo belongs to project \"capacitor\" (Kurrent Capacitor). " + + "Save learnings that span its repos with project: \"capacitor\"."); + await Assert.That(ctx).DoesNotContain("## Team memory"); + } + [Test, NotInParallel] public async Task session_start_with_a_204_memory_index_response_emits_nothing() { // The provider special-cases 204 NoContent as CompleteWithoutContext without even @@ -1179,6 +1202,10 @@ sealed class Fixture : IDisposable { public int MemoryIndexRequestCount => _memoryServer.LogEntries .Count(e => e.RequestMessage.Path == "/api/memories/index"); + /// The query string of the last index GET, for a test pinning what the CLI asked for. + public string? MemoryIndexQuery => _memoryServer.LogEntries + .LastOrDefault(e => e.RequestMessage.Path == "/api/memories/index")?.RequestMessage.RawQuery; + /// The resolution the hook reads its per-profile settings through. A test that /// needs a setting honoured passes the profile here rather than steering process-global /// state. diff --git a/test/Capacitor.Cli.Tests.Unit/MemoryIndexEmitterTests.cs b/test/Capacitor.Cli.Tests.Unit/MemoryIndexEmitterTests.cs index 6523ca4814..9576450957 100644 --- a/test/Capacitor.Cli.Tests.Unit/MemoryIndexEmitterTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/MemoryIndexEmitterTests.cs @@ -8,21 +8,21 @@ static string Url(string? repoHash, string? machineTag) => SessionStartMemoryContextProvider.BuildUrl("http://srv", new SessionStartMemoryScope(repoHash, machineTag)); [Test] - public async Task No_repo_or_machine_omits_query() { + public async Task No_repo_or_machine_still_declares_the_projects_capability() { var url = Url(repoHash: null, machineTag: null); - await Assert.That(url).IsEqualTo("http://srv/api/memories/index"); + await Assert.That(url).IsEqualTo("http://srv/api/memories/index?include=projects"); } [Test] public async Task Includes_repo_and_machine_when_present() { var url = Url("abcd1234", "mach-01"); - await Assert.That(url).IsEqualTo("http://srv/api/memories/index?repo=abcd1234&machine=mach-01"); + await Assert.That(url).IsEqualTo("http://srv/api/memories/index?repo=abcd1234&machine=mach-01&include=projects"); } [Test] public async Task Repo_only_omits_machine_param() { var url = Url("abcd1234", machineTag: null); - await Assert.That(url).IsEqualTo("http://srv/api/memories/index?repo=abcd1234"); + await Assert.That(url).IsEqualTo("http://srv/api/memories/index?repo=abcd1234&include=projects"); } [Test] @@ -209,6 +209,130 @@ public async Task Fragment_is_not_a_json_envelope() { await Assert.That(fragment).DoesNotContain("hookSpecificOutput"); } + [Test] + public async Task Lead_in_names_the_repos_project_above_the_memory_list() { + var entries = new[] { new SessionStartMemoryEntry("id", "a-rule", "org", "d", "feedback") }; + + var fragment = MemoryIndexEmitter.BuildFragment( + entries, [new SessionStartMemoryProject("capacitor", "Kurrent Capacitor")])!; + + await Assert.That(fragment).Contains( + "This repo belongs to project \"capacitor\" (Kurrent Capacitor). " + + "Save learnings that span its repos with project: \"capacitor\"."); + await Assert.That(fragment.IndexOf("This repo belongs", StringComparison.Ordinal)) + .IsLessThan(fragment.IndexOf("## Team memory", StringComparison.Ordinal)); + await Assert.That(fragment.StartsWith(MemoryIndexEmitter.FragmentMarker, StringComparison.Ordinal)).IsTrue(); + await Assert.That(fragment).Contains("- a-rule: d"); + } + + [Test] + public async Task Lead_in_alone_is_a_fragment_when_the_index_is_empty() { + // A project with no memories yet is exactly when the agent needs the slug it can save to, + // so projects alone must produce a fragment where entries alone would produce none. + var fragment = MemoryIndexEmitter.BuildFragment([], [new SessionStartMemoryProject("capacitor", "Kurrent Capacitor")]); + + await Assert.That(fragment).IsNotNull(); + await Assert.That(fragment!.StartsWith(MemoryIndexEmitter.FragmentMarker, StringComparison.Ordinal)).IsTrue(); + await Assert.That(fragment).Contains("This repo belongs to project \"capacitor\""); + await Assert.That(fragment).DoesNotContain("## Team memory"); + } + + [Test] + public async Task No_projects_renders_exactly_what_an_older_server_produced() { + var entries = new[] { new SessionStartMemoryEntry("id", "a-rule", "org", "d", "feedback") }; + + var withNone = MemoryIndexEmitter.BuildFragment(entries, []); + var withNull = MemoryIndexEmitter.BuildFragment(entries); + + await Assert.That(withNone).IsEqualTo(withNull); + await Assert.That(withNone!).DoesNotContain("This repo belongs"); + await Assert.That(withNone!).StartsWith(MemoryIndexEmitter.FragmentMarker + "\n## Team memory"); + } + + [Test] + public async Task Several_projects_get_a_line_each() { + var fragment = MemoryIndexEmitter.BuildFragment([], [ + new SessionStartMemoryProject("capacitor", "Kurrent Capacitor"), + new SessionStartMemoryProject("platform", "Platform") + ])!; + + await Assert.That(fragment).Contains("This repo belongs to project \"capacitor\" (Kurrent Capacitor)."); + await Assert.That(fragment).Contains("This repo belongs to project \"platform\" (Platform)."); + await Assert.That(fragment.Split('\n').Count(l => l.StartsWith("This repo belongs", StringComparison.Ordinal))) + .IsEqualTo(2); + } + + [Test] + public async Task Project_name_is_dropped_when_it_adds_nothing_to_the_slug() { + var same = MemoryIndexEmitter.BuildFragment([], [new SessionStartMemoryProject("capacitor", "capacitor")])!; + var missing = MemoryIndexEmitter.BuildFragment([], [new SessionStartMemoryProject("capacitor", null)])!; + + await Assert.That(same).Contains("This repo belongs to project \"capacitor\". Save learnings"); + await Assert.That(missing).Contains("This repo belongs to project \"capacitor\". Save learnings"); + } + + [Test] + public async Task Projects_without_a_usable_slug_are_skipped() { + var fragment = MemoryIndexEmitter.BuildFragment([], [ + new SessionStartMemoryProject(null, "No Slug"), + new SessionStartMemoryProject(" ", "Blank"), + new SessionStartMemoryProject("real", "Real") + ])!; + + await Assert.That(fragment).Contains("project \"real\""); + await Assert.That(fragment.Split('\n').Count(l => l.StartsWith("This repo belongs", StringComparison.Ordinal))) + .IsEqualTo(1); + } + + [Test] + public async Task Only_slugless_projects_and_no_entries_emit_nothing() => + await Assert.That(MemoryIndexEmitter.BuildFragment([], [new SessionStartMemoryProject(" ", "Blank")])).IsNull(); + + [Test] + public async Task Lead_in_collapses_newlines_to_keep_one_line_per_project() { + var fragment = MemoryIndexEmitter.BuildFragment([], [new SessionStartMemoryProject("cap", "One\nTwo\r\nThree")])!; + + await Assert.That(fragment).Contains("project \"cap\" (One Two Three)."); + await Assert.That(fragment.Split('\n').Count(l => l.StartsWith("This repo belongs", StringComparison.Ordinal))) + .IsEqualTo(1); + } + + [Test] + public async Task Lead_in_stops_at_the_project_cap() { + var many = Enumerable.Range(0, SessionStartMemoryConstants.MaxProjects + 5) + .Select(i => new SessionStartMemoryProject($"p{i}", $"P{i}")) + .ToArray(); + + var fragment = MemoryIndexEmitter.BuildFragment([], many)!; + + await Assert.That(fragment.Split('\n').Count(l => l.StartsWith("This repo belongs", StringComparison.Ordinal))) + .IsEqualTo(SessionStartMemoryConstants.MaxProjects); + } + + [Test] + public async Task Lead_in_counts_against_the_fragment_budget() { + // The lead-in sits in the prefix the group appender measures from, so a memory list that + // overruns the budget must lose entries to it rather than push the fragment past the cap. + // A full cap of long-named projects is far wider than one entry line, so the displacement + // does not turn on how much slack the truncated list happened to leave. + var entries = Enumerable.Range(0, SessionStartMemoryConstants.MaxEntries) + .Select(i => new SessionStartMemoryEntry($"id-{i}", $"slug-{i}", "org", new string('d', 300), "feedback")) + .ToArray(); + var projects = Enumerable.Range(0, SessionStartMemoryConstants.MaxProjects) + .Select(i => new SessionStartMemoryProject($"project-{i}", new string('n', 100))) + .ToArray(); + + var without = MemoryIndexEmitter.BuildFragment(entries)!; + var with = MemoryIndexEmitter.BuildFragment(entries, projects)!; + + static int Bullets(string fragment) => fragment.Split('\n').Count(l => l.StartsWith("- slug-", StringComparison.Ordinal)); + await Assert.That(System.Text.Encoding.UTF8.GetByteCount(without)) + .IsLessThanOrEqualTo(SessionStartMemoryConstants.MaxFragmentBytes); + await Assert.That(System.Text.Encoding.UTF8.GetByteCount(with)) + .IsLessThanOrEqualTo(SessionStartMemoryConstants.MaxFragmentBytes); + await Assert.That(Bullets(without) - Bullets(with)).IsGreaterThanOrEqualTo(5); + } + [Test] [NotInParallel] public async Task Typed_emitter_keeps_fragment_size_accounting_linear() { diff --git a/test/Capacitor.Cli.Tests.Unit/SessionStartMemory/SessionStartMemoryFoundationTests.cs b/test/Capacitor.Cli.Tests.Unit/SessionStartMemory/SessionStartMemoryFoundationTests.cs index d092fca6d3..c63e34bd9a 100644 --- a/test/Capacitor.Cli.Tests.Unit/SessionStartMemory/SessionStartMemoryFoundationTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/SessionStartMemory/SessionStartMemoryFoundationTests.cs @@ -258,7 +258,66 @@ public async Task Provider_omits_only_unresolved_scope_axes() { await provider.GetAsync(new SessionStartMemoryContextRequest( "https://example.test/", null, false, TimeSpan.FromSeconds(1), CancellationToken.None)); - await Assert.That(handler.Uri).IsEqualTo("https://example.test/api/memories/index?machine=machine%20tag"); + await Assert.That(handler.Uri).IsEqualTo("https://example.test/api/memories/index?machine=machine%20tag&include=projects"); + } + + [Test] + public async Task Provider_reads_the_index_in_either_body_shape() { + var scope = new FixedScopeResolver("repo", "machine"); + SessionStartMemoryContextProvider Provider(string body) => new(scope, + (_, _) => Task.FromResult(new HttpClient(new StaticHandler(HttpStatusCode.OK, body)))); + const string entry = "{\"memory_id\":\"1\",\"slug\":\"s\",\"audience\":\"org\",\"description\":\"d\",\"kind\":\"feedback\"}"; + var request = new SessionStartMemoryContextRequest("https://example", "/repo", false, + TimeSpan.FromSeconds(1), CancellationToken.None); + + // An older server ignores include=projects and answers with the bare array it always sent. + var bareArray = await Provider($"[{entry}]").GetAsync(request); + var withProjects = await Provider( + $"{{\"entries\":[{entry}],\"projects\":[{{\"slug\":\"capacitor\",\"name\":\"Kurrent Capacitor\"}}]}}").GetAsync(request); + var projectsOnly = await Provider( + "{\"entries\":[],\"projects\":[{\"slug\":\"capacitor\",\"name\":\"Kurrent Capacitor\"}]}").GetAsync(request); + var neither = await Provider("{\"entries\":[],\"projects\":[]}").GetAsync(request); + + await Assert.That(bareArray.Fragment).Contains("- s: d"); + await Assert.That(bareArray.Fragment).DoesNotContain("This repo belongs"); + await Assert.That(withProjects.Fragment).Contains("This repo belongs to project \"capacitor\""); + await Assert.That(withProjects.Fragment).Contains("- s: d"); + await Assert.That(projectsOnly.Disposition).IsEqualTo(SessionStartMemoryDisposition.Ready); + await Assert.That(projectsOnly.Fragment).Contains("This repo belongs to project \"capacitor\""); + await Assert.That(neither.Disposition).IsEqualTo(SessionStartMemoryDisposition.CompleteWithoutContext); + } + + [Test] + [Arguments("\"not-an-index\"")] + [Arguments("{}")] + [Arguments("{\"entries\":null,\"projects\":null}")] + [Arguments("{\"unrelated\":1}")] + public async Task Provider_retries_a_body_that_is_not_an_index(string body) { + // An object carrying neither member must stay retryable rather than read as an empty index: + // Empty completes the once-per-session lease, so a session would never ask again. + var provider = new SessionStartMemoryContextProvider(new FixedScopeResolver("repo", "machine"), + (_, _) => Task.FromResult(new HttpClient(new StaticHandler(HttpStatusCode.OK, body)))); + + var result = await provider.GetAsync(new SessionStartMemoryContextRequest("https://example", "/repo", false, + TimeSpan.FromSeconds(1), CancellationToken.None)); + + await Assert.That(result.Disposition).IsEqualTo(SessionStartMemoryDisposition.RetryableFailure); + } + + [Test] + [Arguments("{\"entries\":[],\"projects\":[]}")] + [Arguments("{\"entries\":[]}")] + [Arguments("[]")] + public async Task Provider_completes_a_genuinely_empty_index(string body) { + // The mirror of the case above: a present-but-empty array IS an answer, and completing the + // lease on it is what stops a session re-fetching an index the server says is empty. + var provider = new SessionStartMemoryContextProvider(new FixedScopeResolver("repo", "machine"), + (_, _) => Task.FromResult(new HttpClient(new StaticHandler(HttpStatusCode.OK, body)))); + + var result = await provider.GetAsync(new SessionStartMemoryContextRequest("https://example", "/repo", false, + TimeSpan.FromSeconds(1), CancellationToken.None)); + + await Assert.That(result.Disposition).IsEqualTo(SessionStartMemoryDisposition.CompleteWithoutContext); } [Test]