From f94ff83049abdd43a7b9f85d1c4d896ab7f58544 Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 1 Oct 2026 17:11:48 -0700 Subject: [PATCH 01/16] Staticcheck passes: dead helpers removed, a tautological test compares what it meant to, and the archive destination check reads without a loop that always breaks --- cmd/logos/dream.go | 4 --- cmd/logos/ingest.go | 17 ++++++----- cmd/logos/main.go | 9 ------ enginetest/facade_test.go | 54 +++++++++++++++++++---------------- internal/ingest/candidate.go | 7 +---- internal/ingest/sweep.go | 10 ------- internal/memory/bench_test.go | 2 +- internal/setup/hosts.go | 1 + 8 files changed, 40 insertions(+), 64 deletions(-) diff --git a/cmd/logos/dream.go b/cmd/logos/dream.go index 9072e94..f434ddf 100644 --- a/cmd/logos/dream.go +++ b/cmd/logos/dream.go @@ -11,10 +11,6 @@ import ( "github.com/Coder8124/logos/internal/router" ) -// dreamHour is the local hour past which the daemon runs the nightly pass. Zero -// is midnight; the day just ended, so it is the natural moment to sleep on it. -const dreamHour = 0 - // dreamCmd is the nightly consolidation pass and its review queue. // // logos dream [--date YYYY-MM-DD] [--phase nrem|rem] [--dry-run] diff --git a/cmd/logos/ingest.go b/cmd/logos/ingest.go index dd16493..91fce26 100644 --- a/cmd/logos/ingest.go +++ b/cmd/logos/ingest.go @@ -488,15 +488,14 @@ func runIngestStatus() error { // come to rest is the user's decision and their corp's policy, not ours. func runIngestArchive(args []string) error { dest := "" - for _, a := range positionals(args) { - // A flag is never the destination. `logos ingest archive --help` used - // to create a directory called "--help" and copy raw transcripts into - // it, and so did any typo. - if strings.HasPrefix(a, "-") { - return fmt.Errorf("%s is a flag, not a directory — logos ingest archive takes the directory to copy the transcripts into: logos ingest archive ~/transcripts-backup", a) - } - dest = a - break + if pos := positionals(args); len(pos) > 0 { + dest = pos[0] + } + // A flag is never the destination. `logos ingest archive --help` used to + // create a directory called "--help" and copy raw transcripts into it, and + // so did any typo. + if strings.HasPrefix(dest, "-") { + return fmt.Errorf("%s is a flag, not a directory — logos ingest archive takes the directory to copy the transcripts into: logos ingest archive ~/transcripts-backup", dest) } if dest == "" { return fmt.Errorf("logos ingest archive needs a directory to copy the transcripts into: logos ingest archive ~/transcripts-backup") diff --git a/cmd/logos/main.go b/cmd/logos/main.go index 485f475..5fac798 100644 --- a/cmd/logos/main.go +++ b/cmd/logos/main.go @@ -627,15 +627,6 @@ func flagStrs(args []string, name string) []string { return out } -func argInt(args []string, pos, def int) int { - if pos < len(args) { - if v, err := strconv.Atoi(args[pos]); err == nil { - return v - } - } - return def -} - func env(key, def string) string { if v := os.Getenv(key); v != "" { return v diff --git a/enginetest/facade_test.go b/enginetest/facade_test.go index 61d3a39..5903be4 100644 --- a/enginetest/facade_test.go +++ b/enginetest/facade_test.go @@ -4,7 +4,7 @@ import ( "go/ast" "go/parser" "go/token" - "io/fs" + "os" "path/filepath" "sort" "strings" @@ -16,33 +16,37 @@ import ( // alias for free and so never need re-exporting. func exportedNames(t *testing.T, dir string) map[string]bool { t.Helper() - pkgs, err := parser.ParseDir(token.NewFileSet(), dir, func(fi fs.FileInfo) bool { - return !strings.HasSuffix(fi.Name(), "_test.go") - }, 0) + entries, err := os.ReadDir(dir) if err != nil { - t.Fatalf("parsing %s: %v", dir, err) + t.Fatalf("reading %s: %v", dir, err) } + fset := token.NewFileSet() names := map[string]bool{} - for _, pkg := range pkgs { - for _, file := range pkg.Files { - for _, d := range file.Decls { - switch d := d.(type) { - case *ast.FuncDecl: - if d.Recv == nil && d.Name.IsExported() { - names[d.Name.Name] = true - } - case *ast.GenDecl: - for _, spec := range d.Specs { - switch s := spec.(type) { - case *ast.TypeSpec: - if s.Name.IsExported() { - names[s.Name.Name] = true - } - case *ast.ValueSpec: - for _, n := range s.Names { - if n.IsExported() { - names[n.Name] = true - } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") { + continue + } + file, err := parser.ParseFile(fset, filepath.Join(dir, e.Name()), nil, 0) + if err != nil { + t.Fatalf("parsing %s: %v", e.Name(), err) + } + for _, d := range file.Decls { + switch d := d.(type) { + case *ast.FuncDecl: + if d.Recv == nil && d.Name.IsExported() { + names[d.Name.Name] = true + } + case *ast.GenDecl: + for _, spec := range d.Specs { + switch s := spec.(type) { + case *ast.TypeSpec: + if s.Name.IsExported() { + names[s.Name.Name] = true + } + case *ast.ValueSpec: + for _, n := range s.Names { + if n.IsExported() { + names[n.Name] = true } } } diff --git a/internal/ingest/candidate.go b/internal/ingest/candidate.go index 416160e..2212266 100644 --- a/internal/ingest/candidate.go +++ b/internal/ingest/candidate.go @@ -256,12 +256,7 @@ func Parse(raw, filename string) Candidate { return c } -func parseTS(s string) int64 { - ts, _ := parseTSChecked(s) - return ts -} - -// parseTSChecked is parseTS with the distinction parseTS throws away: a line +// parseTSChecked keeps a distinction a plain parse would throw away: a line // that was absent is not the same as a line that was there and unreadable. The // second returns true so the caller can report it rather than carry a zero that // reads as "this session has no start time". diff --git a/internal/ingest/sweep.go b/internal/ingest/sweep.go index bbf564f..73144dc 100644 --- a/internal/ingest/sweep.go +++ b/internal/ingest/sweep.go @@ -295,16 +295,6 @@ func growRecord(vaultDir string, history []session.Checkpoint, rec session.Check return &grown, nil } -// transcriptID is a transcript's id as its path gives it: the part after a -// Cursor source's '#', or the file's name without its extension. -func transcriptID(path string) string { - if i := strings.LastIndexByte(path, '#'); i >= 0 { - return path[i+1:] - } - base := filepath.Base(path) - return strings.TrimSuffix(base, filepath.Ext(base)) -} - // belongsTo reports whether s is a session of project. The transcript's own // Project is the basename of where the host started; the server names the // same place by its marker or repository root (scope.Name), so a session diff --git a/internal/memory/bench_test.go b/internal/memory/bench_test.go index 230d414..77ca066 100644 --- a/internal/memory/bench_test.go +++ b/internal/memory/bench_test.go @@ -5,7 +5,7 @@ import "testing" func TestNormalizeSessionIDMatchesConventions(t *testing.T) { // Evidence ids ("answer_") and haystack ids should compare equal when // they refer to the same session. - if normalizeSessionID("answer_280352e9") != normalizeSessionID("answer_280352e9") { + if normalizeSessionID("answer_280352e9") != normalizeSessionID("280352e9") { t.Error("identical ids should match") } // A plain haystack id is unchanged. diff --git a/internal/setup/hosts.go b/internal/setup/hosts.go index 1ebebd6..74dcdde 100644 --- a/internal/setup/hosts.go +++ b/internal/setup/hosts.go @@ -62,6 +62,7 @@ func claudeCode() Host { return Failed, fmt.Errorf("removed the old logos entry from Claude Code but could not add the new one — run `logos setup` again: %w", err) } if outcome != Registered { + //lint:ignore ST1005 the sentence starts with the product's name return Failed, fmt.Errorf("Claude Code still refuses to replace its logos entry after removing it — check `claude mcp list`") } return Updated, nil From e3e1eb139be897b25f3b30cf74cfc280a50e7014 Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 1 Oct 2026 17:11:22 -0700 Subject: [PATCH 02/16] Every push to main and every pull request runs the build, vet, staticcheck, tests and the chaos tier --- .github/workflows/ci.yml | 51 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0893110 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +# The gate, on every push and pull request. +# +# Until this existed the only place CI ran the tests was release.yml, on a tag — +# so a main that no longer passed `go test` (#229: the site and the npm +# manifests disagreeing about the version) was found by the release, not by the +# commit that broke it. These are the commands CLAUDE.md asks for before +# saying a change works, so a green check here means the same thing a green +# local run does. + +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Format + run: gofmt -l . | grep -v '\.venv' | (! grep .) || (echo "unformatted files above"; exit 1) + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + # Pinned, so a new staticcheck release cannot turn main red on its own. + - name: Staticcheck + run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./... + + - name: Test + run: go test ./... + + # The durability tier: crashes between the vault write and the index + # write, deleted indexes, torn files. Uncached, because a cached pass of a + # test about what survives a crash proves nothing about this commit. + - name: Chaos + run: go test -count=1 -tags chaos ./chaos/... From abd272240a93f6eed66084cb28d95206f5703fd6 Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 1 Oct 2026 17:15:58 -0700 Subject: [PATCH 03/16] The CLI and the MCP checkpoint receipt get their nudges from one package, so a nudge or a wording fix reaches both --- cmd/logos/session.go | 22 ++------- internal/advice/advice.go | 74 ++++++++++++++++++++++++++++++ internal/advice/advice_test.go | 56 ++++++++++++++++++++++ internal/mcpserver/nomodel_test.go | 3 +- internal/mcpserver/server.go | 20 ++------ 5 files changed, 138 insertions(+), 37 deletions(-) create mode 100644 internal/advice/advice.go create mode 100644 internal/advice/advice_test.go diff --git a/cmd/logos/session.go b/cmd/logos/session.go index bd2d532..9aeb314 100644 --- a/cmd/logos/session.go +++ b/cmd/logos/session.go @@ -10,8 +10,8 @@ import ( "strings" "time" + "github.com/Coder8124/logos/internal/advice" "github.com/Coder8124/logos/internal/contextpack" - "github.com/Coder8124/logos/internal/deadend" "github.com/Coder8124/logos/internal/ingest" "github.com/Coder8124/logos/internal/memory" "github.com/Coder8124/logos/internal/provider" @@ -202,24 +202,8 @@ func runCheckpoint(args []string) error { if said := secret.Summary(c.Redactions); said != "" { fmt.Println(said + ".") } - if dropped > 0 { - // Said out loud so the agent knows its "none" was not kept as a dead end. - fmt.Printf("dropped %d placeholder failed entr%s — leave failed empty when nothing was ruled out.\n", - dropped, map[bool]string{true: "y", false: "ies"}[dropped == 1]) - } - if session.NextReadsAsMoreThanOneStep(c.Next) { - fmt.Println("recorded as given; --next reads as more than one step — the conditional or later parts usually belong in --question, which resume prints as \"Still open\".") - } - if n := session.DecisionsWithoutReason(c.Decisions); n > 0 { - fmt.Printf("recorded as given; %d --decided %s no reason — \"X, because Y\" lets the next agent see what forced it without rereading the transcript.\n", - n, map[bool]string{true: "entry gives", false: "entries give"}[n == 1]) - } - if session.IntentDropped(*c, earlier) { - fmt.Println("recorded as given; no intent carried: this task's wording matches no earlier checkpoint that gave its reason, though the work before it had one, so resume will say what is being done but not why — pass --intent again when a task is reworded.") - } - if n := deadend.UnplacedToolchain(c.Failed); n > 0 { - fmt.Printf("recorded as given; %d --failed %s a tool, package manager or PATH with no layer — one that is about this machine's toolchain rather than the code belongs as \"route: ... | observation: ... | layer: environment\", so an agent on another toolchain can tell it does not apply to them.\n", - n, map[bool]string{true: "entry names", false: "entries name"}[n == 1]) + for _, said := range advice.Checkpoint(*c, earlier, dropped, advice.CLI) { + fmt.Println(said) } // Deliberately not "run `logos index` to make it searchable" any more. That // was true about general retrieval and misleading about the thing the user diff --git a/internal/advice/advice.go b/internal/advice/advice.go new file mode 100644 index 0000000..6067bd0 --- /dev/null +++ b/internal/advice/advice.go @@ -0,0 +1,74 @@ +// Package advice is what a checkpoint receipt says about the checkpoint it just +// wrote: the placeholders it dropped and the fields it kept as given but doubts. +// +// The CLI and the MCP server used to build these sentences each for itself, +// and they drifted — a nudge added to one front end reached agents on that +// host only, and a wording fix had to be found and made twice. The checks and +// the sentences live here; a front end supplies only how its fields are +// spelled, "--next" on a command line and `next` in a tool call. +package advice + +import ( + "fmt" + + "github.com/Coder8124/logos/internal/deadend" + "github.com/Coder8124/logos/internal/session" +) + +// Fields is how one front end names a checkpoint's fields, so a nudge tells +// the agent the exact thing to type. The paired nouns are singular and plural. +type Fields struct { + Next, Questions, Intent, Failed string + Decision, RuledOut [2]string +} + +// CLI is `logos checkpoint`'s spelling. +var CLI = Fields{ + Next: "--next", Questions: "--question", Intent: "--intent", Failed: "--failed", + Decision: [2]string{"--decided entry", "--decided entries"}, + RuledOut: [2]string{"--failed entry", "--failed entries"}, +} + +// MCP is the checkpoint tool's spelling. +var MCP = Fields{ + Next: "`next`", Questions: "`questions`", Intent: "`intent`", Failed: "`failed`", + Decision: [2]string{"decision", "decisions"}, + RuledOut: [2]string{"ruled-out approach", "ruled-out approaches"}, +} + +// Checkpoint returns the receipt's sentences for c, in the order an agent +// should act on them. dropped is how many placeholder failed entries the +// caller removed before committing; earlier is the project's history as it +// stood before this checkpoint, which the intent check reads. +// +// Every sentence after the first is "recorded as given": a checkpoint is +// written when context is running out, so nothing here refuses one — it says +// the doubt out loud instead. +func Checkpoint(c session.Checkpoint, earlier []session.Checkpoint, dropped int, f Fields) []string { + var out []string + if dropped > 0 { + // Said out loud so the agent knows its "none" was not kept as a dead end. + out = append(out, fmt.Sprintf("Dropped %s from %s; leave it empty when nothing was ruled out.", + count(dropped, [2]string{"placeholder entry", "placeholder entries"}), f.Failed)) + } + if session.NextReadsAsMoreThanOneStep(c.Next) { + out = append(out, fmt.Sprintf("Recorded as given; %s reads as more than one step — the parts that are conditional or later usually belong in %s, which resume prints as \"Still open\".", f.Next, f.Questions)) + } + if n := session.DecisionsWithoutReason(c.Decisions); n > 0 { + out = append(out, fmt.Sprintf("Recorded as given; %s without a reason — \"X, because Y\" lets the next agent see what forced it without rereading the transcript.", count(n, f.Decision))) + } + if session.IntentDropped(c, earlier) { + out = append(out, fmt.Sprintf("Recorded as given; no intent carried: this task's wording matches no earlier checkpoint that gave its reason, though the work before it had one, so resume will say what is being done but not why — pass %s again when a task is reworded.", f.Intent)) + } + if n := deadend.UnplacedToolchain(c.Failed); n > 0 { + out = append(out, fmt.Sprintf("Recorded as given; %s about a tool, package manager or PATH with no layer — one that is about this machine's toolchain rather than the code belongs as `route: ... | observation: ... | layer: environment`, so an agent on another toolchain can tell it does not apply to them.", count(n, f.RuledOut))) + } + return out +} + +func count(n int, noun [2]string) string { + if n == 1 { + return "1 " + noun[0] + } + return fmt.Sprintf("%d %s", n, noun[1]) +} diff --git a/internal/advice/advice_test.go b/internal/advice/advice_test.go new file mode 100644 index 0000000..96a8c79 --- /dev/null +++ b/internal/advice/advice_test.go @@ -0,0 +1,56 @@ +package advice + +import ( + "strings" + "testing" + + "github.com/Coder8124/logos/internal/session" +) + +// The two front ends drifted when each built its own receipt. Here the same +// checkpoint gets the same nudges from both, and they differ only in how a +// field is spelled. +func TestBothFrontEndsGiveTheSameNudgesInTheirOwnSpelling(t *testing.T) { + c := session.Checkpoint{ + Project: "kestrel", + State: "fixed and merged", + Next: "if asked: populate Appendix A, otherwise quote the extruded option", + Decisions: []string{"use sqlite", "use extruded frames"}, + Failed: []string{"brain binary not on PATH"}, + } + cli := Checkpoint(c, nil, 1, CLI) + mcp := Checkpoint(c, nil, 1, MCP) + if len(cli) != 4 || len(mcp) != len(cli) { + t.Fatalf("got %d CLI and %d MCP sentences, want 4 each:\n%s\n---\n%s", + len(cli), len(mcp), strings.Join(cli, "\n"), strings.Join(mcp, "\n")) + } + for i, want := range []string{"placeholder", "more than one step", "without a reason", "layer: environment"} { + if !strings.Contains(cli[i], want) || !strings.Contains(mcp[i], want) { + t.Errorf("sentence %d does not say %q in both:\n%s\n%s", i, want, cli[i], mcp[i]) + } + } + for _, want := range []string{"--next", "--question", "2 --decided entries", "1 --failed entry"} { + if !strings.Contains(strings.Join(cli, "\n"), want) { + t.Errorf("the CLI receipt does not name %s", want) + } + } + for _, want := range []string{"`next`", "`questions`", "2 decisions", "1 ruled-out approach"} { + if !strings.Contains(strings.Join(mcp, "\n"), want) { + t.Errorf("the MCP receipt does not name %s", want) + } + } +} + +func TestAPlainCheckpointGetsNoAdvice(t *testing.T) { + c := session.Checkpoint{ + Project: "kestrel", + State: "fixed and merged", + Verified: []string{"go test ./... passes"}, + Next: "quote the extruded option", + Decisions: []string{"use sqlite, because the vault stays on this machine"}, + Failed: []string{"retrying hid the race"}, + } + if got := Checkpoint(c, nil, 0, MCP); len(got) != 0 { + t.Errorf("a checkpoint with nothing to doubt got advice:\n%s", strings.Join(got, "\n")) + } +} diff --git a/internal/mcpserver/nomodel_test.go b/internal/mcpserver/nomodel_test.go index c2f9185..9da09b7 100644 --- a/internal/mcpserver/nomodel_test.go +++ b/internal/mcpserver/nomodel_test.go @@ -558,7 +558,7 @@ func TestACheckpointAsksForTheReasonWhenADecisionHasNone(t *testing.T) { if !ok { t.Fatal("checkpoint failed") } - if !strings.Contains(line, "1 decision has no reason") { + if !strings.Contains(line, "1 decision without a reason") { t.Errorf("a decision with no reason was recorded with no comment:\n%s", truncateForLog(line)) } line, _ = call(t, c, 4, "resume", map[string]any{"project": "kestrel"}) @@ -608,3 +608,4 @@ func TestACheckpointSaysWhenARewordedTaskDropsItsReason(t *testing.T) { t.Errorf("a reworded task dropped its reason with no comment:\n%s", truncateForLog(line)) } } + diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 6293086..31a5388 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -39,6 +39,7 @@ import ( "sync" "time" + "github.com/Coder8124/logos/internal/advice" "github.com/Coder8124/logos/internal/agentprompt" "github.com/Coder8124/logos/internal/announce" "github.com/Coder8124/logos/internal/buildinfo" @@ -1473,23 +1474,8 @@ func (s *Session) checkpoint(args map[string]any, handoffTo string) (string, err if said := secret.Summary(c.Redactions); said != "" { msg += " " + said + "." } - if dropped > 0 { - msg += fmt.Sprintf(" Dropped %d placeholder %s from failed; leave failed empty when nothing was ruled out.", - dropped, map[bool]string{true: "entry", false: "entries"}[dropped == 1]) - } - if session.NextReadsAsMoreThanOneStep(c.Next) { - msg += " Recorded as given; `next` reads as more than one step — the parts that are conditional or later usually belong in `questions`, which resume prints as \"Still open\"." - } - if n := session.DecisionsWithoutReason(c.Decisions); n > 0 { - msg += fmt.Sprintf(" Recorded as given; %d %s no reason — \"X, because Y\" lets the next agent see what forced it without rereading the transcript.", - n, map[bool]string{true: "decision has", false: "decisions have"}[n == 1]) - } - if session.IntentDropped(*c, earlier) { - msg += " Recorded as given; no intent carried: this task's wording matches no earlier checkpoint that gave its reason, though the work before it had one, so resume will say what is being done but not why — pass `intent` again when a task is reworded." - } - if n := deadend.UnplacedToolchain(c.Failed); n > 0 { - msg += fmt.Sprintf(" Recorded as given; %d ruled-out %s a tool, package manager or PATH with no layer — one that is about this machine's toolchain rather than the code belongs as `route: ... | observation: ... | layer: environment`, so an agent on another toolchain can tell it does not apply to them.", - n, map[bool]string{true: "approach names", false: "approaches name"}[n == 1]) + for _, said := range advice.Checkpoint(*c, earlier, dropped, advice.MCP) { + msg += " " + said } if handoffTo != "" { msg += fmt.Sprintf(" Handed off to %s — they can call resume(%q).", handoffTo, c.Project) From cbd77490787ad1c9d3b507a4d29ab88d58df63be Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 1 Oct 2026 17:16:14 -0700 Subject: [PATCH 04/16] A checkpoint whose state says the work is done with nothing under verified is kept, and the receipt asks for the command that showed it --- internal/advice/advice.go | 11 +++++--- internal/advice/advice_test.go | 10 +++---- internal/mcpserver/nomodel_test.go | 26 ++++++++++++++++++ internal/session/checkpoint.go | 44 ++++++++++++++++++++++++++++++ internal/session/session_test.go | 32 ++++++++++++++++++++++ 5 files changed, 114 insertions(+), 9 deletions(-) diff --git a/internal/advice/advice.go b/internal/advice/advice.go index 6067bd0..7b5e685 100644 --- a/internal/advice/advice.go +++ b/internal/advice/advice.go @@ -18,20 +18,20 @@ import ( // Fields is how one front end names a checkpoint's fields, so a nudge tells // the agent the exact thing to type. The paired nouns are singular and plural. type Fields struct { - Next, Questions, Intent, Failed string - Decision, RuledOut [2]string + Next, Questions, Intent, Failed, Verified string + Decision, RuledOut [2]string } // CLI is `logos checkpoint`'s spelling. var CLI = Fields{ - Next: "--next", Questions: "--question", Intent: "--intent", Failed: "--failed", + Next: "--next", Questions: "--question", Intent: "--intent", Failed: "--failed", Verified: "--verified", Decision: [2]string{"--decided entry", "--decided entries"}, RuledOut: [2]string{"--failed entry", "--failed entries"}, } // MCP is the checkpoint tool's spelling. var MCP = Fields{ - Next: "`next`", Questions: "`questions`", Intent: "`intent`", Failed: "`failed`", + Next: "`next`", Questions: "`questions`", Intent: "`intent`", Failed: "`failed`", Verified: "`verified`", Decision: [2]string{"decision", "decisions"}, RuledOut: [2]string{"ruled-out approach", "ruled-out approaches"}, } @@ -51,6 +51,9 @@ func Checkpoint(c session.Checkpoint, earlier []session.Checkpoint, dropped int, out = append(out, fmt.Sprintf("Dropped %s from %s; leave it empty when nothing was ruled out.", count(dropped, [2]string{"placeholder entry", "placeholder entries"}), f.Failed)) } + if session.ClaimsDoneUnverified(c) { + out = append(out, fmt.Sprintf("Recorded as given; the state says the work is done but %s is empty, so the next agent takes that on trust — add the command that showed it.", f.Verified)) + } if session.NextReadsAsMoreThanOneStep(c.Next) { out = append(out, fmt.Sprintf("Recorded as given; %s reads as more than one step — the parts that are conditional or later usually belong in %s, which resume prints as \"Still open\".", f.Next, f.Questions)) } diff --git a/internal/advice/advice_test.go b/internal/advice/advice_test.go index 96a8c79..5906dd8 100644 --- a/internal/advice/advice_test.go +++ b/internal/advice/advice_test.go @@ -20,21 +20,21 @@ func TestBothFrontEndsGiveTheSameNudgesInTheirOwnSpelling(t *testing.T) { } cli := Checkpoint(c, nil, 1, CLI) mcp := Checkpoint(c, nil, 1, MCP) - if len(cli) != 4 || len(mcp) != len(cli) { - t.Fatalf("got %d CLI and %d MCP sentences, want 4 each:\n%s\n---\n%s", + if len(cli) != 5 || len(mcp) != len(cli) { + t.Fatalf("got %d CLI and %d MCP sentences, want 5 each:\n%s\n---\n%s", len(cli), len(mcp), strings.Join(cli, "\n"), strings.Join(mcp, "\n")) } - for i, want := range []string{"placeholder", "more than one step", "without a reason", "layer: environment"} { + for i, want := range []string{"placeholder", "work is done", "more than one step", "without a reason", "layer: environment"} { if !strings.Contains(cli[i], want) || !strings.Contains(mcp[i], want) { t.Errorf("sentence %d does not say %q in both:\n%s\n%s", i, want, cli[i], mcp[i]) } } - for _, want := range []string{"--next", "--question", "2 --decided entries", "1 --failed entry"} { + for _, want := range []string{"--verified", "--next", "--question", "2 --decided entries", "1 --failed entry"} { if !strings.Contains(strings.Join(cli, "\n"), want) { t.Errorf("the CLI receipt does not name %s", want) } } - for _, want := range []string{"`next`", "`questions`", "2 decisions", "1 ruled-out approach"} { + for _, want := range []string{"`verified`", "`next`", "`questions`", "2 decisions", "1 ruled-out approach"} { if !strings.Contains(strings.Join(mcp, "\n"), want) { t.Errorf("the MCP receipt does not name %s", want) } diff --git a/internal/mcpserver/nomodel_test.go b/internal/mcpserver/nomodel_test.go index 9da09b7..c9cbaa6 100644 --- a/internal/mcpserver/nomodel_test.go +++ b/internal/mcpserver/nomodel_test.go @@ -609,3 +609,29 @@ func TestACheckpointSaysWhenARewordedTaskDropsItsReason(t *testing.T) { } } +// A state that says the work is done, with nothing under verified, is the +// checkpoint the next agent is most likely to build on without checking. It is +// kept as given and the receipt asks for the proof. +func TestACheckpointThatClaimsDoneWithNothingVerifiedAsksForTheProof(t *testing.T) { + c, _ := startNoModel(t) + + line, ok := call(t, c, 3, "checkpoint", map[string]any{ + "project": "kestrel", "state": "fixed and merged to main", + }) + if !ok { + t.Fatal("checkpoint failed") + } + if !strings.Contains(line, "`verified` is empty") { + t.Errorf("a done claim with nothing verified was recorded with no comment:\n%s", truncateForLog(line)) + } + + line, ok = call(t, c, 4, "checkpoint", map[string]any{ + "project": "kestrel", "state": "fixed and merged to main", "verified": []string{"go test ./... passes"}, + }) + if !ok { + t.Fatal("checkpoint failed") + } + if strings.Contains(line, "is empty") { + t.Errorf("a done claim with its proof was second-guessed:\n%s", truncateForLog(line)) + } +} diff --git a/internal/session/checkpoint.go b/internal/session/checkpoint.go index 8d15634..7c8dfb9 100644 --- a/internal/session/checkpoint.go +++ b/internal/session/checkpoint.go @@ -588,6 +588,50 @@ func DecisionsWithoutReason(decisions []string) int { return n } +// ClaimsDoneUnverified reports a state that says the work is finished while +// verified is empty. That is the most misleading checkpoint there is: the next +// agent reads "fixed and merged" as settled and builds on it, and the record +// holds nothing that shows it was ever run. A receipt, not a refusal, for the +// reason NextReadsAsMoreThanOneStep gives. +// +// A claim word counts only when nothing in the two words before it takes it +// back — "half done", "not fixed yet", "nothing finished" are the opposite +// claim, and flagging them would teach agents to ignore the nudge. +func ClaimsDoneUnverified(c Checkpoint) bool { + if len(c.Verified) > 0 { + return false + } + words := strings.FieldsFunc(strings.ToLower(c.State), func(r rune) bool { + return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '\'') + }) + for i, w := range words { + if !doneWords[w] { + continue + } + negated := false + for j := max(0, i-2); j < i; j++ { + if undoneWords[words[j]] || strings.HasSuffix(words[j], "n't") { + negated = true + } + } + if !negated { + return true + } + } + return false +} + +var doneWords = map[string]bool{ + "done": true, "complete": true, "completed": true, "finished": true, "fixed": true, + "passing": true, "passes": true, "shipped": true, "merged": true, + "resolved": true, +} + +var undoneWords = map[string]bool{ + "not": true, "no": true, "nothing": true, "never": true, "half": true, + "almost": true, "nearly": true, "partly": true, "partially": true, "yet": true, +} + var reasonWords = map[string]bool{ "because": true, "since": true, "so": true, "given": true, "per": true, "why": true, "otherwise": true, "unless": true, "until": true, "though": true, diff --git a/internal/session/session_test.go b/internal/session/session_test.go index dd23bc0..7570f9f 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -609,3 +609,35 @@ func TestADecisionWithNoReasonIsCounted(t *testing.T) { } } } + +// "Done" with nothing under verified is the checkpoint that misleads most: the +// next agent builds on a claim nobody showed. The examples that must not fire +// are the ones a working vault actually holds — work still in progress, a +// negated claim, and "done" with its proof beside it. +func TestAStateThatClaimsDoneWithNothingVerifiedIsFlagged(t *testing.T) { + for _, state := range []string{ + "Done. Fix is merged to main.", + "#231 fixed and merged; tester's report closed", + "All tests passing, feature complete", + "shipped in 0.4.10", + } { + if !ClaimsDoneUnverified(Checkpoint{State: state}) { + t.Errorf("a state claiming done with nothing verified was not flagged: %q", state) + } + } + for _, state := range []string{ + "", + "half done: the parser works on one file, the walker is not written", + "not fixed yet — the race still shows under -count=50", + "isn't merged; waiting on review", + "working on the importer; nothing finished", + "origin/main = afba4df (pushed)", + } { + if ClaimsDoneUnverified(Checkpoint{State: state}) { + t.Errorf("a state that claims nothing done was flagged: %q", state) + } + } + if ClaimsDoneUnverified(Checkpoint{State: "Done. Fix is merged.", Verified: []string{"go test ./... passes"}}) { + t.Error("a done state with its proof under verified was flagged") + } +} From 5e93d48b2ff6820131380101f7e2a22b277e97dc Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 1 Oct 2026 17:21:22 -0700 Subject: [PATCH 05/16] Every table the code creates is either rebuilt from the vault, checked row for row after deleting the index, or declared a cache with the reason --- cmd/logos/index_cache_test.go | 264 ++++++++++++++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 cmd/logos/index_cache_test.go diff --git a/cmd/logos/index_cache_test.go b/cmd/logos/index_cache_test.go new file mode 100644 index 0000000..3b56690 --- /dev/null +++ b/cmd/logos/index_cache_test.go @@ -0,0 +1,264 @@ +package main + +import ( + "database/sql" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "github.com/Coder8124/logos/internal/dream" + "github.com/Coder8124/logos/internal/memory" +) + +// Every table the code creates is in exactly one of these two lists. +// +// "Delete the index, run logos index, lose nothing" broke four times the same +// way — memories, working notes, checkpoints, the review queue — each time a +// table that held something the user wrote and that nothing rebuilt from +// markdown, and each time silently. Each fix came with a test for that one +// table, which says nothing about the next one. These lists are the question +// CLAUDE.md asks of a new table, asked by the build: a table in neither fails +// TestEveryTableTheCodeCreatesIsRebuiltOrDeclaredACache until someone says +// which it is, and a table declared rebuilt is checked row for row below. +// +// Each rebuilt table maps to the query for what the vault holds of it. Most are +// the whole row; the exceptions say what they leave out and why. +var rebuiltTables = map[string]string{ + "notes": "SELECT * FROM notes", + "aliases": "SELECT * FROM aliases", + "edges": "SELECT * FROM edges", + // vec is the embedding, a cache of the text beside it. + "memories": `SELECT id, text, kind, salience, confidence, project, source, agent, created, + last_used, uses, fingerprint, superseded, superseded_by, quarantined, pin, unflushed + FROM memories`, + "memory_log": "SELECT * FROM memory_log", + // A closed session is bookkeeping for its checkpoint file, which is the + // record and is read from markdown; an open one is re-opened around its + // working notes under an id taken from the clock. What the vault holds is + // which project has work open, and the notes themselves. + "sessions": "SELECT project, agent, task FROM sessions WHERE ended = 0", + "session_notes": `SELECT s.project, s.agent, n.text, n.ts, n.unflushed + FROM session_notes n JOIN sessions s ON s.id = n.session`, + "commitments": "SELECT * FROM commitments", + "dream_insights": "SELECT * FROM dream_insights", +} + +// cacheOnlyTables holds what a rebuild may lose, each with the reason losing it +// costs the user nothing they wrote. +var cacheOnlyTables = map[string]string{ + "embeddings": "vectors of note text, recomputed by the next index that has a model", + "ruling_vectors": "vectors of ruled-out text, recomputed the next time a ruling is matched", + "notes_fts": "the full-text index over notes, refilled by Sync from the same files", + "meta": "last_sync, which a rebuild is itself the new value of", + "replay_state": "a read cursor, cache-only on purpose — see internal/replay/state.go", +} + +// createdTables finds every table the code can create by reading the source, +// not by opening an index: some tables are created lazily by the command that +// first needs them, and an index that never ran that command would let a new +// one through. +func createdTables(t *testing.T) map[string]string { + t.Helper() + create := regexp.MustCompile(`CREATE (?:VIRTUAL )?TABLE (?:IF NOT EXISTS )?(\w+)\s*(?:\(|USING)`) + out := map[string]string{} + for _, root := range []string{"../../internal", "."} { + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return err + } + src, err := os.ReadFile(path) + if err != nil { + return err + } + for _, m := range create.FindAllStringSubmatch(string(src), -1) { + out[m[1]] = path + } + return nil + }) + if err != nil { + t.Fatal(err) + } + } + if len(out) == 0 { + t.Fatal("found no CREATE TABLE in the source — the scan is broken, not the schema") + } + return out +} + +func TestEveryTableTheCodeCreatesIsRebuiltOrDeclaredACache(t *testing.T) { + created := createdTables(t) + for name := range rebuiltTables { + if _, ok := cacheOnlyTables[name]; ok { + t.Errorf("%s is declared both rebuilt and cache-only", name) + } + } + for name, where := range created { + _, rebuilt := rebuiltTables[name] + if _, cache := cacheOnlyTables[name]; !rebuilt && !cache { + t.Errorf("%s creates table %s, which is neither rebuilt from the vault nor declared a cache — "+ + "if it holds anything a user wrote, it needs a markdown home and an import before it needs a writer", where, name) + } + } + // A stale entry would let a renamed table slip past both lists. + for name := range rebuiltTables { + if _, ok := created[name]; !ok { + t.Errorf("%s is declared rebuilt but nothing creates it any more", name) + } + } + for name := range cacheOnlyTables { + if _, ok := created[name]; !ok { + t.Errorf("%s is declared cache-only but nothing creates it any more", name) + } + } +} + +// One vault with something in every rebuilt table, written through the same +// commands a user runs, then the index deleted and rebuilt. Each table must hold +// the same rows afterwards. A table that comes back empty is the bug this suite +// exists for; one that comes back different — a new id, a reset status — is the +// quieter version of it. +func TestEveryRebuiltTableComesBackRowForRowAfterDeletingTheIndex(t *testing.T) { + vaultDir := t.TempDir() + t.Setenv("LOGOS_VAULT", vaultDir) + t.Setenv("LOGOS_EMBED", "off") + t.Setenv("LOGOS_PROJECT", "kestrel") + + note := "---\naliases: [the waveguide]\n---\n# Waveguide\n\nCosted in [[bom]].\n" + if err := os.WriteFile(filepath.Join(vaultDir, "waveguide.md"), []byte(note), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(vaultDir, "bom.md"), []byte("# BOM\n"), 0o644); err != nil { + t.Fatal(err) + } + + captureStdout(t, func() { + for _, fact := range []string{"the waveguide costs 4.20 dollars per unit", "the BOM is owned by procurement"} { + if err := memoryCmd([]string{"add", "--project", "kestrel", fact}); err != nil { + t.Fatalf("memory add: %v", err) + } + } + if err := runCheckpoint([]string{"--task", "quote the waveguide", + "--decided", "extruded frames, because the mould costs too much", + "--failed", "casting the frame — the mould costs more than the run"}); err != nil { + t.Fatalf("checkpoint: %v", err) + } + if err := runNote([]string{"kestrel", "priced the extruded option"}); err != nil { + t.Fatalf("note: %v", err) + } + for _, loop := range []string{"send the quote to procurement", "ask about lead time"} { + if err := commitmentCmd([]string{"add", loop}); err != nil { + t.Fatalf("loop add: %v", err) + } + } + if err := commitmentCmd([]string{"done", "1"}); err != nil { + t.Fatalf("loop done: %v", err) + } + }) + + // A proposal and an insight have no model-free command that makes one, so + // they go in through the same calls the MCP server and the dream pass use. + ix, err := openIndex() + if err != nil { + t.Fatal(err) + } + if _, err := memory.Store(ix.DB, nil, "", &memory.Memory{ + Text: "procurement wants quotes in euros", Kind: memory.Fact, Source: "mcp", Agent: "claude-code", Quarantined: true, + }); err != nil { + t.Fatalf("propose: %v", err) + } + if err := dream.InitQueue(ix.DB); err != nil { + t.Fatal(err) + } + if err := dream.Enqueue(ix.DB, &dream.Insight{ + Kind: dream.Connection, Text: "the BOM owner is who the quote goes to", + EndpointA: 1, EndpointB: 2, Conf: 0.6, Model: "test", + }); err != nil { + t.Fatalf("insight: %v", err) + } + ix.Close() + + snapshot := func() map[string][]string { + captureStdout(t, func() { + if err := runIndex(false); err != nil { + t.Fatal(err) + } + }) + ix, err := openIndex() + if err != nil { + t.Fatal(err) + } + defer ix.Close() + out := map[string][]string{} + for name, query := range rebuiltTables { + out[name] = rows(t, ix.DB, query) + } + return out + } + + before := snapshot() + for name := range rebuiltTables { + if len(before[name]) == 0 { + t.Errorf("%s is empty before the rebuild, so this test proves nothing about it — populate it above", name) + } + } + if err := os.RemoveAll(filepath.Join(vaultDir, ".logos")); err != nil { + t.Fatal(err) + } + after := snapshot() + + for name := range rebuiltTables { + b, a := strings.Join(before[name], "\n"), strings.Join(after[name], "\n") + if a != b { + t.Errorf("%s changed when the index was rebuilt from the vault:\nbefore:\n%s\nafter:\n%s", name, b, a) + } + } +} + +// rows renders every row a query returns as one line, sorted, so the comparison +// is about content and not the order a rebuild happened to insert it in. +func rows(t *testing.T, db *sql.DB, query string) []string { + t.Helper() + rs, err := db.Query(query) + if err != nil && strings.Contains(err.Error(), "no such table") { + // A rebuild that never recreated the table lost every row in it; let + // the comparison say so, with the rows it lost. + return nil + } + if err != nil { + t.Fatal(err) + } + defer rs.Close() + cols, err := rs.Columns() + if err != nil { + t.Fatal(err) + } + var out []string + for rs.Next() { + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rs.Scan(ptrs...); err != nil { + t.Fatal(err) + } + parts := make([]string, len(cols)) + for i, v := range vals { + if b, ok := v.([]byte); ok { + v = string(b) + } + parts[i] = fmt.Sprintf("%s=%v", cols[i], v) + } + out = append(out, strings.Join(parts, " ")) + } + if err := rs.Err(); err != nil { + t.Fatal(err) + } + sort.Strings(out) + return out +} From 0ad96e1c75831a20c6467db06e4413c77401e4f6 Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 1 Oct 2026 17:23:31 -0700 Subject: [PATCH 06/16] The MCP server's tools live in files by topic, so server.go holds only the protocol and the dispatch --- internal/mcpserver/args.go | 63 ++ internal/mcpserver/continuity.go | 426 ++++++++++++ internal/mcpserver/memorytools.go | 369 +++++++++++ internal/mcpserver/projects.go | 194 ++++++ internal/mcpserver/server.go | 1022 ----------------------------- 5 files changed, 1052 insertions(+), 1022 deletions(-) create mode 100644 internal/mcpserver/continuity.go create mode 100644 internal/mcpserver/memorytools.go create mode 100644 internal/mcpserver/projects.go diff --git a/internal/mcpserver/args.go b/internal/mcpserver/args.go index 2863faa..ac7770e 100644 --- a/internal/mcpserver/args.go +++ b/internal/mcpserver/args.go @@ -3,6 +3,7 @@ package mcpserver import ( "fmt" "sort" + "strconv" "strings" ) @@ -166,3 +167,65 @@ func editDistance(a, b string) int { } return prev[len(b)] } + +func argStr(args map[string]any, k string) string { + if v, ok := args[k].(string); ok { + return v + } + return "" +} + +// argBool accepts a real bool or the string a model emits when it is being +// loose about JSON types, which is often enough to matter on a flag that +// changes which memories come back. +func argBool(args map[string]any, k string, def bool) bool { + switch v := args[k].(type) { + case bool: + return v + case string: + if b, err := strconv.ParseBool(strings.TrimSpace(v)); err == nil { + return b + } + } + return def +} + +func argInt(args map[string]any, k string, def int) int { + switch v := args[k].(type) { + case float64: + return int(v) + case int: + return v + case string: + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +// argList accepts either a JSON array or a newline/semicolon separated string. +// Hosts vary in how reliably their models emit arrays for list-shaped +// arguments, and rejecting a checkpoint because the decisions arrived as a +// string would lose the work it was recording. +func argList(args map[string]any, k string) []string { + var out []string + switch v := args[k].(type) { + case []any: + for _, it := range v { + if s, ok := it.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, strings.TrimSpace(s)) + } + } + case []string: + out = v + case string: + for _, line := range strings.FieldsFunc(v, func(r rune) bool { return r == '\n' || r == ';' }) { + line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "-*+ ")) + if line != "" { + out = append(out, line) + } + } + } + return out +} diff --git a/internal/mcpserver/continuity.go b/internal/mcpserver/continuity.go new file mode 100644 index 0000000..54ab963 --- /dev/null +++ b/internal/mcpserver/continuity.go @@ -0,0 +1,426 @@ +package mcpserver + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/Coder8124/logos/internal/advice" + "github.com/Coder8124/logos/internal/announce" + "github.com/Coder8124/logos/internal/contextpack" + "github.com/Coder8124/logos/internal/deadend" + "github.com/Coder8124/logos/internal/ingest" + "github.com/Coder8124/logos/internal/memory" + "github.com/Coder8124/logos/internal/procedure" + "github.com/Coder8124/logos/internal/secret" + "github.com/Coder8124/logos/internal/session" + "github.com/Coder8124/logos/internal/untrusted" + "github.com/Coder8124/logos/internal/usage" +) + +// contextPack assembles everything relevant to a file, project, or topic — the +// project dossier, standing preferences, and related memories — as one markdown +// bundle a host can drop straight into its model's context. +func (s *Server) context(req contextpack.Request) (string, error) { + if strings.TrimSpace(req.Task) == "" && strings.TrimSpace(req.Hint) == "" { + return "", fmt.Errorf("context needs a task (what you are trying to do) or a project") + } + pack, err := contextpack.Build(s.index(), s.embed, s.embedModel, req) + if err != nil { + return "", err + } + body := pack.Render() + return s.lead(pack) + body + s.awaitingReview() + s.ledgerPack("mcp:context", req.Hint, pack), nil +} + +// lead puts the receipt above the pack rather than below it. A person skimming +// a tool result reads the first line and stops; a summary underneath a page of +// markdown is a summary nobody sees. +func (s *Server) lead(pack contextpack.Pack) string { + carried := pack.Carried() + if carried == "" { + return "" + } + r := announce.Say(s.vault, "recalled "+carried) + if r == "" { + return "" + } + return r + "\n\n" +} + +// resume is context aimed at one question: where did the last agent stop. It is +// the same assembly as context, told to lead with the checkpoint, so an agent +// that has just been handed a project can start with one call. +// beforeYouTry is the one tool here that is not retrieval. +// +// Everything else answers a question the host's model already has. This answers +// two it does not know to ask: whether the approach was already ruled out, and +// whether there is a known-good way to do it with a trap the obvious way falls +// into. Which is why the tool description is written as an instruction: the +// model has no way of knowing either on its own. +// +// here is the project the check is counted under in the usage ledger, which is +// not project: the search stays unscoped, the count belongs to the work here. +func (s *Server) beforeYouTry(approach, project, here string) (string, error) { + if strings.TrimSpace(approach) == "" { + return "", fmt.Errorf("before_you_try needs the approach you are considering") + } + if err := session.Init(s.DB); err != nil { + return "", err + } + hits, semanticErr, err := deadend.CheckNoting(s.vault, s.DB, s.embed, s.embedModel, approach, project, 6) + if err != nil { + return "", err + } + // The corpus is gathered unranked and unfiltered (p=nil, so RecallProcedures + // takes its All()-backed fallback with no reinforcement side effect) — Check + // does its own lexical-plus-semantic scoring below, and a candidate the + // embedding pass here dropped early is exactly the one the lexical arm is + // for. Mirrors deadend's own Collect-then-Check split, and for the same + // reason: ranking degrades to lexical-only with no embedder, gathering must + // not have degraded it already. + corpus, err := memory.RecallProcedures(s.DB, nil, "", "", 0) + if err != nil { + return "", err + } + procHits, err := procedure.Check(corpus, s.embed, s.embedModel, approach, project, 4) + if err != nil { + return "", err + } + + var b strings.Builder + b.WriteString(untrusted.Boundary) + b.WriteString("\n\n") + b.WriteString(deadend.Render(approach, hits)) + b.WriteString(deadend.SemanticSkipped(semanticErr)) + if section := procedure.Render(procHits); section != "" { + b.WriteString("\n") + b.WriteString(section) + } + if len(hits) > 0 { + b.WriteString(s.ledger(usage.Event{Kind: usage.KindDeadEnd, Via: "mcp:before_you_try", Project: here, Rulings: len(hits)})) + } + return b.String(), nil +} + +// why reports what was being decided when a file was worked on. +// +// Reads markdown out of the vault and needs no model and no index, so it works +// on a machine with neither — which matters, because the moment it is useful is +// the moment an agent is about to change something it does not understand. +// +// here is the project a returned dead end is counted under in the usage ledger. +func (s *Server) why(file string, limit int, here string) (string, error) { + if strings.TrimSpace(file) == "" { + return "", fmt.Errorf("why needs a file path") + } + if s.vault == "" { + return "", fmt.Errorf("why reads checkpoints from the vault, and no vault is configured") + } + mentions, err := session.Touching(s.vault, file, limit) + if err != nil { + return "", err + } + if len(mentions) == 0 { + // Distinguish the two nothings. "Nothing was recorded" is a fact about + // the record; "there is no reason" is a claim about the code, and this + // tool is not entitled to make it. + return fmt.Sprintf( + "No checkpoint mentions %s.\n\nNothing was written down while this file was worked on, or it was "+ + "recorded under a different path. Do not read this as evidence the code is arbitrary.", file), nil + } + + var b strings.Builder + fmt.Fprintf(&b, "# What was decided around %s\n\n", file) + ruled := 0 + for _, m := range mentions { + ruled += len(nonBlank(m.Failed)) + when := "an unknown date" + if m.TS > 0 { + when = time.Unix(m.TS, 0).Format("2 Jan 2006") + } + who := m.Agent + if who == "" { + who = "an unrecorded author" + } + fmt.Fprintf(&b, "## %s — %s", when, who) + if m.Project != "" { + fmt.Fprintf(&b, " · %s", m.Project) + } + b.WriteString("\n\n") + if m.Task != "" { + // "While:" is a label, so its value is one line. A task recorded with + // a heading in it could otherwise close the label and open a section + // of its own, directly above the evidence why exists to present. + fmt.Fprintf(&b, "While: %s\n\n", untrusted.Inline(m.Task)) + } + if m.Intent != "" { + fmt.Fprintf(&b, "Because: %s\n\n", untrusted.Inline(m.Intent)) + } + // Ruled out first: a decision explains the shape of the code, and a dead + // end explains why it is not some other shape — which is what someone + // about to "fix" it needs. + writeList(&b, "Ruled out", m.Failed) + writeList(&b, "Decided", m.Decisions) + writeList(&b, "Still open", m.Questions) + fmt.Fprintf(&b, "Source: %s\n\n", m.Slug) + } + b.WriteString("This is what was recorded while the file was touched, not an analysis of " + + "the code. Treat it as evidence about intent, and check it still holds.\n") + // Counted only when a ruled-out approach came back: a why that returned + // decisions alone kept nobody from repeating anything. + if ruled > 0 { + b.WriteString(s.ledger(usage.Event{Kind: usage.KindDeadEnd, Via: "mcp:why", Project: here, Rulings: ruled})) + } + return b.String(), nil +} + +func nonBlank(items []string) []string { + var kept []string + for _, it := range items { + if s := strings.TrimSpace(it); s != "" { + kept = append(kept, s) + } + } + return kept +} + +func writeList(b *strings.Builder, label string, items []string) { + kept := nonBlank(items) + if len(kept) == 0 { + return + } + fmt.Fprintf(b, "**%s:**\n", label) + for _, it := range kept { + // Every item here is a checkpoint field somebody else's agent wrote. One + // bullet, one line — a newline in a recorded dead end was enough to end + // the list and start a heading of logos's own. + fmt.Fprintf(b, "- %s\n", untrusted.Inline(it)) + } + b.WriteString("\n") +} + +// resume takes the project argument unresolved, because whether it was given at +// all decides whether the worktree narrows it — see resolveContinuity. +func (s *Session) resume(projectArg, agent string, budget int, since contextpack.Since) (string, error) { + project, worktree := s.resolveContinuity(projectArg) + chose := "" + // A folder name the vault has never heard of is the ordinary way to reach + // this branch, not a launch in /: a scratch directory, a fresh clone under + // another name. The guard used to test only for an empty name, and a + // working directory almost always has a basename, so the fallback below + // was dead code while the agent was told, emphatically, that nothing was + // recorded (#169). A name the caller gave, or LOGOS_PROJECT set, is still + // honoured as asked — there the caller asserted something, and is told it + // was wrong below rather than handed another project's work. + guess := s.inferredProject() + swept, sweptFor := "", "" + if strings.TrimSpace(projectArg) == "" && guess != "" && !s.projectExists(project) { + // The folder's own project is swept before it is judged unknown. One + // with no checkpoint yet is the one whose killed sessions are recorded + // nowhere else, and falling back first swept the other project instead: + // in a host with no hooks they were never recorded, and after a week + // they were gone (#193). If the sweep recorded one, the project exists + // now and there is nothing to fall back from. + swept, sweptFor = ingest.SweepNotice(ingest.Sweep(s.vault, project, time.Now())), project + } + if strings.TrimSpace(projectArg) == "" && guess != "" && !s.projectExists(project) { + if ps := s.checkpointedProjects(); len(ps) > 0 { + chose = fmt.Sprintf("_Nothing in this vault is filed under %s, the folder this host was launched in — resuming %s, the most recently checkpointed project%s. Pass project to resume a different one, or checkpoint to start %s._\n\n", + untrusted.Inline(guess), untrusted.Inline(ps[0].name), s.knownProjects(), untrusted.Inline(guess)) + project, worktree = ps[0].name, "" + } + } + if strings.TrimSpace(project) == "" { + // Hosts without hooks (Cursor, Codex, Claude Desktop) launched outside + // any repository give no project, and an error sent the user off to + // learn the name another tool filed the work under. The most recent + // checkpoint is the likeliest thing they mean by "resume"; saying which + // was picked lets the agent correct course if it was not. The worktree + // is dropped because it was read from where the host stands, not from + // the project being resumed. + ps := s.checkpointedProjects() + if len(ps) == 0 { + return "", fmt.Errorf("resume needs a project%s", s.knownProjects()) + } + project, worktree = ps[0].name, "" + chose = fmt.Sprintf("_No project given and none to tell from where this host was launched — resuming %s, the most recently checkpointed project%s. Pass project to resume a different one._\n\n", + untrusted.Inline(project), s.knownProjects()) + } + if err := session.Init(s.DB); err != nil { + return "", err + } + // Before the pack, so a session whose host was killed before the shutdown + // path could record it is in the handoff it is missing from (#134). + if project != sweptFor { + swept += ingest.SweepNotice(ingest.Sweep(s.vault, project, time.Now())) + } + pack, err := contextpack.Build(s.index(), s.embed, s.embedModel, contextpack.Request{ + Task: "resume work on " + project, Hint: project, Worktree: worktree, Dir: s.repoDir(project), + Agent: s.agentFor(map[string]any{"agent": agent}), Budget: budget, Since: since, + }) + if err != nil { + return "", err + } + // First, above the pack: the pack's own last word on an unmatched name is + // "say so rather than inferring", and an agent that stops there reports + // nothing about "Saathi backend" while saathi holds the work. Suggested, + // not substituted — the name given is still the one resumed. + if pack.Checkpoint == nil && strings.TrimSpace(projectArg) != "" && !s.projectExists(project) { + if names, err := session.Scopes(s.vault); err == nil { + if near := session.NameInside(project, names); near != "" { + chose = fmt.Sprintf("_Did you mean %s? Nothing is filed under %s itself, and %s is the one known project whose name is inside it — call resume with project %q before reporting that nothing is recorded._\n\n", + untrusted.Inline(near), untrusted.Inline(project), untrusted.Inline(near), near) + chose + } + } + } + out := chose + swept + s.lead(pack) + pack.Render() + out += s.ledgerPack("mcp:resume", project, pack) + if pack.Checkpoint == nil { + // Say so plainly. An agent that assumes there was a checkpoint and + // finds none will invent continuity that never existed. + out += "\n_No checkpoint has been written for this project yet — " + + "this is context, not a handoff. Call checkpoint before you stop._\n" + // "Nothing recorded" is true of this name and false of the vault. An + // agent told only the first stops; one told what exists recovers in a + // single call. + if known := s.knownProjects(); known != "" && !s.projectExists(project) { + out += fmt.Sprintf("_Nothing in this vault is filed under %s%s._\n", untrusted.Inline(project), known) + } + } + out += s.awaitingReview() + // Filed under the scope the pack itself read, so the note lands in the same + // session a checkpoint will later close — in this worktree, not in the + // project the worktree belongs to. Not when the project was a guess: the + // agent is standing somewhere else and will most likely work there, so the + // note opened a session in another project's history that nothing closed. + if scope := pack.Continuity(); strings.TrimSpace(agent) != "" && scope != "" && chose == "" { + session.AddNote(s.DB, scope, agent, "resumed the project") + } + return out, nil +} + +func (s *Server) noteProgress(project, agent, text string) (string, error) { + if strings.TrimSpace(project) == "" { + return "", fmt.Errorf("note_progress needs a project and some text%s", s.knownProjects()) + } + if strings.TrimSpace(text) == "" { + return "", fmt.Errorf("note_progress needs a project and some text") + } + if err := session.Init(s.DB); err != nil { + return "", err + } + n, err := session.AddNote(s.DB, project, agent, text) + if err != nil { + return "", err + } + msg := s.receipt("noted in logos — uncommitted until you checkpoint") + if said := secret.Summary(n.Redactions); said != "" { + msg += " " + said + "." + } + return msg, nil +} + +// receipt marks a line as ours so the person watching the transcript can find +// it without reading it. See internal/announce for why this is a setting and +// not a constant. +// +// It lives on Server rather than Session because the tools that write are split +// across both, and a receipt that appeared on half of them would be worse than +// none: an inconsistent marker teaches people the absence of a marker means +// nothing happened. +func (s *Server) receipt(what string) string { + if r := announce.Say(s.vault, what); r != "" { + return r + } + // At LOGOS_ANNOUNCE=off the model still needs to know what happened, even + // though the user has asked not to be told about it. Silence towards the + // user is not silence towards the caller. + return upperFirst(what) +} + +func upperFirst(s string) string { + if s == "" { + return s + } + r := []rune(s) + return strings.ToUpper(string(r[0])) + string(r[1:]) +} + +// agentFor names who wrote a checkpoint or note: the model's own agent +// argument when it gave one, otherwise the host's handshake name. Without the +// fallback an agent that skipped the optional argument was filed as "agent", +// and a handoff could not say which host stopped there. +func (s *Session) agentFor(args map[string]any) string { + if a := argStr(args, "agent"); a != "" { + // "claude" under claude-code is the host's name cut short, not another + // agent; recording it as typed split one session's trail in two. + if s.clientAgent != "" && strings.HasPrefix(s.clientAgent, strings.ToLower(a)+"-") { + return s.clientAgent + } + return a + } + return s.clientAgent +} + +// checkpoint commits the session to the vault. handoffTo is set when the caller +// came in through the handoff tool — same mechanism, stated intent. +func (s *Session) checkpoint(args map[string]any, handoffTo string) (string, error) { + proj := s.resolveScope(argStr(args, "project")) + if strings.TrimSpace(proj) == "" { + return "", fmt.Errorf("checkpoint needs a project, and none could be inferred from the working directory%s", s.knownProjects()) + } + key, _ := json.Marshal([]any{proj, handoffTo, args}) + // The file is checked too: a receipt for a checkpoint that is no longer on + // disk would be a success-shaped failure. + if last := s.lastCheckpoint; last.key == string(key) && time.Since(last.at) < checkpointRetryWindow && + checkpointOnDisk(s.vault, last.slug) { + msg := s.receipt(fmt.Sprintf("checkpoint already saved to logos — %s.md; this identical retry was not written again", last.slug)) + if handoffTo != "" { + msg += fmt.Sprintf(" Handed off to %s — they can call resume(%q).", handoffTo, proj) + } + return msg, nil + } + if err := session.Init(s.DB); err != nil { + return "", err + } + c := &session.Checkpoint{ + Project: proj, + Agent: s.agentFor(args), + Task: argStr(args, "task"), + Intent: argStr(args, "intent"), + State: argStr(args, "state"), + Decisions: argList(args, "decisions"), + Failed: argList(args, "failed"), + Verified: argList(args, "verified"), + Blockers: argList(args, "blockers"), + Commands: argList(args, "commands"), + Questions: argList(args, "questions"), + Files: argList(args, "files"), + Next: argStr(args, "next"), + HandoffTo: handoffTo, + } + var dropped int + c.Failed, dropped = session.DropPlaceholders(c.Failed) + // Read before the commit, so the history is what came before this checkpoint. + earlier, _ := session.History(s.vault, c.Project, session.IntentDepth) + if err := session.Commit(s.DB, s.vault, c); err != nil { + return "", err + } + s.lastCheckpoint.key, s.lastCheckpoint.slug, s.lastCheckpoint.at = string(key), c.Slug, time.Now() + s.checkpointed(c.Project) + msg := s.receipt(fmt.Sprintf("checkpoint saved to logos — %s.md", c.Slug)) + if said := secret.Summary(c.Redactions); said != "" { + msg += " " + said + "." + } + for _, said := range advice.Checkpoint(*c, earlier, dropped, advice.MCP) { + msg += " " + said + } + if handoffTo != "" { + msg += fmt.Sprintf(" Handed off to %s — they can call resume(%q).", handoffTo, c.Project) + } + // No "run `logos index`": resume and before_you_try read the checkpoint off + // disk, so it is usable the moment this returns. See cmd/logos/session.go. + return msg, nil +} diff --git a/internal/mcpserver/memorytools.go b/internal/mcpserver/memorytools.go new file mode 100644 index 0000000..5f80c95 --- /dev/null +++ b/internal/mcpserver/memorytools.go @@ -0,0 +1,369 @@ +package mcpserver + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/Coder8124/logos/internal/memory" + "github.com/Coder8124/logos/internal/procedure" + "github.com/Coder8124/logos/internal/secret" + "github.com/Coder8124/logos/internal/untrusted" +) + +// remember stores a fact scoped to the project the session is working on. +// global=true opts out, for the things that really do apply everywhere — a +// standing preference about how the user likes replies is not a fact about +// this repository. +func (s *Session) remember(text, kindStr, projectArg string, global bool) (string, error) { + if strings.TrimSpace(text) == "" { + return "", fmt.Errorf("remember needs text") + } + project := "" + if !global { + project = s.resolveProject(projectArg) + } + kind := memory.Fact + switch memory.Kind(kindStr) { + case memory.Preference: + kind = memory.Preference + case memory.Person: + kind = memory.Person + case memory.Context: + kind = memory.Context + case memory.Procedure: + kind = memory.Procedure + } + // A procedure earns its slot by naming what goes wrong without it — the + // trap test. Refuse here, with the reason, rather than storing a + // convention that will never be flagged as one again: a rejected write + // must not come back looking like a stored one. + if kind == memory.Procedure { + if err := procedure.Validate(procedure.ParseRecord(text)); err != nil { + return "", err + } + } + r, err := memory.Store(s.DB, s.embed, s.embedModel, &memory.Memory{ + Text: text, Kind: kind, Salience: 0.7, Source: "mcp", Project: project, Agent: s.clientAgent, + Quarantined: reviewEverythingMCP(), + ReviewIfContested: !trustMCP(), + }) + if err != nil { + return "", err + } + // Name the scope in the receipt. The host shows this to the user, and + // "which pile did that go in" is the one thing they cannot otherwise see. + where := "everywhere" + if project != "" { + where = project + } + msg := s.rememberReceipt(r, kind, where) + if said := secret.Summary(r.Redactions); said != "" { + msg += " " + said + "." + } + return msg, nil +} + +func (s *Session) rememberReceipt(r memory.Receipt, kind memory.Kind, where string) string { + // A receipt rather than "Remembered." — the host is about to tell the user + // what happened, and creating a fact is not the same as confirming one it + // already had, or queuing one that still needs a yes. + switch r.Outcome { + case memory.EvReinforced: + if r.StillQueued { + return s.receipt(fmt.Sprintf("still queued — memory #%d (%s, %s) is waiting for review; the user runs `%s review` to accept or reject it", r.Ref, kind, where, s.shell())) + } + return s.receipt(fmt.Sprintf("already knew that — reinforced memory #%d (%s, %s)", r.Ref, kind, where)) + case memory.EvQuarantined: + return s.receipt(s.quarantineReceipt(r.ID, string(kind), where, r)) + case memory.EvCreated: + return s.receipt(fmt.Sprintf("stored in logos — memory #%d (%s, %s)", r.ID, kind, where)) + } + return "Nothing stored." +} + +// trustMCP and reviewEverythingMCP are the two ends of how much scrutiny a +// `remember` from an MCP client gets before it counts as known. +// +// The middle — the default — is that a write goes active unless it contradicts +// something already stored, and only the contradiction waits for a person. See +// the Review-only-what-is-in-dispute comment in memory.Store for why. +// +// This used to quarantine everything, on the reasoning that an MCP client is a +// different process and the user is not necessarily watching when it writes. +// The reasoning was right about the risk and wrong about the remedy, and the +// old comment here said so without following it: an agent whose every write +// silently queues has lost its memory just as thoroughly as one that writes +// with no oversight at all. MCP is not one path among several — it is the only +// path an agent has, so "review everything" meant nothing an agent learned ever +// reached the next agent unless the user personally typed `logos review`. What +// people install this for is continuity. A queue that has to be drained by hand +// before continuity happens is a bill most users will simply not pay, and the +// facts sit unreviewed while both agents behave as though nothing was stored. +// +// Both escape hatches stay, because the old default was right for someone: +// +// LOGOS_TRUST_MCP=1 never queue, not even a contradiction +// LOGOS_REVIEW_ALL=1 queue every agent write, as before +// +// LOGOS_* environment variables rather than a config file, matching every other +// one-bit decision in this codebase. +func trustMCP() bool { return os.Getenv("LOGOS_TRUST_MCP") != "" } + +func reviewEverythingMCP() bool { return os.Getenv("LOGOS_REVIEW_ALL") != "" } + +// recall searches this project's memories plus the global ones. allProjects +// widens it to everything, which is the "unless explicitly asked" half — an +// agent that genuinely wants another project's history can have it, but has to +// say so rather than getting it by accident. +func (s *Session) recall(query string, k int, projectArg string, allProjects bool) (string, error) { + if strings.TrimSpace(query) == "" { + return "", fmt.Errorf("recall needs a query") + } + var ( + mems []memory.Memory + err error + ) + project := "" + if !allProjects { + project = s.resolveProject(projectArg) + } + if project == "" { + mems, err = memory.Recall(s.DB, s.embed, s.embedModel, query, k) + } else { + mems, err = memory.RecallInProject(s.DB, s.embed, s.embedModel, query, project, k) + } + if err != nil { + return "", err + } + if len(mems) == 0 { + if project != "" { + // A typo and a real project with nothing on the subject used to get + // the same sentence, and the agent draws the same conclusion from + // it: this work has no recorded facts, carry on without them. Only + // one of those is true. resolveProject accepts any string, so the + // check has to be here. + if !s.projectExists(project) { + return fmt.Sprintf("No project named %s in this vault%s", untrusted.Inline(project), s.knownProjectsSentence()) + s.awaitingReview(), nil + } + return fmt.Sprintf("No relevant memories in %s. Pass all_projects to search every project.", project) + s.awaitingReview(), nil + } + return "No relevant memories." + s.awaitingReview(), nil + } + var b strings.Builder + // A memory the vault never got is still usable and still true — it is just + // one `rm -rf .logos` from gone, and the README tells people that command + // is safe. The write reported the failure once, to a caller that has since + // exited; every reader after that saw a row indistinguishable from a + // durable one. See memory.UnflushedIDs. + stranded := memory.UnflushedIDs(s.DB) + for _, m := range mems { + // Tag anything from outside the current project, so a fact borrowed + // from elsewhere cannot be read as this project's own settled truth. + switch { + // Inline, because each memory is one bullet and a stored fact may contain + // anything: a newline plus "## Where we left off" turned a recalled fact + // into a section of logos's own frame, with a "Next step" the reading + // agent had no way to tell from the real one. + case m.Project == "" || m.Project == project: + fmt.Fprintf(&b, "- (%s%s) %s\n", m.Kind, notDurable(stranded[m.ID]), untrusted.Inline(m.Text)) + default: + fmt.Fprintf(&b, "- (%s, from %s%s) %s\n", m.Kind, m.Project, notDurable(stranded[m.ID]), untrusted.Inline(m.Text)) + } + } + return strings.TrimRight(b.String(), "\n") + s.awaitingReview(), nil +} + +// notDurable marks a memory that is in the cache and not in the vault. Worded +// as a fact about where it is rather than a warning, because the memory itself +// is fine — an agent should still use it, and should know not to rely on it +// being there tomorrow. +func notDurable(stranded bool) string { + if !stranded { + return "" + } + return ", not yet saved to the vault" +} + +// fromProject names the project a memory belongs to when it is not the one the +// caller is standing in, and says nothing when it is — the same distinction +// recall draws, in the same words, so a reader moving between the two tools +// does not have to learn two conventions. A global fact belongs everywhere and +// is never foreign. +func fromProject(owner, here string) string { + if owner == "" || owner == here { + return "" + } + return ", from " + untrusted.Inline(owner) +} + +// diffOwner is fromProject for the +/-/~ lines, which carry no kind to hang a +// clause off and so need their own parentheses. +func diffOwner(owner, here string) string { + if owner == "" || owner == here { + return "" + } + return " (" + untrusted.Inline(owner) + ")" +} + +// listMemories lists every memory in the vault, labelled with the project each +// one belongs to. `here` is the project the caller is standing in, whose +// memories are printed bare; "" labels everything, which is what the resource +// surface wants because it is addressed to no one in particular. +// +// Labelled rather than scoped: this tool is meant to show everything, and that +// is fine as long as it says what everything is. It was not saying, and the +// consequence was worse than a model being misled — the documented use for this +// tool is "before forgetting something", `forget` takes the id printed here, +// and another project's id sat in the same undifferentiated list as this +// project's. The obvious next call deleted work from a repository nobody in the +// session had opened. recall has labelled foreign results since #155; these two +// tools are where that fix did not reach. +func (s *Server) listMemories(here string) (string, error) { + mems, err := memory.All(s.DB) + if err != nil { + return "", err + } + if len(mems) == 0 { + return "No memories yet.", nil + } + var b strings.Builder + stranded := memory.UnflushedIDs(s.DB) + for _, m := range mems { + tag := "" + // Pin state has to be visible here too, not just in the CLI — a host's + // model deciding whether to pin/exclude something needs to see what + // already is, or it will keep re-pinning the same memory every session. + switch m.Pin { + case memory.PinAlways: + tag = " [pinned]" + case memory.PinNever: + tag = " [excluded]" + } + fmt.Fprintf(&b, "[%d] (%s%s%s)%s %s\n", m.ID, m.Kind, fromProject(m.Project, here), notDurable(stranded[m.ID]), tag, untrusted.Inline(m.Text)) + } + return strings.TrimRight(b.String(), "\n"), nil +} + +func (s *Server) forget(idStr string) (string, error) { + id, err := strconv.ParseInt(strings.TrimSpace(idStr), 10, 64) + if err != nil { + return "", fmt.Errorf("forget needs a numeric memory id") + } + if err := memory.Forget(s.DB, id); err != nil { + return "", err + } + return "Forgotten.", nil +} + +// pinMemory sets or clears always-include. unpin covers both directions of +// override (see memory.Unpin) so a host does not need a third tool just to +// walk back an exclude_memory call. +func (s *Server) pinMemory(idStr string, unpin bool) (string, error) { + id, err := strconv.ParseInt(strings.TrimSpace(idStr), 10, 64) + if err != nil { + return "", fmt.Errorf("pin_memory needs a numeric memory id") + } + if unpin { + if err := memory.Unpin(s.DB, id); err != nil { + return "", err + } + return "Unpinned — back to normal ranking.", nil + } + if err := memory.Pin(s.DB, id); err != nil { + return "", err + } + return "Pinned — always included in context packs, budget permitting.", nil +} + +func (s *Server) excludeMemory(idStr string) (string, error) { + id, err := strconv.ParseInt(strings.TrimSpace(idStr), 10, 64) + if err != nil { + return "", fmt.Errorf("exclude_memory needs a numeric memory id") + } + if err := memory.Exclude(s.DB, id); err != nil { + return "", err + } + return "Excluded — kept on record, never surfaced.", nil +} + +// memoryDiff reports what the memory learned, dropped, or corroborated over the +// last `days`, optionally about one subject. Instant and offline — it reads the +// append-only memory log, no model. +func (s *Server) memoryDiff(subject string, days int, here string) (string, error) { + if days <= 0 { + days = 7 + } + until := time.Now() + since := until.AddDate(0, 0, -days) + res, err := memory.Diff(s.DB, subject, since.Unix(), until.Unix()) + if err != nil { + return "", err + } + if res.Empty() { + return "Nothing changed in that window.", nil + } + var b strings.Builder + // One line per entry, for the same reason recall collapses: the +/-/~ marker + // is the only thing distinguishing logos's reading of the window from the + // stored text beside it. + // The project on each line for the same reason recall carries it: over a + // window, every project's changes arrive in one list, and an unlabelled + // line about another repository reads as a change to this one. + for _, e := range res.Added { + fmt.Fprintf(&b, "+%s %s\n", diffOwner(e.Project, here), untrusted.Inline(e.Text)) + } + for _, e := range res.Removed { + fmt.Fprintf(&b, "-%s %s\n", diffOwner(e.Project, here), untrusted.Inline(e.Text)) + } + for _, e := range res.Corroborated { + fmt.Fprintf(&b, "~%s %s\n", diffOwner(e.Project, here), untrusted.Inline(e.Text)) + } + return strings.TrimRight(b.String(), "\n"), nil +} + +// quarantineReceipt names the review command this install answers to. Under +// npx or the plugin alone there is no logos on PATH, and "run `logos review`" +// left the memory queued behind a command the user could not run. +func (s *Server) quarantineReceipt(id int64, kind, where string, r memory.Receipt) string { + // Why it queued, not just that it did. A memory only waits for review when + // it disputes one already stored, so the receipt quotes the memory in + // dispute — that is what lets the agent raise it in the conversation the + // user is already having, rather than leaving it for a queue they open + // some other day. + if r.Contested != 0 { + return fmt.Sprintf("queued memory #%d (%s, %s) — it contradicts memory #%d, %q. The user runs `%s review` to settle which is current; until then neither answer changes", + id, kind, where, r.Contested, r.ContestedText, s.shell()) + } + return fmt.Sprintf("queued memory #%d (%s, %s) for review — the user runs `%s review` to accept or reject it before it becomes active", id, kind, where, s.shell()) +} + +// shell is the command this install answers to; see quarantineReceipt. +func (s *Server) shell() string { + if s.Shell == "" { + return "logos" + } + return s.Shell +} + +// awaitingReview is the line every read appends while the review queue is not +// empty. Quarantine keeps an agent's memories out of recall until the user says +// yes, and a user who is never told there is anything to say yes to leaves them +// there for good — while the agent reads the empty recall as the fact never +// having been stored. A failed count is said, not swallowed, but does not fail +// the read it is attached to. +func (s *Server) awaitingReview() string { + n, err := memory.PendingCount(s.DB) + if err != nil { + return fmt.Sprintf("\n\n(could not count the memories waiting for review: %v)", err) + } + if n == 0 { + return "" + } + if n == 1 { + return fmt.Sprintf("\n\n1 memory is waiting for your review — `%s review`", s.shell()) + } + return fmt.Sprintf("\n\n%d memories are waiting for your review — `%s review`", n, s.shell()) +} diff --git a/internal/mcpserver/projects.go b/internal/mcpserver/projects.go new file mode 100644 index 0000000..85d0a90 --- /dev/null +++ b/internal/mcpserver/projects.go @@ -0,0 +1,194 @@ +package mcpserver + +import ( + "fmt" + "sort" + "strings" + + "github.com/Coder8124/logos/internal/memory" + "github.com/Coder8124/logos/internal/project" + "github.com/Coder8124/logos/internal/session" + "github.com/Coder8124/logos/internal/untrusted" +) + +// knownProjects ends a refusal for a missing project with the projects that +// have checkpoints, newest first. A model that does not know the name has no +// other way to learn it from the refusal, and a host that launches the server +// in / never supplies one. +func (s *Server) knownProjects() string { + ps := s.checkpointedProjects() + if len(ps) == 0 { + return "" + } + const maxKnown = 5 + if len(ps) > maxKnown { + ps = ps[:maxKnown] + } + parts := make([]string, len(ps)) + for i, p := range ps { + parts[i] = fmt.Sprintf("%s (%s)", untrusted.Inline(p.name), project.Age(p.ts)) + if p.agent != "" { + parts[i] = fmt.Sprintf("%s (%s, %s)", untrusted.Inline(p.name), project.Age(p.ts), untrusted.Inline(p.agent)) + } + } + return ". Known projects: " + strings.Join(parts, ", ") +} + +// projectExists reports whether the vault has ever heard this exact name — +// either a memory filed under it or a session directory carrying it. Both are +// consulted because a project can have checkpoints and no memory, or memory +// and no checkpoint, and either one makes the name real. +func (s *Server) projectExists(name string) bool { + if ok, err := memory.HasProject(s.DB, name); err == nil && ok { + return true + } + names, err := session.Scopes(s.vault) + if err != nil { + return false + } + for _, n := range names { + // Either direction counts: a worktree scope is "shop/fix-auth" while + // the enumerator lists "shop", so a name can be the parent of a known + // scope or a scope under a known parent. + if n == name || strings.HasPrefix(n, name+"/") || strings.HasPrefix(name, n+"/") { + return true + } + } + return false +} + +// knownProjectsSentence is knownProjects punctuated as an answer rather than +// as the tail of a refusal. +func (s *Server) knownProjectsSentence() string { + if known := s.knownProjects(); known != "" { + return known + "." + } + return "." +} + +type knownProject struct { + name, agent string + ts int64 +} + +// checkpointedProjects lists the projects that have a checkpoint, most recent +// first. +func (s *Server) checkpointedProjects() []knownProject { + // Scopes, not Projects: a scope this returns is one an agent will pass + // straight back to resume, and a worktree scope was the single thing none + // of these surfaces could name. + names, err := session.Scopes(s.vault) + if err != nil { + return nil + } + var ps []knownProject + for _, n := range names { + if h, err := session.History(s.vault, n, 1); err == nil && len(h) > 0 { + ps = append(ps, knownProject{n, h[0].Agent, h[0].TS}) + } + } + sort.Slice(ps, func(i, j int) bool { return ps[i].ts > ps[j].ts }) + return ps +} + +// scopeCount is a scope and how much work is filed under it. +type scopeCount struct { + name string + n int +} + +// checkpointedScopes lists the scopes holding at least one checkpoint, with +// the count. Separate from checkpointedProjects because that one carries the +// most recent checkpoint's agent and timestamp and this one only needs a +// number; both drop the scopes holding none, which is the part that matters. +func (s *Server) checkpointedScopes() []scopeCount { + names, err := session.Scopes(s.vault) + if err != nil { + return nil + } + var out []scopeCount + for _, n := range names { + h, err := session.History(s.vault, n, 0) + if err != nil || len(h) == 0 { + continue + } + out = append(out, scopeCount{n, len(h)}) + } + return out +} + +// listProjectsHere is listProjects with the one fact it could never supply: +// where the caller is standing. +// +// listProjects is a method on Server, and its line in the tool switch was the +// only one that threaded no session state — so the single tool whose answer is +// a list of names had no way to mark the name belonging to the agent asking. +// An agent handed four names fans out and calls resume once per name; three of +// those answers are somebody else's work. The scope comes from the same +// observed sources every other continuity tool uses, never from an argument. +// +// The resource surface keeps the unscoped listing: logos://projects is a +// directory of the vault, not advice to an agent standing somewhere. +func (s *Session) listProjectsHere() (string, error) { + body, err := s.listProjects() + if err != nil { + return "", err + } + here := s.resolveScope("") + if here == "" { + return body, nil + } + head := fmt.Sprintf("You are in %s", untrusted.Inline(here)) + if h, err := session.History(s.vault, here, 0); err == nil && len(h) > 0 { + word := "checkpoints" + if len(h) == 1 { + word = "checkpoint" + } + head += fmt.Sprintf(" (%d %s)", len(h), word) + } else { + head += " (no checkpoints yet)" + } + return head + ".\n\nEverything in this vault:\n" + body, nil +} + +// listProjects enumerates the projects logos detected, most-recently-active +// first, so a host can navigate the memory by the work it is organised around. +func (s *Server) listProjects() (string, error) { + ps, err := project.Detect(s.DB) + if err != nil { + return "", err + } + if len(ps) == 0 { + // The activity rollup is not where checkpoints live. A model looking + // for a name to resume was told there were none while sessions/ held + // them, and reported an empty memory. `logos projects` falls back the + // same way. + // Only scopes that actually hold a checkpoint. This branch used to print + // every session directory under a heading asserting they all had one, + // contradicting itself on the rows reading "(0 checkpoints)" — and those + // empty rows are the ghost projects a host leaves behind in any folder + // it was opened in, so the list was advertising its own exhaust. + if ps := s.checkpointedScopes(); len(ps) > 0 { + var b strings.Builder + // A statement, not an instruction. "call resume with one" was the + // only line in this server aimed at the model, and it sat directly + // above a list — which a thorough agent reads as "enumerate these", + // and did: four resume calls where one was wanted. + b.WriteString("No activity rollup yet. These scopes hold checkpoints:\n") + for _, p := range ps { + word := "checkpoints" + if p.n == 1 { + word = "checkpoint" + } + fmt.Fprintf(&b, "- %s (%d %s)\n", p.name, p.n, word) + } + return strings.TrimRight(b.String(), "\n"), nil + } + return "No projects detected yet.", nil + } + var b strings.Builder + for _, p := range ps { + fmt.Fprintf(&b, "- %s (last active %s)\n", p.Name, project.Age(p.LastActive)) + } + return strings.TrimRight(b.String(), "\n"), nil +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 31a5388..59cde9e 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -33,29 +33,18 @@ import ( "os" "path/filepath" "runtime/debug" - "sort" "strconv" "strings" "sync" "time" - "github.com/Coder8124/logos/internal/advice" "github.com/Coder8124/logos/internal/agentprompt" - "github.com/Coder8124/logos/internal/announce" "github.com/Coder8124/logos/internal/buildinfo" "github.com/Coder8124/logos/internal/contextpack" - "github.com/Coder8124/logos/internal/deadend" "github.com/Coder8124/logos/internal/index" - "github.com/Coder8124/logos/internal/ingest" "github.com/Coder8124/logos/internal/memory" - "github.com/Coder8124/logos/internal/procedure" - "github.com/Coder8124/logos/internal/project" "github.com/Coder8124/logos/internal/provider" "github.com/Coder8124/logos/internal/router" - "github.com/Coder8124/logos/internal/secret" - "github.com/Coder8124/logos/internal/session" - "github.com/Coder8124/logos/internal/untrusted" - "github.com/Coder8124/logos/internal/usage" ) // protocolVersion is what this server speaks when the host asks for something @@ -797,911 +786,6 @@ func (s *Session) dispatch(name string, args map[string]any) (string, error) { return "", fmt.Errorf("unknown tool %q", name) } -// --- memory operations --- - -// remember stores a fact scoped to the project the session is working on. -// global=true opts out, for the things that really do apply everywhere — a -// standing preference about how the user likes replies is not a fact about -// this repository. -func (s *Session) remember(text, kindStr, projectArg string, global bool) (string, error) { - if strings.TrimSpace(text) == "" { - return "", fmt.Errorf("remember needs text") - } - project := "" - if !global { - project = s.resolveProject(projectArg) - } - kind := memory.Fact - switch memory.Kind(kindStr) { - case memory.Preference: - kind = memory.Preference - case memory.Person: - kind = memory.Person - case memory.Context: - kind = memory.Context - case memory.Procedure: - kind = memory.Procedure - } - // A procedure earns its slot by naming what goes wrong without it — the - // trap test. Refuse here, with the reason, rather than storing a - // convention that will never be flagged as one again: a rejected write - // must not come back looking like a stored one. - if kind == memory.Procedure { - if err := procedure.Validate(procedure.ParseRecord(text)); err != nil { - return "", err - } - } - r, err := memory.Store(s.DB, s.embed, s.embedModel, &memory.Memory{ - Text: text, Kind: kind, Salience: 0.7, Source: "mcp", Project: project, Agent: s.clientAgent, - Quarantined: reviewEverythingMCP(), - ReviewIfContested: !trustMCP(), - }) - if err != nil { - return "", err - } - // Name the scope in the receipt. The host shows this to the user, and - // "which pile did that go in" is the one thing they cannot otherwise see. - where := "everywhere" - if project != "" { - where = project - } - msg := s.rememberReceipt(r, kind, where) - if said := secret.Summary(r.Redactions); said != "" { - msg += " " + said + "." - } - return msg, nil -} - -func (s *Session) rememberReceipt(r memory.Receipt, kind memory.Kind, where string) string { - // A receipt rather than "Remembered." — the host is about to tell the user - // what happened, and creating a fact is not the same as confirming one it - // already had, or queuing one that still needs a yes. - switch r.Outcome { - case memory.EvReinforced: - if r.StillQueued { - return s.receipt(fmt.Sprintf("still queued — memory #%d (%s, %s) is waiting for review; the user runs `%s review` to accept or reject it", r.Ref, kind, where, s.shell())) - } - return s.receipt(fmt.Sprintf("already knew that — reinforced memory #%d (%s, %s)", r.Ref, kind, where)) - case memory.EvQuarantined: - return s.receipt(s.quarantineReceipt(r.ID, string(kind), where, r)) - case memory.EvCreated: - return s.receipt(fmt.Sprintf("stored in logos — memory #%d (%s, %s)", r.ID, kind, where)) - } - return "Nothing stored." -} - -// trustMCP and reviewEverythingMCP are the two ends of how much scrutiny a -// `remember` from an MCP client gets before it counts as known. -// -// The middle — the default — is that a write goes active unless it contradicts -// something already stored, and only the contradiction waits for a person. See -// the Review-only-what-is-in-dispute comment in memory.Store for why. -// -// This used to quarantine everything, on the reasoning that an MCP client is a -// different process and the user is not necessarily watching when it writes. -// The reasoning was right about the risk and wrong about the remedy, and the -// old comment here said so without following it: an agent whose every write -// silently queues has lost its memory just as thoroughly as one that writes -// with no oversight at all. MCP is not one path among several — it is the only -// path an agent has, so "review everything" meant nothing an agent learned ever -// reached the next agent unless the user personally typed `logos review`. What -// people install this for is continuity. A queue that has to be drained by hand -// before continuity happens is a bill most users will simply not pay, and the -// facts sit unreviewed while both agents behave as though nothing was stored. -// -// Both escape hatches stay, because the old default was right for someone: -// -// LOGOS_TRUST_MCP=1 never queue, not even a contradiction -// LOGOS_REVIEW_ALL=1 queue every agent write, as before -// -// LOGOS_* environment variables rather than a config file, matching every other -// one-bit decision in this codebase. -func trustMCP() bool { return os.Getenv("LOGOS_TRUST_MCP") != "" } - -func reviewEverythingMCP() bool { return os.Getenv("LOGOS_REVIEW_ALL") != "" } - -// recall searches this project's memories plus the global ones. allProjects -// widens it to everything, which is the "unless explicitly asked" half — an -// agent that genuinely wants another project's history can have it, but has to -// say so rather than getting it by accident. -func (s *Session) recall(query string, k int, projectArg string, allProjects bool) (string, error) { - if strings.TrimSpace(query) == "" { - return "", fmt.Errorf("recall needs a query") - } - var ( - mems []memory.Memory - err error - ) - project := "" - if !allProjects { - project = s.resolveProject(projectArg) - } - if project == "" { - mems, err = memory.Recall(s.DB, s.embed, s.embedModel, query, k) - } else { - mems, err = memory.RecallInProject(s.DB, s.embed, s.embedModel, query, project, k) - } - if err != nil { - return "", err - } - if len(mems) == 0 { - if project != "" { - // A typo and a real project with nothing on the subject used to get - // the same sentence, and the agent draws the same conclusion from - // it: this work has no recorded facts, carry on without them. Only - // one of those is true. resolveProject accepts any string, so the - // check has to be here. - if !s.projectExists(project) { - return fmt.Sprintf("No project named %s in this vault%s", untrusted.Inline(project), s.knownProjectsSentence()) + s.awaitingReview(), nil - } - return fmt.Sprintf("No relevant memories in %s. Pass all_projects to search every project.", project) + s.awaitingReview(), nil - } - return "No relevant memories." + s.awaitingReview(), nil - } - var b strings.Builder - // A memory the vault never got is still usable and still true — it is just - // one `rm -rf .logos` from gone, and the README tells people that command - // is safe. The write reported the failure once, to a caller that has since - // exited; every reader after that saw a row indistinguishable from a - // durable one. See memory.UnflushedIDs. - stranded := memory.UnflushedIDs(s.DB) - for _, m := range mems { - // Tag anything from outside the current project, so a fact borrowed - // from elsewhere cannot be read as this project's own settled truth. - switch { - // Inline, because each memory is one bullet and a stored fact may contain - // anything: a newline plus "## Where we left off" turned a recalled fact - // into a section of logos's own frame, with a "Next step" the reading - // agent had no way to tell from the real one. - case m.Project == "" || m.Project == project: - fmt.Fprintf(&b, "- (%s%s) %s\n", m.Kind, notDurable(stranded[m.ID]), untrusted.Inline(m.Text)) - default: - fmt.Fprintf(&b, "- (%s, from %s%s) %s\n", m.Kind, m.Project, notDurable(stranded[m.ID]), untrusted.Inline(m.Text)) - } - } - return strings.TrimRight(b.String(), "\n") + s.awaitingReview(), nil -} - -// notDurable marks a memory that is in the cache and not in the vault. Worded -// as a fact about where it is rather than a warning, because the memory itself -// is fine — an agent should still use it, and should know not to rely on it -// being there tomorrow. -func notDurable(stranded bool) string { - if !stranded { - return "" - } - return ", not yet saved to the vault" -} - -// fromProject names the project a memory belongs to when it is not the one the -// caller is standing in, and says nothing when it is — the same distinction -// recall draws, in the same words, so a reader moving between the two tools -// does not have to learn two conventions. A global fact belongs everywhere and -// is never foreign. -func fromProject(owner, here string) string { - if owner == "" || owner == here { - return "" - } - return ", from " + untrusted.Inline(owner) -} - -// diffOwner is fromProject for the +/-/~ lines, which carry no kind to hang a -// clause off and so need their own parentheses. -func diffOwner(owner, here string) string { - if owner == "" || owner == here { - return "" - } - return " (" + untrusted.Inline(owner) + ")" -} - -// listMemories lists every memory in the vault, labelled with the project each -// one belongs to. `here` is the project the caller is standing in, whose -// memories are printed bare; "" labels everything, which is what the resource -// surface wants because it is addressed to no one in particular. -// -// Labelled rather than scoped: this tool is meant to show everything, and that -// is fine as long as it says what everything is. It was not saying, and the -// consequence was worse than a model being misled — the documented use for this -// tool is "before forgetting something", `forget` takes the id printed here, -// and another project's id sat in the same undifferentiated list as this -// project's. The obvious next call deleted work from a repository nobody in the -// session had opened. recall has labelled foreign results since #155; these two -// tools are where that fix did not reach. -func (s *Server) listMemories(here string) (string, error) { - mems, err := memory.All(s.DB) - if err != nil { - return "", err - } - if len(mems) == 0 { - return "No memories yet.", nil - } - var b strings.Builder - stranded := memory.UnflushedIDs(s.DB) - for _, m := range mems { - tag := "" - // Pin state has to be visible here too, not just in the CLI — a host's - // model deciding whether to pin/exclude something needs to see what - // already is, or it will keep re-pinning the same memory every session. - switch m.Pin { - case memory.PinAlways: - tag = " [pinned]" - case memory.PinNever: - tag = " [excluded]" - } - fmt.Fprintf(&b, "[%d] (%s%s%s)%s %s\n", m.ID, m.Kind, fromProject(m.Project, here), notDurable(stranded[m.ID]), tag, untrusted.Inline(m.Text)) - } - return strings.TrimRight(b.String(), "\n"), nil -} - -func (s *Server) forget(idStr string) (string, error) { - id, err := strconv.ParseInt(strings.TrimSpace(idStr), 10, 64) - if err != nil { - return "", fmt.Errorf("forget needs a numeric memory id") - } - if err := memory.Forget(s.DB, id); err != nil { - return "", err - } - return "Forgotten.", nil -} - -// pinMemory sets or clears always-include. unpin covers both directions of -// override (see memory.Unpin) so a host does not need a third tool just to -// walk back an exclude_memory call. -func (s *Server) pinMemory(idStr string, unpin bool) (string, error) { - id, err := strconv.ParseInt(strings.TrimSpace(idStr), 10, 64) - if err != nil { - return "", fmt.Errorf("pin_memory needs a numeric memory id") - } - if unpin { - if err := memory.Unpin(s.DB, id); err != nil { - return "", err - } - return "Unpinned — back to normal ranking.", nil - } - if err := memory.Pin(s.DB, id); err != nil { - return "", err - } - return "Pinned — always included in context packs, budget permitting.", nil -} - -func (s *Server) excludeMemory(idStr string) (string, error) { - id, err := strconv.ParseInt(strings.TrimSpace(idStr), 10, 64) - if err != nil { - return "", fmt.Errorf("exclude_memory needs a numeric memory id") - } - if err := memory.Exclude(s.DB, id); err != nil { - return "", err - } - return "Excluded — kept on record, never surfaced.", nil -} - -// --- memory-layer operations: the surface other applications build on --- - -// contextPack assembles everything relevant to a file, project, or topic — the -// project dossier, standing preferences, and related memories — as one markdown -// bundle a host can drop straight into its model's context. -func (s *Server) context(req contextpack.Request) (string, error) { - if strings.TrimSpace(req.Task) == "" && strings.TrimSpace(req.Hint) == "" { - return "", fmt.Errorf("context needs a task (what you are trying to do) or a project") - } - pack, err := contextpack.Build(s.index(), s.embed, s.embedModel, req) - if err != nil { - return "", err - } - body := pack.Render() - return s.lead(pack) + body + s.awaitingReview() + s.ledgerPack("mcp:context", req.Hint, pack), nil -} - -// lead puts the receipt above the pack rather than below it. A person skimming -// a tool result reads the first line and stops; a summary underneath a page of -// markdown is a summary nobody sees. -func (s *Server) lead(pack contextpack.Pack) string { - carried := pack.Carried() - if carried == "" { - return "" - } - r := announce.Say(s.vault, "recalled "+carried) - if r == "" { - return "" - } - return r + "\n\n" -} - -// --- continuity --- - -// resume is context aimed at one question: where did the last agent stop. It is -// the same assembly as context, told to lead with the checkpoint, so an agent -// that has just been handed a project can start with one call. -// beforeYouTry is the one tool here that is not retrieval. -// -// Everything else answers a question the host's model already has. This answers -// two it does not know to ask: whether the approach was already ruled out, and -// whether there is a known-good way to do it with a trap the obvious way falls -// into. Which is why the tool description is written as an instruction: the -// model has no way of knowing either on its own. -// -// here is the project the check is counted under in the usage ledger, which is -// not project: the search stays unscoped, the count belongs to the work here. -func (s *Server) beforeYouTry(approach, project, here string) (string, error) { - if strings.TrimSpace(approach) == "" { - return "", fmt.Errorf("before_you_try needs the approach you are considering") - } - if err := session.Init(s.DB); err != nil { - return "", err - } - hits, semanticErr, err := deadend.CheckNoting(s.vault, s.DB, s.embed, s.embedModel, approach, project, 6) - if err != nil { - return "", err - } - // The corpus is gathered unranked and unfiltered (p=nil, so RecallProcedures - // takes its All()-backed fallback with no reinforcement side effect) — Check - // does its own lexical-plus-semantic scoring below, and a candidate the - // embedding pass here dropped early is exactly the one the lexical arm is - // for. Mirrors deadend's own Collect-then-Check split, and for the same - // reason: ranking degrades to lexical-only with no embedder, gathering must - // not have degraded it already. - corpus, err := memory.RecallProcedures(s.DB, nil, "", "", 0) - if err != nil { - return "", err - } - procHits, err := procedure.Check(corpus, s.embed, s.embedModel, approach, project, 4) - if err != nil { - return "", err - } - - var b strings.Builder - b.WriteString(untrusted.Boundary) - b.WriteString("\n\n") - b.WriteString(deadend.Render(approach, hits)) - b.WriteString(deadend.SemanticSkipped(semanticErr)) - if section := procedure.Render(procHits); section != "" { - b.WriteString("\n") - b.WriteString(section) - } - if len(hits) > 0 { - b.WriteString(s.ledger(usage.Event{Kind: usage.KindDeadEnd, Via: "mcp:before_you_try", Project: here, Rulings: len(hits)})) - } - return b.String(), nil -} - -// why reports what was being decided when a file was worked on. -// -// Reads markdown out of the vault and needs no model and no index, so it works -// on a machine with neither — which matters, because the moment it is useful is -// the moment an agent is about to change something it does not understand. -// -// here is the project a returned dead end is counted under in the usage ledger. -func (s *Server) why(file string, limit int, here string) (string, error) { - if strings.TrimSpace(file) == "" { - return "", fmt.Errorf("why needs a file path") - } - if s.vault == "" { - return "", fmt.Errorf("why reads checkpoints from the vault, and no vault is configured") - } - mentions, err := session.Touching(s.vault, file, limit) - if err != nil { - return "", err - } - if len(mentions) == 0 { - // Distinguish the two nothings. "Nothing was recorded" is a fact about - // the record; "there is no reason" is a claim about the code, and this - // tool is not entitled to make it. - return fmt.Sprintf( - "No checkpoint mentions %s.\n\nNothing was written down while this file was worked on, or it was "+ - "recorded under a different path. Do not read this as evidence the code is arbitrary.", file), nil - } - - var b strings.Builder - fmt.Fprintf(&b, "# What was decided around %s\n\n", file) - ruled := 0 - for _, m := range mentions { - ruled += len(nonBlank(m.Failed)) - when := "an unknown date" - if m.TS > 0 { - when = time.Unix(m.TS, 0).Format("2 Jan 2006") - } - who := m.Agent - if who == "" { - who = "an unrecorded author" - } - fmt.Fprintf(&b, "## %s — %s", when, who) - if m.Project != "" { - fmt.Fprintf(&b, " · %s", m.Project) - } - b.WriteString("\n\n") - if m.Task != "" { - // "While:" is a label, so its value is one line. A task recorded with - // a heading in it could otherwise close the label and open a section - // of its own, directly above the evidence why exists to present. - fmt.Fprintf(&b, "While: %s\n\n", untrusted.Inline(m.Task)) - } - if m.Intent != "" { - fmt.Fprintf(&b, "Because: %s\n\n", untrusted.Inline(m.Intent)) - } - // Ruled out first: a decision explains the shape of the code, and a dead - // end explains why it is not some other shape — which is what someone - // about to "fix" it needs. - writeList(&b, "Ruled out", m.Failed) - writeList(&b, "Decided", m.Decisions) - writeList(&b, "Still open", m.Questions) - fmt.Fprintf(&b, "Source: %s\n\n", m.Slug) - } - b.WriteString("This is what was recorded while the file was touched, not an analysis of " + - "the code. Treat it as evidence about intent, and check it still holds.\n") - // Counted only when a ruled-out approach came back: a why that returned - // decisions alone kept nobody from repeating anything. - if ruled > 0 { - b.WriteString(s.ledger(usage.Event{Kind: usage.KindDeadEnd, Via: "mcp:why", Project: here, Rulings: ruled})) - } - return b.String(), nil -} - -func nonBlank(items []string) []string { - var kept []string - for _, it := range items { - if s := strings.TrimSpace(it); s != "" { - kept = append(kept, s) - } - } - return kept -} - -func writeList(b *strings.Builder, label string, items []string) { - kept := nonBlank(items) - if len(kept) == 0 { - return - } - fmt.Fprintf(b, "**%s:**\n", label) - for _, it := range kept { - // Every item here is a checkpoint field somebody else's agent wrote. One - // bullet, one line — a newline in a recorded dead end was enough to end - // the list and start a heading of logos's own. - fmt.Fprintf(b, "- %s\n", untrusted.Inline(it)) - } - b.WriteString("\n") -} - -// resume takes the project argument unresolved, because whether it was given at -// all decides whether the worktree narrows it — see resolveContinuity. -func (s *Session) resume(projectArg, agent string, budget int, since contextpack.Since) (string, error) { - project, worktree := s.resolveContinuity(projectArg) - chose := "" - // A folder name the vault has never heard of is the ordinary way to reach - // this branch, not a launch in /: a scratch directory, a fresh clone under - // another name. The guard used to test only for an empty name, and a - // working directory almost always has a basename, so the fallback below - // was dead code while the agent was told, emphatically, that nothing was - // recorded (#169). A name the caller gave, or LOGOS_PROJECT set, is still - // honoured as asked — there the caller asserted something, and is told it - // was wrong below rather than handed another project's work. - guess := s.inferredProject() - swept, sweptFor := "", "" - if strings.TrimSpace(projectArg) == "" && guess != "" && !s.projectExists(project) { - // The folder's own project is swept before it is judged unknown. One - // with no checkpoint yet is the one whose killed sessions are recorded - // nowhere else, and falling back first swept the other project instead: - // in a host with no hooks they were never recorded, and after a week - // they were gone (#193). If the sweep recorded one, the project exists - // now and there is nothing to fall back from. - swept, sweptFor = ingest.SweepNotice(ingest.Sweep(s.vault, project, time.Now())), project - } - if strings.TrimSpace(projectArg) == "" && guess != "" && !s.projectExists(project) { - if ps := s.checkpointedProjects(); len(ps) > 0 { - chose = fmt.Sprintf("_Nothing in this vault is filed under %s, the folder this host was launched in — resuming %s, the most recently checkpointed project%s. Pass project to resume a different one, or checkpoint to start %s._\n\n", - untrusted.Inline(guess), untrusted.Inline(ps[0].name), s.knownProjects(), untrusted.Inline(guess)) - project, worktree = ps[0].name, "" - } - } - if strings.TrimSpace(project) == "" { - // Hosts without hooks (Cursor, Codex, Claude Desktop) launched outside - // any repository give no project, and an error sent the user off to - // learn the name another tool filed the work under. The most recent - // checkpoint is the likeliest thing they mean by "resume"; saying which - // was picked lets the agent correct course if it was not. The worktree - // is dropped because it was read from where the host stands, not from - // the project being resumed. - ps := s.checkpointedProjects() - if len(ps) == 0 { - return "", fmt.Errorf("resume needs a project%s", s.knownProjects()) - } - project, worktree = ps[0].name, "" - chose = fmt.Sprintf("_No project given and none to tell from where this host was launched — resuming %s, the most recently checkpointed project%s. Pass project to resume a different one._\n\n", - untrusted.Inline(project), s.knownProjects()) - } - if err := session.Init(s.DB); err != nil { - return "", err - } - // Before the pack, so a session whose host was killed before the shutdown - // path could record it is in the handoff it is missing from (#134). - if project != sweptFor { - swept += ingest.SweepNotice(ingest.Sweep(s.vault, project, time.Now())) - } - pack, err := contextpack.Build(s.index(), s.embed, s.embedModel, contextpack.Request{ - Task: "resume work on " + project, Hint: project, Worktree: worktree, Dir: s.repoDir(project), - Agent: s.agentFor(map[string]any{"agent": agent}), Budget: budget, Since: since, - }) - if err != nil { - return "", err - } - // First, above the pack: the pack's own last word on an unmatched name is - // "say so rather than inferring", and an agent that stops there reports - // nothing about "Saathi backend" while saathi holds the work. Suggested, - // not substituted — the name given is still the one resumed. - if pack.Checkpoint == nil && strings.TrimSpace(projectArg) != "" && !s.projectExists(project) { - if names, err := session.Scopes(s.vault); err == nil { - if near := session.NameInside(project, names); near != "" { - chose = fmt.Sprintf("_Did you mean %s? Nothing is filed under %s itself, and %s is the one known project whose name is inside it — call resume with project %q before reporting that nothing is recorded._\n\n", - untrusted.Inline(near), untrusted.Inline(project), untrusted.Inline(near), near) + chose - } - } - } - out := chose + swept + s.lead(pack) + pack.Render() - out += s.ledgerPack("mcp:resume", project, pack) - if pack.Checkpoint == nil { - // Say so plainly. An agent that assumes there was a checkpoint and - // finds none will invent continuity that never existed. - out += "\n_No checkpoint has been written for this project yet — " + - "this is context, not a handoff. Call checkpoint before you stop._\n" - // "Nothing recorded" is true of this name and false of the vault. An - // agent told only the first stops; one told what exists recovers in a - // single call. - if known := s.knownProjects(); known != "" && !s.projectExists(project) { - out += fmt.Sprintf("_Nothing in this vault is filed under %s%s._\n", untrusted.Inline(project), known) - } - } - out += s.awaitingReview() - // Filed under the scope the pack itself read, so the note lands in the same - // session a checkpoint will later close — in this worktree, not in the - // project the worktree belongs to. Not when the project was a guess: the - // agent is standing somewhere else and will most likely work there, so the - // note opened a session in another project's history that nothing closed. - if scope := pack.Continuity(); strings.TrimSpace(agent) != "" && scope != "" && chose == "" { - session.AddNote(s.DB, scope, agent, "resumed the project") - } - return out, nil -} - -func (s *Server) noteProgress(project, agent, text string) (string, error) { - if strings.TrimSpace(project) == "" { - return "", fmt.Errorf("note_progress needs a project and some text%s", s.knownProjects()) - } - if strings.TrimSpace(text) == "" { - return "", fmt.Errorf("note_progress needs a project and some text") - } - if err := session.Init(s.DB); err != nil { - return "", err - } - n, err := session.AddNote(s.DB, project, agent, text) - if err != nil { - return "", err - } - msg := s.receipt("noted in logos — uncommitted until you checkpoint") - if said := secret.Summary(n.Redactions); said != "" { - msg += " " + said + "." - } - return msg, nil -} - -// receipt marks a line as ours so the person watching the transcript can find -// it without reading it. See internal/announce for why this is a setting and -// not a constant. -// -// It lives on Server rather than Session because the tools that write are split -// across both, and a receipt that appeared on half of them would be worse than -// none: an inconsistent marker teaches people the absence of a marker means -// nothing happened. -func (s *Server) receipt(what string) string { - if r := announce.Say(s.vault, what); r != "" { - return r - } - // At LOGOS_ANNOUNCE=off the model still needs to know what happened, even - // though the user has asked not to be told about it. Silence towards the - // user is not silence towards the caller. - return upperFirst(what) -} - -func upperFirst(s string) string { - if s == "" { - return s - } - r := []rune(s) - return strings.ToUpper(string(r[0])) + string(r[1:]) -} - -// agentFor names who wrote a checkpoint or note: the model's own agent -// argument when it gave one, otherwise the host's handshake name. Without the -// fallback an agent that skipped the optional argument was filed as "agent", -// and a handoff could not say which host stopped there. -func (s *Session) agentFor(args map[string]any) string { - if a := argStr(args, "agent"); a != "" { - // "claude" under claude-code is the host's name cut short, not another - // agent; recording it as typed split one session's trail in two. - if s.clientAgent != "" && strings.HasPrefix(s.clientAgent, strings.ToLower(a)+"-") { - return s.clientAgent - } - return a - } - return s.clientAgent -} - -// checkpoint commits the session to the vault. handoffTo is set when the caller -// came in through the handoff tool — same mechanism, stated intent. -func (s *Session) checkpoint(args map[string]any, handoffTo string) (string, error) { - proj := s.resolveScope(argStr(args, "project")) - if strings.TrimSpace(proj) == "" { - return "", fmt.Errorf("checkpoint needs a project, and none could be inferred from the working directory%s", s.knownProjects()) - } - key, _ := json.Marshal([]any{proj, handoffTo, args}) - // The file is checked too: a receipt for a checkpoint that is no longer on - // disk would be a success-shaped failure. - if last := s.lastCheckpoint; last.key == string(key) && time.Since(last.at) < checkpointRetryWindow && - checkpointOnDisk(s.vault, last.slug) { - msg := s.receipt(fmt.Sprintf("checkpoint already saved to logos — %s.md; this identical retry was not written again", last.slug)) - if handoffTo != "" { - msg += fmt.Sprintf(" Handed off to %s — they can call resume(%q).", handoffTo, proj) - } - return msg, nil - } - if err := session.Init(s.DB); err != nil { - return "", err - } - c := &session.Checkpoint{ - Project: proj, - Agent: s.agentFor(args), - Task: argStr(args, "task"), - Intent: argStr(args, "intent"), - State: argStr(args, "state"), - Decisions: argList(args, "decisions"), - Failed: argList(args, "failed"), - Verified: argList(args, "verified"), - Blockers: argList(args, "blockers"), - Commands: argList(args, "commands"), - Questions: argList(args, "questions"), - Files: argList(args, "files"), - Next: argStr(args, "next"), - HandoffTo: handoffTo, - } - var dropped int - c.Failed, dropped = session.DropPlaceholders(c.Failed) - // Read before the commit, so the history is what came before this checkpoint. - earlier, _ := session.History(s.vault, c.Project, session.IntentDepth) - if err := session.Commit(s.DB, s.vault, c); err != nil { - return "", err - } - s.lastCheckpoint.key, s.lastCheckpoint.slug, s.lastCheckpoint.at = string(key), c.Slug, time.Now() - s.checkpointed(c.Project) - msg := s.receipt(fmt.Sprintf("checkpoint saved to logos — %s.md", c.Slug)) - if said := secret.Summary(c.Redactions); said != "" { - msg += " " + said + "." - } - for _, said := range advice.Checkpoint(*c, earlier, dropped, advice.MCP) { - msg += " " + said - } - if handoffTo != "" { - msg += fmt.Sprintf(" Handed off to %s — they can call resume(%q).", handoffTo, c.Project) - } - // No "run `logos index`": resume and before_you_try read the checkpoint off - // disk, so it is usable the moment this returns. See cmd/logos/session.go. - return msg, nil -} - -// memoryDiff reports what the memory learned, dropped, or corroborated over the -// last `days`, optionally about one subject. Instant and offline — it reads the -// append-only memory log, no model. -func (s *Server) memoryDiff(subject string, days int, here string) (string, error) { - if days <= 0 { - days = 7 - } - until := time.Now() - since := until.AddDate(0, 0, -days) - res, err := memory.Diff(s.DB, subject, since.Unix(), until.Unix()) - if err != nil { - return "", err - } - if res.Empty() { - return "Nothing changed in that window.", nil - } - var b strings.Builder - // One line per entry, for the same reason recall collapses: the +/-/~ marker - // is the only thing distinguishing logos's reading of the window from the - // stored text beside it. - // The project on each line for the same reason recall carries it: over a - // window, every project's changes arrive in one list, and an unlabelled - // line about another repository reads as a change to this one. - for _, e := range res.Added { - fmt.Fprintf(&b, "+%s %s\n", diffOwner(e.Project, here), untrusted.Inline(e.Text)) - } - for _, e := range res.Removed { - fmt.Fprintf(&b, "-%s %s\n", diffOwner(e.Project, here), untrusted.Inline(e.Text)) - } - for _, e := range res.Corroborated { - fmt.Fprintf(&b, "~%s %s\n", diffOwner(e.Project, here), untrusted.Inline(e.Text)) - } - return strings.TrimRight(b.String(), "\n"), nil -} - -// knownProjects ends a refusal for a missing project with the projects that -// have checkpoints, newest first. A model that does not know the name has no -// other way to learn it from the refusal, and a host that launches the server -// in / never supplies one. -func (s *Server) knownProjects() string { - ps := s.checkpointedProjects() - if len(ps) == 0 { - return "" - } - const maxKnown = 5 - if len(ps) > maxKnown { - ps = ps[:maxKnown] - } - parts := make([]string, len(ps)) - for i, p := range ps { - parts[i] = fmt.Sprintf("%s (%s)", untrusted.Inline(p.name), project.Age(p.ts)) - if p.agent != "" { - parts[i] = fmt.Sprintf("%s (%s, %s)", untrusted.Inline(p.name), project.Age(p.ts), untrusted.Inline(p.agent)) - } - } - return ". Known projects: " + strings.Join(parts, ", ") -} - -// projectExists reports whether the vault has ever heard this exact name — -// either a memory filed under it or a session directory carrying it. Both are -// consulted because a project can have checkpoints and no memory, or memory -// and no checkpoint, and either one makes the name real. -func (s *Server) projectExists(name string) bool { - if ok, err := memory.HasProject(s.DB, name); err == nil && ok { - return true - } - names, err := session.Scopes(s.vault) - if err != nil { - return false - } - for _, n := range names { - // Either direction counts: a worktree scope is "shop/fix-auth" while - // the enumerator lists "shop", so a name can be the parent of a known - // scope or a scope under a known parent. - if n == name || strings.HasPrefix(n, name+"/") || strings.HasPrefix(name, n+"/") { - return true - } - } - return false -} - -// knownProjectsSentence is knownProjects punctuated as an answer rather than -// as the tail of a refusal. -func (s *Server) knownProjectsSentence() string { - if known := s.knownProjects(); known != "" { - return known + "." - } - return "." -} - -type knownProject struct { - name, agent string - ts int64 -} - -// checkpointedProjects lists the projects that have a checkpoint, most recent -// first. -func (s *Server) checkpointedProjects() []knownProject { - // Scopes, not Projects: a scope this returns is one an agent will pass - // straight back to resume, and a worktree scope was the single thing none - // of these surfaces could name. - names, err := session.Scopes(s.vault) - if err != nil { - return nil - } - var ps []knownProject - for _, n := range names { - if h, err := session.History(s.vault, n, 1); err == nil && len(h) > 0 { - ps = append(ps, knownProject{n, h[0].Agent, h[0].TS}) - } - } - sort.Slice(ps, func(i, j int) bool { return ps[i].ts > ps[j].ts }) - return ps -} - -// scopeCount is a scope and how much work is filed under it. -type scopeCount struct { - name string - n int -} - -// checkpointedScopes lists the scopes holding at least one checkpoint, with -// the count. Separate from checkpointedProjects because that one carries the -// most recent checkpoint's agent and timestamp and this one only needs a -// number; both drop the scopes holding none, which is the part that matters. -func (s *Server) checkpointedScopes() []scopeCount { - names, err := session.Scopes(s.vault) - if err != nil { - return nil - } - var out []scopeCount - for _, n := range names { - h, err := session.History(s.vault, n, 0) - if err != nil || len(h) == 0 { - continue - } - out = append(out, scopeCount{n, len(h)}) - } - return out -} - -// listProjectsHere is listProjects with the one fact it could never supply: -// where the caller is standing. -// -// listProjects is a method on Server, and its line in the tool switch was the -// only one that threaded no session state — so the single tool whose answer is -// a list of names had no way to mark the name belonging to the agent asking. -// An agent handed four names fans out and calls resume once per name; three of -// those answers are somebody else's work. The scope comes from the same -// observed sources every other continuity tool uses, never from an argument. -// -// The resource surface keeps the unscoped listing: logos://projects is a -// directory of the vault, not advice to an agent standing somewhere. -func (s *Session) listProjectsHere() (string, error) { - body, err := s.listProjects() - if err != nil { - return "", err - } - here := s.resolveScope("") - if here == "" { - return body, nil - } - head := fmt.Sprintf("You are in %s", untrusted.Inline(here)) - if h, err := session.History(s.vault, here, 0); err == nil && len(h) > 0 { - word := "checkpoints" - if len(h) == 1 { - word = "checkpoint" - } - head += fmt.Sprintf(" (%d %s)", len(h), word) - } else { - head += " (no checkpoints yet)" - } - return head + ".\n\nEverything in this vault:\n" + body, nil -} - -// listProjects enumerates the projects logos detected, most-recently-active -// first, so a host can navigate the memory by the work it is organised around. -func (s *Server) listProjects() (string, error) { - ps, err := project.Detect(s.DB) - if err != nil { - return "", err - } - if len(ps) == 0 { - // The activity rollup is not where checkpoints live. A model looking - // for a name to resume was told there were none while sessions/ held - // them, and reported an empty memory. `logos projects` falls back the - // same way. - // Only scopes that actually hold a checkpoint. This branch used to print - // every session directory under a heading asserting they all had one, - // contradicting itself on the rows reading "(0 checkpoints)" — and those - // empty rows are the ghost projects a host leaves behind in any folder - // it was opened in, so the list was advertising its own exhaust. - if ps := s.checkpointedScopes(); len(ps) > 0 { - var b strings.Builder - // A statement, not an instruction. "call resume with one" was the - // only line in this server aimed at the model, and it sat directly - // above a list — which a thorough agent reads as "enumerate these", - // and did: four resume calls where one was wanted. - b.WriteString("No activity rollup yet. These scopes hold checkpoints:\n") - for _, p := range ps { - word := "checkpoints" - if p.n == 1 { - word = "checkpoint" - } - fmt.Fprintf(&b, "- %s (%d %s)\n", p.name, p.n, word) - } - return strings.TrimRight(b.String(), "\n"), nil - } - return "No projects detected yet.", nil - } - var b strings.Builder - for _, p := range ps { - fmt.Fprintf(&b, "- %s (last active %s)\n", p.Name, project.Age(p.LastActive)) - } - return strings.TrimRight(b.String(), "\n"), nil -} - // --- json-rpc plumbing --- // response is one JSON-RPC reply. It is returned rather than written, so the @@ -1730,109 +814,3 @@ func reply(id json.RawMessage, result any) *response { func replyErr(id json.RawMessage, code int, msg string) *response { return &response{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}} } - -func argStr(args map[string]any, k string) string { - if v, ok := args[k].(string); ok { - return v - } - return "" -} - -// argBool accepts a real bool or the string a model emits when it is being -// loose about JSON types, which is often enough to matter on a flag that -// changes which memories come back. -func argBool(args map[string]any, k string, def bool) bool { - switch v := args[k].(type) { - case bool: - return v - case string: - if b, err := strconv.ParseBool(strings.TrimSpace(v)); err == nil { - return b - } - } - return def -} - -func argInt(args map[string]any, k string, def int) int { - switch v := args[k].(type) { - case float64: - return int(v) - case int: - return v - case string: - if n, err := strconv.Atoi(v); err == nil { - return n - } - } - return def -} - -// argList accepts either a JSON array or a newline/semicolon separated string. -// Hosts vary in how reliably their models emit arrays for list-shaped -// arguments, and rejecting a checkpoint because the decisions arrived as a -// string would lose the work it was recording. -func argList(args map[string]any, k string) []string { - var out []string - switch v := args[k].(type) { - case []any: - for _, it := range v { - if s, ok := it.(string); ok && strings.TrimSpace(s) != "" { - out = append(out, strings.TrimSpace(s)) - } - } - case []string: - out = v - case string: - for _, line := range strings.FieldsFunc(v, func(r rune) bool { return r == '\n' || r == ';' }) { - line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "-*+ ")) - if line != "" { - out = append(out, line) - } - } - } - return out -} - -// quarantineReceipt names the review command this install answers to. Under -// npx or the plugin alone there is no logos on PATH, and "run `logos review`" -// left the memory queued behind a command the user could not run. -func (s *Server) quarantineReceipt(id int64, kind, where string, r memory.Receipt) string { - // Why it queued, not just that it did. A memory only waits for review when - // it disputes one already stored, so the receipt quotes the memory in - // dispute — that is what lets the agent raise it in the conversation the - // user is already having, rather than leaving it for a queue they open - // some other day. - if r.Contested != 0 { - return fmt.Sprintf("queued memory #%d (%s, %s) — it contradicts memory #%d, %q. The user runs `%s review` to settle which is current; until then neither answer changes", - id, kind, where, r.Contested, r.ContestedText, s.shell()) - } - return fmt.Sprintf("queued memory #%d (%s, %s) for review — the user runs `%s review` to accept or reject it before it becomes active", id, kind, where, s.shell()) -} - -// shell is the command this install answers to; see quarantineReceipt. -func (s *Server) shell() string { - if s.Shell == "" { - return "logos" - } - return s.Shell -} - -// awaitingReview is the line every read appends while the review queue is not -// empty. Quarantine keeps an agent's memories out of recall until the user says -// yes, and a user who is never told there is anything to say yes to leaves them -// there for good — while the agent reads the empty recall as the fact never -// having been stored. A failed count is said, not swallowed, but does not fail -// the read it is attached to. -func (s *Server) awaitingReview() string { - n, err := memory.PendingCount(s.DB) - if err != nil { - return fmt.Sprintf("\n\n(could not count the memories waiting for review: %v)", err) - } - if n == 0 { - return "" - } - if n == 1 { - return fmt.Sprintf("\n\n1 memory is waiting for your review — `%s review`", s.shell()) - } - return fmt.Sprintf("\n\n%d memories are waiting for your review — `%s review`", n, s.shell()) -} From 211db8481b50de838c220cd24a9356635a6bd23f Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 1 Oct 2026 17:24:19 -0700 Subject: [PATCH 07/16] Setup's model pulls, vault choice, binary pinning and MCP config each live in their own file, so setup.go holds the flow --- cmd/logos/setup.go | 1116 ------------------------------------- cmd/logos/setup_binary.go | 288 ++++++++++ cmd/logos/setup_mcp.go | 332 +++++++++++ cmd/logos/setup_models.go | 225 ++++++++ cmd/logos/setup_vault.go | 315 +++++++++++ 5 files changed, 1160 insertions(+), 1116 deletions(-) create mode 100644 cmd/logos/setup_binary.go create mode 100644 cmd/logos/setup_mcp.go create mode 100644 cmd/logos/setup_models.go create mode 100644 cmd/logos/setup_vault.go diff --git a/cmd/logos/setup.go b/cmd/logos/setup.go index 8849ddd..f7c6cdd 100644 --- a/cmd/logos/setup.go +++ b/cmd/logos/setup.go @@ -2,29 +2,16 @@ package main import ( "bufio" - "context" - "encoding/json" "fmt" "io" - "net" - "net/http" "os" - "os/exec" "path/filepath" - "runtime" "slices" - "sort" - "strconv" "strings" - "time" "github.com/Coder8124/logos/internal/activity" "github.com/Coder8124/logos/internal/health" - "github.com/Coder8124/logos/internal/index" - "github.com/Coder8124/logos/internal/provider" - "github.com/Coder8124/logos/internal/router" "github.com/Coder8124/logos/internal/selfupdate" - "github.com/Coder8124/logos/internal/session" "github.com/Coder8124/logos/internal/setup" "github.com/Coder8124/logos/internal/transcript" "github.com/Coder8124/logos/internal/vault" @@ -206,563 +193,11 @@ func wireOptsFrom(args []string) wireOpts { } } -// chooseVault resolves where the vault lives and, unless this is a dry run, -// makes sure it exists and is the one this machine remembers. -// -// created reports whether the directory was missing, so a dry run can say what -// it would have made without making it. recorded reports whether this vault was -// written down as the machine's, which is not the same question. -// -// A vault named only by LOGOS_VAULT is deliberately not recorded. LOGOS_VAULT -// is a per-process override — it is how the documented scratch-vault workflow -// works, and how an MCP host config pins one server to one vault — so treating -// it as a machine-wide choice means a single `setup` run against a throwaway -// directory silently repoints every front end at it. That shipped: a scratch -// vault under an agent's job directory became the recorded pointer, and because -// the directory still existed, Recorded() kept returning it. Every command, the -// MCP server and the SessionStart hook then read an empty vault and truthfully -// reported nothing, while twenty-eight checkpoints sat in ~/logos. --vault, and -// the default, are choices someone made; an inherited environment variable is -// not. -// recorded says whether this vault became the machine's recorded pointer, and -// why not when it did not. The reason is load-bearing: the three ways to end up -// unrecorded — the environment chose the vault, the write failed, or this was a -// dry run — need three different next steps, and reporting one of them for all -// three told a user whose config directory was unwritable to "pass --vault", -// which is exactly what they had just done. -type recordOutcome int - // hostColumn is the width of the name column in setup's host report. It was // 16 until "Copilot in VS Code" (18) pushed its arrow out of line with every // other row's. const hostColumn = 18 -const ( - recordedHere recordOutcome = iota // written down - recordSkipEnv // LOGOS_VAULT chose it, so it is this process only - recordFailed // the write was attempted and failed; the error is already printed - recordSkipTemp // a temporary directory, and nobody said to record it anyway - recordSkipMove // this machine already has a vault holding work, and nobody said to move it -) - -func chooseVault(args []string, dryRun bool) (dir string, created bool, rec recordOutcome, err error) { - dir = flagStr(args, "--vault", "") - fromEnv := false - if dir == "" { - if v := os.Getenv("LOGOS_VAULT"); v != "" { - // fromEnv means somebody named a vault for this one run. The host - // pin this process adopted is not that: it is the machine's own - // recorded choice arriving by another road, and treating it as - // per-process made setup inside a host record nothing. - dir, fromEnv = v, !vaultCameFromHostPin(v) - } else { - dir = vaultPath() // the recorded path, then ~/logos - } - } - abs, err := filepath.Abs(expandHome(dir)) - if err != nil { - return "", false, recordFailed, err - } - // Before anything is recorded: the pointer is machine-wide and outlives the - // run, and a file there left every host wired to a path that cannot hold a - // vault, with the only failure printed ten lines above a table of ticks. - // Usually a shell's doing — a tab-completion onto a neighbouring file, or an - // empty $VAR that made the next word the path — not anybody's choice. - if info, err := os.Stat(abs); err == nil && !info.IsDir() { - return "", false, recordFailed, fmt.Errorf("%s is a file, not a directory — pass --vault ; nothing was changed", abs) - } - if _, err := os.Stat(abs); os.IsNotExist(err) && flagStr(args, "--vault", "") == "" && !fromEnv && abs == vault.Pointer() { - // Nobody asked for this directory in this run; it is the recorded vault, - // and it is missing — usually an unmounted drive. Creating it makes an - // empty vault at the mount path. - return "", false, recordFailed, missingVaultError(abs) - } - if flagStr(args, "--vault", "") == "" && !fromEnv && looksLikeSourceTree(abs) { - // Nobody chose this directory; it is the default, and it is a project. - // `git clone …/logos` run in ~ lands exactly on ~/logos, and taking it - // indexes the repository's markdown as notes and puts the user's memory - // inside a tree `git clean` or a re-clone deletes. - return "", false, recordFailed, fmt.Errorf("%s looks like a source checkout, not a vault — pass --vault to choose where the vault goes", abs) - } - if _, err := os.Stat(abs); os.IsNotExist(err) { - created = true - if !dryRun { - // Private from the first mkdir. A vault created world-readable and - // tightened later is a vault that was world-readable for however long - // the user took to run `logos doctor`. - if err := vault.MkdirPrivate(abs); err != nil { - return "", false, recordFailed, fmt.Errorf("creating %s: %w", abs, err) - } - } - } - // A dry run reports the outcome the real run would reach, which under - // LOGOS_VAULT is "not recorded" — the one command whose whole job is - // previewing was promising the opposite of what followed. - // doctor fails a recorded vault that lives under a temp root, because it - // will be empty or gone. By then the pointer has already moved; setup is - // the one place the check can stop it, so a temporary directory is used - // for this run and recorded only when someone says so. - // - // --yes is not the answer to this one. It means "do not ask me questions", - // and a script that passed it was also silently repointing the whole machine - // at a scratch directory — the pointer is one file, and that is how it moved - // without anyone deciding to move it. Waiving the guard needs its own flag. - temp := !fromEnv && health.UnderTempDir(abs) && !hasFlag(args, "--record-temp") - yes := hasFlag(args, "--yes") || hasFlag(args, "-y") - if dryRun { - if fromEnv { - return abs, created, recordSkipEnv, nil - } - if temp { - return abs, created, recordSkipTemp, nil - } - if move, _, _ := movingLoadedVault(args, abs); move { - return abs, created, recordSkipMove, nil - } - return abs, created, recordedHere, nil - } - if fromEnv { - return abs, created, recordSkipEnv, nil - } - if temp { - fmt.Printf(" %s is a temporary directory — it will be empty or gone\n", abs) - // No terminal to ask, so the safe answer is taken and named: a run that - // silently did the dangerous thing is the bug being fixed here. - if yes { - fmt.Println(" not recording it — pass --record-temp to record it anyway") - return abs, created, recordSkipTemp, nil - } - if !confirmNo(" record it as this machine's vault anyway?") { - return abs, created, recordSkipTemp, nil - } - } - // Moving a vault that holds work is the one setup decision worth its own - // answer. The pointer is one file and `--vault B` rewrote it whether or not - // A held every checkpoint this machine has taken — announced afterwards, in - // the same receipt line as everything else. --yes does not answer this one - // either, for the reason above it. - if move, from, holds := movingLoadedVault(args, abs); move { - fmt.Printf(" this machine's vault is %s, and it holds %s\n", from, holds) - if yes { - fmt.Println(" not moving it — pass --move-vault to move it anyway") - return abs, created, recordSkipMove, nil - } - if !confirmNo(fmt.Sprintf(" make %s this machine's vault instead?", abs)) { - return abs, created, recordSkipMove, nil - } - } - // Write the choice down where a process with no shell can read it. A host - // launched from Finder, such as Claude Desktop, inherits no LOGOS_VAULT, so - // without this the server it starts can only find a vault at the default. - if err := vault.Record(abs); err != nil { - fmt.Printf(" could not record this vault for hosts started without LOGOS_VAULT: %v\n", err) - return abs, created, recordFailed, nil - } - return abs, created, recordedHere, nil -} - -// movingLoadedVault reports whether this run would repoint the machine away -// from a recorded vault that has checkpoints in it, names that vault, and says -// what is in it. An empty vault, or the one already recorded, is not a decision -// anybody needs to defend. -// -// The description is the part that makes the question answerable (#90). "It -// holds work" is true of a vault with one checkpoint and of a vault with a -// year of them, and the two deserve opposite answers — so the count is said -// before the prompt, not discovered afterwards by a resume that finds nothing. -func movingLoadedVault(args []string, abs string) (bool, string, string) { - if hasFlag(args, "--move-vault") { - return false, "", "" - } - prev := vault.Recorded() - if prev == "" || filepath.Clean(prev) == abs { - return false, "", "" - } - projects, err := session.Projects(prev) - if err != nil { - return false, "", "" - } - // A directory under sessions/ is not by itself work worth defending: it - // also exists for a project that has only working notes. Asking about one - // produced a warning whose own sentence said there was nothing to lose. - holding, checkpoints := vaultHolding(prev, projects) - if checkpoints == 0 { - return false, "", "" - } - return true, prev, holding -} - -// vaultHolding counts what would be left behind, in the terms the user names it -// in: checkpoints, and the projects they are filed under. A project directory -// that cannot be read counts as nothing rather than failing the move — this -// sentence exists to inform a decision, and refusing to describe the vault is a -// worse answer than describing the part of it that is readable. -func vaultHolding(prev string, projects []string) (string, int) { - checkpoints, held := 0, 0 - for _, p := range projects { - before := checkpoints - checkpoints += checkpointsUnder(filepath.Join(prev, session.CheckpointDir, p)) - // Counted the same way as the checkpoints, for the same reason: a - // project the user would be leaving nothing of is not one of the - // projects this sentence is warning them about. - if checkpoints > before { - held++ - } - } - return fmt.Sprintf("%d %s across %d %s", checkpoints, plural(checkpoints, "checkpoint"), held, plural(held, "project")), checkpoints -} - -// checkpointsUnder counts a project's checkpoints, including the ones a -// worktree keeps in its own subdirectory. -// -// One level down, not a full walk. A worktree scope is spelled -// "project/worktree" and session.Projects returns only the top level, so a -// vault whose work is all on branches counted zero and the "this vault holds -// work" prompt never appeared — setup repointed the machine away from it in -// silence. Two levels is the whole of the layout; recursing further would only -// find whatever else a user has put in their own directory. -func checkpointsUnder(dir string) int { - entries, err := os.ReadDir(dir) - if err != nil { - // A project directory that cannot be read counts as nothing rather - // than failing the move: this sentence exists to inform a decision, - // and refusing to describe the vault is worse than describing the - // part of it that is readable. - return 0 - } - n := 0 - for _, e := range entries { - if e.IsDir() { - n += worktreeCheckpoints(filepath.Join(dir, e.Name())) - continue - } - // The same predicate session.Read and doctor count with: a session - // directory also holds the project's working notes, and calling those - // a checkpoint overstates what the vault holds. - if session.IsCheckpointFile(e.Name()) { - n++ - } - } - return n -} - -// worktreeCheckpoints counts the checkpoint files directly inside one -// worktree's directory, and does not descend again. -func worktreeCheckpoints(dir string) int { - entries, err := os.ReadDir(dir) - if err != nil { - return 0 - } - n := 0 - for _, e := range entries { - if !e.IsDir() && session.IsCheckpointFile(e.Name()) { - n++ - } - } - return n -} - -// goRunBinary reports a binary inside a go-build directory, where `go run` -// puts the executable it removes on exit. A `.test` binary lives there too, -// but it is this package's tests calling setup, not a person wiring hosts. -func goRunBinary(bin string) bool { - if strings.HasSuffix(bin, ".test") || strings.HasSuffix(bin, ".test.exe") { - return false - } - return inGoBuildDir(bin) -} - -// inGoBuildDir reports a binary Go built into its own temp tree, `go run`'s and -// the test binary's alike. Neither is an install, so neither is something to -// copy onto a PATH and wire hosts to. -func inGoBuildDir(bin string) bool { - for _, part := range strings.Split(filepath.ToSlash(bin), "/") { - if strings.HasPrefix(part, "go-build") { - return true - } - } - return false -} - -// looksLikeSourceTree reports a directory holding a Go or npm project and no -// Logos history. A vault with sessions or memories is a vault whatever else is -// in it; a plain git repository is not enough, since notes vaults are often -// kept in git. -func looksLikeSourceTree(dir string) bool { - for _, d := range []string{"sessions", "memories"} { - if _, err := os.Stat(filepath.Join(dir, d)); err == nil { - return false - } - } - for _, f := range []string{"go.mod", "package.json"} { - if _, err := os.Stat(filepath.Join(dir, f)); err == nil { - return true - } - } - return false -} - -// checkRuntime reports the local model runtime and offers to pull what is -// missing. A machine with no runtime hears nothing about one: lexical retrieval -// and the whole continuity surface need no model, and telling a coding-agent -// user to install Ollama made a tool that needs no configuring look like it did. -// dryRun turns every offer into a description. `--dry-run --yes` used to be a -// combination that downloaded models — several gigabytes, from a command whose -// last line says nothing was written. -func checkRuntime(yes, dryRun bool) { - found := provider.Discover() - if len(found) == 0 { - return - } - p := found[0].Provider - fmt.Printf(" runtime %s at %s\n", p.Name, p.BaseURL) - // Pulling is Ollama's /api/pull. Every other runtime answered it with a 404 - // after the user had already said yes, so they are told what to load instead. - canPull := p.Name == "Ollama" - - have := map[string]bool{} - for _, m := range found[0].Models { - have[m] = true - if base, _, ok := strings.Cut(m, ":"); ok { - have[base] = true - } - } - - // The embedding model and the chat tiers are asked about separately, because - // they are not the same decision and lumping them made the answer harder - // than it needed to be. - // - // T0 is 274MB and buys semantic search. T1 and T2 together are ~26GB and buy - // `ask`, `voice`, `presence` and the nightly rollup — none of which any MCP - // tool touches, so a coding agent needs none of it. Offering all three in one - // prompt asked people to download 26GB to get 274MB of product, with no way - // to say "just the useful one" and no sizes to judge by. - embed := env("LOGOS_EMBED", defaultEmbedModel) - fmt.Printf(" embedding %s %s\n", embed, tick(have[embed])) - if !have[embed] { - if !canPull { - fmt.Printf(" load %s in %s for semantic search — logos can only pull through Ollama\n", embed, p.Name) - } else if dryRun { - fmt.Printf(" would offer to pull %s (%s)\n", embed, modelSize(embed)) - } else if yes || confirm(fmt.Sprintf(" pull %s (%s)? adds semantic search", - embed, modelSize(embed))) { - pull(p.BaseURL, embed) - } else { - fmt.Println(" skipped; retrieval stays lexical, which still works") - } - } - - var chat []string - for _, want := range chatModels() { - if !have[want] { - chat = append(chat, want) - } - fmt.Printf(" model %s %s\n", want, tick(have[want])) - } - if len(chat) == 0 { - return - } - - // Default no, and say what declining costs. With the server no longer - // refusing to start without a runtime, "no" is a safe answer rather than a - // gamble — which is what makes stating the size honest rather than a scare. - fmt.Printf(" %s are optional (%s) — only `logos ask`, `voice`\n", - strings.Join(chat, " and "), totalSize(chat)) - fmt.Println(" and the nightly rollup use them. No MCP tool does.") - if !allModels(os.Args) { - fmt.Println(" skipped; pass --all-models to pull them") - return - } - if !canPull { - fmt.Printf(" load %s in %s — logos can only pull through Ollama\n", strings.Join(chat, " and "), p.Name) - return - } - if dryRun { - fmt.Printf(" would pull %s (%s)\n", strings.Join(chat, " and "), totalSize(chat)) - return - } - for _, m := range chat { - pull(p.BaseURL, m) - } -} - -// pull fetches one model, reporting either way. -func pull(baseURL, model string) { - fmt.Printf(" pulling %s … ", model) - // One updating line: a multi-gigabyte download that printed nothing until it - // finished could not be told apart from a hang. - last := -1 - progress := func(pct int) { - if pct != last { - last = pct - fmt.Printf("\r pulling %s … %d%% ", model, pct) - } - } - if err := pullModel(baseURL, model, progress); err != nil { - fmt.Printf("failed: %v\n", err) - return - } - fmt.Println("done") -} - -func allModels(args []string) bool { return hasFlag(args, "--all-models") } - -// modelSize is what a download actually costs, so "yes" is an informed answer. -// Approximate and clearly so — the exact figure depends on the quantisation the -// registry serves, and a rounded number a user can plan around beats a precise -// one that is wrong on their machine. -func modelSize(model string) string { - switch { - // The default only: a custom LOGOS_EMBED containing "embed" is not this size. - case strings.HasPrefix(model, "nomic-embed-text"): - return "~270 MB" - case strings.HasPrefix(model, "gemma3:4b"): - return "~3.3 GB" - case strings.HasPrefix(model, "qwen3"): - return "~23 GB" - default: - return "size unknown" - } -} - -func totalSize(models []string) string { - var known []string - for _, m := range models { - if s := modelSize(m); s != "size unknown" { - known = append(known, s) - } - } - if len(known) == 0 { - return "size unknown" - } - return strings.Join(known, " + ") -} - -// chatModels is the configured local chat tiers. Read from the router config -// rather than hard-coded, so setup offers what this install would actually use. -// -// Deliberately excludes the embedding model, which is a separate and much -// smaller decision — see checkRuntime. -func chatModels() []string { - cfg, err := router.Load(vaultPath()) - if err != nil { - return nil - } - var out []string - for _, t := range []router.Tier{router.T1, router.T2} { - if tc, ok := cfg.Tiers[t.String()]; ok && tc.Model != "" && tc.BaseURL == "" { - out = append(out, tc.Model) - } - } - return out -} - -func tick(ok bool) string { - if ok { - return "✓" - } - return "✗ missing" -} - -// pullTimeout bounds connecting to Ollama and waiting for it to start -// answering. Not the download: that streams for as long as the model takes. -var pullTimeout = 30 * time.Second - -// pullModel asks Ollama to fetch a model. The response streams progress as -// JSON lines, passed on as a percentage of the layer being downloaded. -func pullModel(baseURL, model string, progress func(pct int)) error { - // Ollama's native API sits alongside the OpenAI-compatible /v1 path. - root := strings.TrimSuffix(strings.TrimSuffix(baseURL, "/"), "/v1") - body, err := json.Marshal(map[string]string{"model": model}) - if err != nil { - return err - } - // The default client has no timeout, so an Ollama that accepted the - // connection and never answered held setup forever. - client := &http.Client{Transport: &http.Transport{ - DialContext: (&net.Dialer{Timeout: pullTimeout}).DialContext, - ResponseHeaderTimeout: pullTimeout, - }} - resp, err := client.Post(root+"/api/pull", "application/json", strings.NewReader(string(body))) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("%s", resp.Status) - } - sc := bufio.NewScanner(resp.Body) - sc.Buffer(make([]byte, 0, 64*1024), 1<<20) - for sc.Scan() { - var line struct { - Error string `json:"error"` - Total int64 `json:"total"` - Completed int64 `json:"completed"` - } - if json.Unmarshal(sc.Bytes(), &line) != nil { - continue - } - if line.Error != "" { - return fmt.Errorf("%s", line.Error) - } - if line.Total > 0 { - progress(int(line.Completed * 100 / line.Total)) - } - } - return sc.Err() -} - -// indexVault runs the first index so the vault is queryable immediately. -func indexVault(dir string) error { - // The same guard `logos index` runs, and the one that matters most here: - // setup is the path every user takes on day one, and `git init && git add - // -A` in a vault without it commits index.db and #88's activity log — every - // command and file path a host reported. - if wrote, err := vault.EnsureGitignore(dir); err != nil { - fmt.Printf(" index could not write .gitignore: %v\n", err) - } else if wrote { - fmt.Println(" index .gitignore now keeps .logos/ and activity/ out of git") - } - - // Returned, not printed and dropped: the caller goes on to wire every host - // to this vault, and a vault it could not index is not one to wire them to. - ix, err := index.Open(dir) - if err != nil { - return err - } - defer ix.Close() - - rep, err := ix.Sync() - if err != nil { - return err - } - // provider.Discover rather than findProvider: the latter prints a banner of - // its own, which would interrupt this report mid-table. - embedModel := env("LOGOS_EMBED", defaultEmbedModel) - if found := provider.Discover(); len(found) > 0 { - // Said before it starts: a large vault takes minutes to embed, and - // silence for that long reads as a hang with the hosts prompt stuck - // behind it. - var pending int - ix.DB.QueryRow(`SELECT COUNT(*) FROM notes n LEFT JOIN embeddings e ON e.slug = n.slug WHERE e.slug IS NULL`).Scan(&pending) - if pending > 0 { - fmt.Printf(" index embedding %d %s with %s — search already works without it…\n", pending, plural(pending, "note"), embedModel) - } - if _, err := ix.EmbedPending(found[0].Provider, embedModel, 32); err != nil { - fmt.Printf(" index embedding failed: %v — search is lexical until `logos index` succeeds\n", err) - } - ix.SyncMemories(found[0].Provider, embedModel) - } - notes, _ := ix.NoteCount() - edges, _ := ix.EdgeCount() - fmt.Printf(" index %d notes, %d edges", notes, edges) - if rep.Skipped > 0 { - fmt.Printf(" (%d skipped)", rep.Skipped) - } - fmt.Println() - return nil -} - // wireHosts registers this binary with every MCP host on the machine. // wireOpts is how the caller narrows or previews the wiring. type wireOpts struct { @@ -784,205 +219,6 @@ var ( integrationChecks = health.Integration ) -// logosServer is the command line and environment any host — known to -// setup.Hosts() or not — needs to reach this logos and this vault. Shared by -// wireHosts, --print-config and --config so that all three describe the exact -// same server; a hand-typed config that differs from what `logos setup` itself -// would have written is a bug users would have no way to notice. -func logosServer(vault string) (setup.Server, error) { - bin, err := selfPath() - if err != nil { - return setup.Server{}, err - } - return serverFor(bin, vault), nil -} - -// executable is os.Executable, replaceable so a test can be a binary running -// from npm's npx cache. -var executable = os.Executable - -func selfPath() (string, error) { - bin, err := executable() - if err != nil { - return "", fmt.Errorf("could not find my own path, which the host config needs: %w", err) - } - if resolved, err := filepath.EvalSymlinks(bin); err == nil { - bin = resolved - } - return bin, nil -} - -// terminalCommand is how this install is reached from a shell, for the -// commands setup suggests, with a hint when that is not simply `logos`. Setup -// used to say "logos resume " regardless, and under npx, a source -// build or a release binary run from Downloads there is no logos on PATH. -func terminalCommand(self string) (cmd, hint string) { - switch selfupdate.DetectInstall(self) { - case selfupdate.NPX: - return "npx @noeton/logos", "for a `logos` command, run `npm i -g @noeton/logos`" - case selfupdate.NPMManaged: - // npm's logos is a node shim, not this file, so it cannot be compared - // by path; being on PATH is the whole question. - if _, err := exec.LookPath("logos"); err == nil { - return "logos", "" - } - default: - if found, err := exec.LookPath("logos"); err == nil { - if resolved, err := filepath.EvalSymlinks(found); err == nil && resolved == self { - return "logos", "" - } - } - } - // Asked of the real path, before the quoting below rewrites it. - pinned, dir := ourPin(self), filepath.Dir(self) - // Quoted for both hints, not just the last one: a home directory with a - // space in it is exactly where a command gets pasted and splits in two. - // The directory needs it as much as the binary — it is the argument of the - // other hint, and it is the half that carries the user's name. - self, dir = shellQuote(self), shellQuote(dir) - // setup's own copy is where it is on purpose: the hosts are wired to it and - // the plugin's resolver searches that directory. Telling the user to move - // it would break both, so the fix is to put the directory on PATH. - if pinned { - return self, fmt.Sprintf("add %s to your PATH to type `logos`", dir) - } - // The hosts setup just wired launch this exact path, so moving the file - // breaks every one of them unless setup rewires them to where it went. - return self, "logos is not on your PATH — to type `logos`, move it into a directory that is (for example ~/.local/bin), then run `logos setup` again: the hosts are wired to where it is now" -} - -// probeTarget is what the integration check launches, which is deliberately not -// always what the hosts launch. -// -// Under npx the wired command is `npx -y @noeton/logos mcp serve`, and running -// that here would make `logos doctor` fetch the package whenever npm's cache has -// been pruned — an egress from a command that promises nothing leaves the -// machine, and slow enough that the probe's ten-second handshake deadline -// expires first, reporting a perfectly healthy install as broken. The server -// binary is identical either way; npx only adds the fetch. So probe this binary -// and say out loud that the wired command differs, rather than quietly claiming -// to have tried it. -func probeTarget(self string, srv setup.Server) (bin string, args []string, note string) { - // Only npx's launcher fetches; an absolute path (this binary, or Homebrew's - // opt link to it) is probed as written. - if launchesThroughNpx(srv) { - return self, []string{"mcp", "serve"}, - fmt.Sprintf("probed this binary; hosts launch `%s %s`, which resolves the same server on demand", - srv.Bin, strings.Join(srv.Args, " ")) - } - return srv.Bin, srv.Args, "" -} - -// hostOS is runtime.GOOS, a variable so the Windows launcher can be tested on -// the machines this suite actually runs on. -var hostOS = runtime.GOOS - -// npxServer is the command a host runs to resolve logos through npx. On -// Windows npx is npx.cmd, a batch file, and a host that spawns "npx" directly -// fails to start it with nothing in its log pointing at why; cmd /c is how -// Windows runs a batch file, and is what Claude Code's docs prescribe (#21). -func npxServer(env map[string]string) setup.Server { - args := []string{"-y", "@noeton/logos", "mcp", "serve"} - if hostOS == "windows" { - return setup.Server{Bin: "cmd", Args: append([]string{"/c", "npx"}, args...), Env: env} - } - return setup.Server{Bin: "npx", Args: args, Env: env} -} - -// launchesThroughNpx reports whether srv is npxServer's launcher, on either OS. -func launchesThroughNpx(srv setup.Server) bool { - return srv.Bin == "npx" || (srv.Bin == "cmd" && len(srv.Args) > 1 && srv.Args[0] == "/c" && srv.Args[1] == "npx") -} - -// serverFor is the decision logosServer makes, separated from finding this -// process's own path so it can be tested for a binary this test run is not -// executing from. -// -// The README's own install line is `npx -y @noeton/logos setup`, and under npx -// the binary lives in a cache directory npm prunes. Writing that path into a -// host config produces the worst shape of failure this product has: setup says -// "Working", and weeks later the host fails to launch a binary that is simply -// gone, with nothing tying it back to the install. npx resolves a copy on -// demand, so name the command instead of the file — which is also the config -// npm/README.md tells people to write by hand, "portable between machines, -// which an absolute binary path is not". -func serverFor(bin, vault string) setup.Server { - // Absolute, and always written: a host launches the server from a directory - // nobody chose, and a relative vault would silently resolve somewhere the - // user will never look. - env := map[string]string{"LOGOS_VAULT": vault} - if selfupdate.DetectInstall(bin) == selfupdate.NPX { - return npxServer(env) - } - // Under Homebrew bin is the versioned Cellar path, which `brew upgrade` - // deletes; the opt link follows upgrades. - if stable := selfupdate.HomebrewStablePath(bin); stable != "" { - bin = stable - } - return setup.Server{Bin: bin, Args: []string{"mcp", "serve"}, Env: env} -} - -// resolvedVault is the vault --print-config and --config act on: an explicit -// --vault, falling back to the one this machine already has configured. Never -// created here — printing or merging a config is not the step that brings a -// vault into existence, and doing so behind a flag whose whole point is "just -// show me / just write this" would be the same silent-vault-creation mistake -// chooseVault's own doc comment already explains. -func resolvedVault(args []string) (string, error) { - v := flagStr(args, "--vault", "") - if v == "" { - v = vaultPath() - } - return filepath.Abs(expandHome(v)) -} - -// printConfigCmd is `logos setup --print-config`: the server block by hand, -// for an MCP client that is not one of the four Hosts() knows how to find or -// register. Those clients are real — MCP has more of them than this package -// will ever special-case — and until this existed, the only answer for their -// users was silence. -func printConfigCmd(args []string) error { - vault, err := resolvedVault(args) - if err != nil { - return err - } - srv, err := logosServer(vault) - if err != nil { - return err - } - out, err := setup.RenderConfig(srv, flagStr(args, "--format", "")) - if err != nil { - return err - } - fmt.Print(out) - return nil -} - -// configFileCmd is `logos setup --config `: merge logos into a config -// file at a location logos has no built-in convention for, reusing the exact -// merge (parse-before-touch, backup-before-write, no-op-writes-nothing) -// mergeJSON already gives Claude Desktop and Cursor. -func configFileCmd(args []string, path string) error { - vault, err := resolvedVault(args) - if err != nil { - return err - } - srv, err := logosServer(vault) - if err != nil { - return err - } - abs, err := filepath.Abs(expandHome(path)) - if err != nil { - return err - } - outcome, err := setup.MergeFile(abs, srv) - if err != nil { - return err - } - fmt.Printf(" %-16s %s (%s)\n", "config", outcome, abs) - return nil -} - func wireHosts(vault string, opts wireOpts) error { // --no-hosts is for someone evaluating logos, or setting up a second vault // on a machine that already has one wired. Until it existed the only way to @@ -1586,116 +822,6 @@ func normalizeFlags(args, valueFlags, boolFlags []string) ([]string, error) { return out, nil } -// mcpInstallCmd is the wiring on its own, for someone who already has a vault. -func mcpInstallCmd(args []string) error { - if hasFlag(args, "--help") || hasFlag(args, "-h") { - fmt.Print(setupUsage) - return nil - } - args, err := normalizeSetupFlags(args) - if err != nil { - return err - } - vault := flagStr(args, "--vault", "") - if vault == "" { - vault = vaultPath() - } - abs, err := filepath.Abs(expandHome(vault)) - if err != nil { - return err - } - if _, err := os.Stat(abs); err != nil { - return fmt.Errorf("vault not found at %s — run `logos setup` first, or pass --vault", abs) - } - return wireHosts(abs, wireOptsFrom(args)) -} - -// mcpUninstallCmd is `logos mcp uninstall [--host NAME]`, the way back out of -// install. It edits host configs and nothing else: the vault is the user's -// memory, so where it was left is said and deleting it stays their call. -func mcpUninstallCmd(args []string) error { - args, err := normalizeFlags(args, uninstallValueFlags, uninstallBoolFlags) - if err != nil { - return err - } - known := detectHosts() - names := flagStrs(args, "--host") - hosts, unmatched := setup.Only(known, names) - if len(unmatched) > 0 { - return fmt.Errorf("unknown host %s — logos knows: %s", - strings.Join(unmatched, ", "), strings.Join(setup.Names(known), ", ")) - } - // The plugin goes with Claude Code and only with it: `--host cursor` is not - // a run that should take Claude Code's plugin out from under it. - pluginGoing := hasHost(hosts, "Claude Code") && setup.LogosPluginRecord().Installed - going := setup.Names(hosts) - if pluginGoing { - going = append(going, "the Claude Code plugin") - } - // Unwiring more than one thing at a time is asked about, the way wiring them - // is: `--host` matches on a prefix and a mistyped flag used to mean every - // host, so the run that takes logos off the whole machine says what it is - // about to remove before it does it. - if len(going) > 1 && !hasFlag(args, "--yes") && !hasFlag(args, "-y") { - fmt.Printf(" %-*s %s\n", hostColumn, "hosts", strings.Join(going, ", ")) - if !confirm(" Remove logos from all of them?") { - fmt.Println(" nothing was removed") - return nil - } - } - failed := 0 - removals := setup.Uninstall(hosts) - if len(removals) == 0 { - // About the selection, not the machine: "nothing was removed" for a - // host the user named reads as "already clean", and they leave an entry - // in place that is still there. - if len(names) > 0 { - fmt.Printf(" %-*s %s is not installed here (found: %s), so nothing was removed\n", hostColumn, "hosts", - strings.Join(setup.Names(hosts), ", "), strings.Join(setup.Names(setup.Detected(known)), ", ")) - } else { - fmt.Printf(" %-*s none of the hosts logos knows are installed here, so nothing was removed\n", hostColumn, "hosts") - } - } - for _, r := range removals { - switch { - case r.Err != nil: - failed++ - fmt.Printf(" %-*s failed: %v\n", hostColumn, r.Host, r.Err) - case len(r.Removed) == 0: - fmt.Printf(" %-*s not registered\n", hostColumn, r.Host) - default: - fmt.Printf(" %-*s removed %s (%s)\n", hostColumn, r.Host, strings.Join(r.Removed, " and "), r.Where) - } - // Invariant 3: a hook removed silently is one the user keeps looking for. - if r.Unhooked { - fmt.Printf(" %-*s and its session-start hook\n", hostColumn, "") - } - if r.Backup != "" { - fmt.Printf(" %-*s backup of the old config: %s\n", hostColumn, "", r.Backup) - } - } - // The plugin carries its own server, which no host config holds, so it is - // removed through claude's own CLI rather than left running. - if pluginGoing { - switch { - case !setup.SupportsPluginCommands(): - fmt.Printf("\n %-*s still installed — this claude cannot remove plugins from the command line, so remove it in Claude Code with /plugin uninstall logos@logos\n", hostColumn, "plugin") - default: - if err := setup.RunPluginSteps(setup.UninstallPluginSteps()); err != nil { - failed++ - fmt.Printf("\n %-*s could not be removed: %v — remove it in Claude Code with /plugin uninstall logos@logos\n", hostColumn, "plugin", err) - } else { - fmt.Printf("\n %-*s uninstalled with `%s`\n", hostColumn, "plugin", setup.PluginCommand(setup.UninstallPluginSteps()[0])) - } - } - } - fmt.Printf("\n %-*s left untouched at %s — delete it yourself if you want the memory gone too\n", hostColumn, "vault", vaultPath()) - if failed > 0 { - return fmt.Errorf("%d host(s) could not be cleaned — see above", failed) - } - return nil -} - // offerActivityRecording asks, once, whether this vault should keep the // activity log — and asks it here because here is where the hooks that write it // have just been installed. The question is meaningless before that and @@ -1791,248 +917,6 @@ func expandHome(path string) string { return path } -// otherLogosEntries names the host's registrations, other than the one setup -// just wrote, that also start logos — matched the way doctor's duplicate -// check matches them, so the two never disagree about what counts. -func otherLogosEntries(h setup.Host) []string { - if h.List == nil { - return nil - } - regs, err := h.List() - if err != nil { - return nil - } - var names []string - for _, r := range regs { - if r.Name == setup.Name { - continue - } - if strings.Contains(r.Command, "mcp serve") || strings.HasPrefix(r.Name, "plugin:logos:") { - names = append(names, r.Name) - } - } - sort.Strings(names) - return names -} - -// offerPathCopy offers to copy a logos that cannot be typed into ~/.local/bin, -// and returns where the copy goes for the hosts to be wired to. "" means wire -// this binary where it stands — the offer was declined, refused, or never -// needed. The copy itself is made at the one place that makes it, alongside -// npx's, so the version guard there covers this route too. -// -// Only asked when the binary is not reachable as `logos`: an install that is -// already on PATH has nothing to move. Excluded are npx, because the caller has -// its own copy to make on a different reason; `go run`, because Go deletes that -// file on exit and setup refuses it a few lines further down anyway; and -// Homebrew's and npm's own installs, because a copy of a managed install is -// frozen at today's version and sits ahead of its manager on PATH, so the hosts -// launch the one logos `brew upgrade` and `npm update -g` can never reach — -// the trap #83 is about. -func offerPathCopy(yes, dryRun bool) (dst, src string) { - self, err := selfPath() - if err != nil || inGoBuildDir(self) { - return "", "" - } - switch selfupdate.DetectInstall(self) { - case selfupdate.NPX, selfupdate.Homebrew, selfupdate.NPMManaged: - return "", "" - } - if _, hint := terminalCommand(self); hint == "" { - return "", "" - } - dst, err = pinnedBinary() - if err != nil { - return "", "" - } - // Setup run from the copy it made earlier, with that directory still not on - // PATH, reaches here about the file it is already running as — and offered - // to copy it onto itself. The hint terminalCommand gave is the right one; - // there is simply nothing to copy. - if dst == self { - return "", "" - } - fmt.Printf("\n logos %s is not on your PATH, so `logos` is not a command yet\n", self) - // A plan that leaves out the one file the run creates is not the plan: the - // roster used to show the hosts pointed at ~/Downloads with nothing saying - // a real run writes a binary into ~/.local/bin and wires them there. The - // destination is returned under --dry-run too, so the roster names the - // binary a real run would wire; the copy itself is behind the dry-run - // return further up, and is not made. - if dryRun { - fmt.Printf(" a real run offers to copy it to %s and wire the hosts to the copy\n", dst) - return dst, self - } - if !yes && !confirm(fmt.Sprintf(" copy it to %s and wire the hosts to the copy?", dst)) { - return "", "" - } - return dst, self -} - -// pinnedBinary is where an npx setup keeps its copy of logos. -func pinnedBinary() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - name := "logos" - if runtime.GOOS == "windows" { - name += ".exe" - } - return filepath.Join(home, ".local", "bin", name), nil -} - -// pinBinary copies self to dst. Re-running setup through npx refreshes the -// copy, but a file there that is not logos belongs to someone else and is left -// alone. The copy is written beside dst and renamed over it, so a host -// starting mid-copy never launches half a binary. -func pinBinary(self, dst string) error { - if _, err := os.Stat(dst); err == nil && !runsAsLogos(dst) { - return fmt.Errorf("%s already exists and is not logos", dst) - } - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - return err - } - data, err := os.ReadFile(self) - if err != nil { - return err - } - tmp := fmt.Sprintf("%s.tmp-%d", dst, os.Getpid()) - if err := os.WriteFile(tmp, data, 0o755); err != nil { - os.Remove(tmp) - return err - } - if err := os.Rename(tmp, dst); err != nil { - os.Remove(tmp) - return err - } - return writePinReceipt(dst) -} - -// The receipt beside the copy, naming it. Nothing on disk used to say that the -// logos in ~/.local/bin was setup's own doing, so a later setup could neither -// prefer the install that replaced it nor offer to clear it away — it could -// only tell the user about a file and leave them to judge whose it was (#83). -func pinReceipt() (string, error) { - dst, err := pinnedBinary() - if err != nil { - return "", err - } - return filepath.Join(filepath.Dir(dst), ".logos-pin"), nil -} - -func writePinReceipt(pin string) error { - path, err := pinReceipt() - if err != nil { - return err - } - return os.WriteFile(path, []byte(pin+"\n"), 0o644) -} - -// ourPin reports whether the logos at path is the copy setup made. A logos -// somebody else put in that directory has no receipt, and is not setup's to -// prefer against, replace or remove. -func ourPin(path string) bool { - receipt, err := pinReceipt() - if err != nil { - return false - } - data, err := os.ReadFile(receipt) - if err != nil { - return false - } - return strings.TrimSpace(string(data)) == path -} - -// offerPinRemoval offers to take back the copy setup pinned into ~/.local/bin, -// once a managed install — Homebrew's or npm's — is the one running setup. -// That copy is ahead of both on PATH, so leaving it there is what makes `brew -// upgrade` and `logos update` reach an install no host launches. Only offered, -// never assumed: --yes is not an answer to a question about deleting a file. -func offerPinRemoval(self string) { - switch selfupdate.DetectInstall(self) { - case selfupdate.Homebrew, selfupdate.NPMManaged: - default: - return - } - dst, err := pinnedBinary() - if err != nil || dst == self || !ourPin(dst) { - return - } - if !confirm(fmt.Sprintf(" → remove %s, the copy setup pinned there?", dst)) { - return - } - if err := os.Remove(dst); err != nil { - fmt.Printf(" could not remove %s: %v\n", dst, err) - return - } - if receipt, err := pinReceipt(); err == nil { - os.Remove(receipt) - } - fmt.Printf(" removed %s\n", dst) -} - -// runsAsLogos is the resolver's test in plugin/bin/resolve.sh: every logos -// answers --version with "logos …". Bounded, because the file being asked -// may be any program at all. -func runsAsLogos(path string) bool { - _, ok := logosVersion(path) - return ok -} - -// logosVersion is the version a logos at path reports, from the same -// `--version` answer runsAsLogos trusts. -func logosVersion(path string) (string, bool) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - out, err := exec.CommandContext(ctx, path, "--version").Output() - if err != nil || !strings.HasPrefix(string(out), "logos ") { - return "", false - } - fields := strings.Fields(string(out)) - if len(fields) < 2 { - return "", true - } - return fields[1], true -} - -// newerRelease reports whether a is a later release than b. Anything that is -// not a plain major.minor.patch — a dev build, an empty answer — compares as -// not newer, so an unreadable version never blocks a copy. -func newerRelease(a, b string) bool { - pa, okA := releaseParts(a) - pb, okB := releaseParts(b) - if !okA || !okB { - return false - } - for i := range pa { - if pa[i] != pb[i] { - return pa[i] > pb[i] - } - } - return false -} - -func releaseParts(v string) ([3]int, bool) { - var parts [3]int - v = strings.TrimPrefix(v, "v") - if i := strings.IndexAny(v, "-+"); i >= 0 { - v = v[:i] - } - fields := strings.Split(v, ".") - if len(fields) != 3 { - return parts, false - } - for i, f := range fields { - n, err := strconv.Atoi(f) - if err != nil || n < 0 { - return parts, false - } - parts[i] = n - } - return parts, true -} - // hasHost says whether the roster about to be wired includes a host by name. func hasHost(hosts []setup.Host, name string) bool { for _, h := range hosts { diff --git a/cmd/logos/setup_binary.go b/cmd/logos/setup_binary.go new file mode 100644 index 0000000..1e03398 --- /dev/null +++ b/cmd/logos/setup_binary.go @@ -0,0 +1,288 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/Coder8124/logos/internal/selfupdate" +) + +// goRunBinary reports a binary inside a go-build directory, where `go run` +// puts the executable it removes on exit. A `.test` binary lives there too, +// but it is this package's tests calling setup, not a person wiring hosts. +func goRunBinary(bin string) bool { + if strings.HasSuffix(bin, ".test") || strings.HasSuffix(bin, ".test.exe") { + return false + } + return inGoBuildDir(bin) +} + +// inGoBuildDir reports a binary Go built into its own temp tree, `go run`'s and +// the test binary's alike. Neither is an install, so neither is something to +// copy onto a PATH and wire hosts to. +func inGoBuildDir(bin string) bool { + for _, part := range strings.Split(filepath.ToSlash(bin), "/") { + if strings.HasPrefix(part, "go-build") { + return true + } + } + return false +} + +// looksLikeSourceTree reports a directory holding a Go or npm project and no +// Logos history. A vault with sessions or memories is a vault whatever else is +// in it; a plain git repository is not enough, since notes vaults are often +// kept in git. +func looksLikeSourceTree(dir string) bool { + for _, d := range []string{"sessions", "memories"} { + if _, err := os.Stat(filepath.Join(dir, d)); err == nil { + return false + } + } + for _, f := range []string{"go.mod", "package.json"} { + if _, err := os.Stat(filepath.Join(dir, f)); err == nil { + return true + } + } + return false +} + +// executable is os.Executable, replaceable so a test can be a binary running +// from npm's npx cache. +var executable = os.Executable + +func selfPath() (string, error) { + bin, err := executable() + if err != nil { + return "", fmt.Errorf("could not find my own path, which the host config needs: %w", err) + } + if resolved, err := filepath.EvalSymlinks(bin); err == nil { + bin = resolved + } + return bin, nil +} + +// offerPathCopy offers to copy a logos that cannot be typed into ~/.local/bin, +// and returns where the copy goes for the hosts to be wired to. "" means wire +// this binary where it stands — the offer was declined, refused, or never +// needed. The copy itself is made at the one place that makes it, alongside +// npx's, so the version guard there covers this route too. +// +// Only asked when the binary is not reachable as `logos`: an install that is +// already on PATH has nothing to move. Excluded are npx, because the caller has +// its own copy to make on a different reason; `go run`, because Go deletes that +// file on exit and setup refuses it a few lines further down anyway; and +// Homebrew's and npm's own installs, because a copy of a managed install is +// frozen at today's version and sits ahead of its manager on PATH, so the hosts +// launch the one logos `brew upgrade` and `npm update -g` can never reach — +// the trap #83 is about. +func offerPathCopy(yes, dryRun bool) (dst, src string) { + self, err := selfPath() + if err != nil || inGoBuildDir(self) { + return "", "" + } + switch selfupdate.DetectInstall(self) { + case selfupdate.NPX, selfupdate.Homebrew, selfupdate.NPMManaged: + return "", "" + } + if _, hint := terminalCommand(self); hint == "" { + return "", "" + } + dst, err = pinnedBinary() + if err != nil { + return "", "" + } + // Setup run from the copy it made earlier, with that directory still not on + // PATH, reaches here about the file it is already running as — and offered + // to copy it onto itself. The hint terminalCommand gave is the right one; + // there is simply nothing to copy. + if dst == self { + return "", "" + } + fmt.Printf("\n logos %s is not on your PATH, so `logos` is not a command yet\n", self) + // A plan that leaves out the one file the run creates is not the plan: the + // roster used to show the hosts pointed at ~/Downloads with nothing saying + // a real run writes a binary into ~/.local/bin and wires them there. The + // destination is returned under --dry-run too, so the roster names the + // binary a real run would wire; the copy itself is behind the dry-run + // return further up, and is not made. + if dryRun { + fmt.Printf(" a real run offers to copy it to %s and wire the hosts to the copy\n", dst) + return dst, self + } + if !yes && !confirm(fmt.Sprintf(" copy it to %s and wire the hosts to the copy?", dst)) { + return "", "" + } + return dst, self +} + +// pinnedBinary is where an npx setup keeps its copy of logos. +func pinnedBinary() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + name := "logos" + if runtime.GOOS == "windows" { + name += ".exe" + } + return filepath.Join(home, ".local", "bin", name), nil +} + +// pinBinary copies self to dst. Re-running setup through npx refreshes the +// copy, but a file there that is not logos belongs to someone else and is left +// alone. The copy is written beside dst and renamed over it, so a host +// starting mid-copy never launches half a binary. +func pinBinary(self, dst string) error { + if _, err := os.Stat(dst); err == nil && !runsAsLogos(dst) { + return fmt.Errorf("%s already exists and is not logos", dst) + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + data, err := os.ReadFile(self) + if err != nil { + return err + } + tmp := fmt.Sprintf("%s.tmp-%d", dst, os.Getpid()) + if err := os.WriteFile(tmp, data, 0o755); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, dst); err != nil { + os.Remove(tmp) + return err + } + return writePinReceipt(dst) +} + +// The receipt beside the copy, naming it. Nothing on disk used to say that the +// logos in ~/.local/bin was setup's own doing, so a later setup could neither +// prefer the install that replaced it nor offer to clear it away — it could +// only tell the user about a file and leave them to judge whose it was (#83). +func pinReceipt() (string, error) { + dst, err := pinnedBinary() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(dst), ".logos-pin"), nil +} + +func writePinReceipt(pin string) error { + path, err := pinReceipt() + if err != nil { + return err + } + return os.WriteFile(path, []byte(pin+"\n"), 0o644) +} + +// ourPin reports whether the logos at path is the copy setup made. A logos +// somebody else put in that directory has no receipt, and is not setup's to +// prefer against, replace or remove. +func ourPin(path string) bool { + receipt, err := pinReceipt() + if err != nil { + return false + } + data, err := os.ReadFile(receipt) + if err != nil { + return false + } + return strings.TrimSpace(string(data)) == path +} + +// offerPinRemoval offers to take back the copy setup pinned into ~/.local/bin, +// once a managed install — Homebrew's or npm's — is the one running setup. +// That copy is ahead of both on PATH, so leaving it there is what makes `brew +// upgrade` and `logos update` reach an install no host launches. Only offered, +// never assumed: --yes is not an answer to a question about deleting a file. +func offerPinRemoval(self string) { + switch selfupdate.DetectInstall(self) { + case selfupdate.Homebrew, selfupdate.NPMManaged: + default: + return + } + dst, err := pinnedBinary() + if err != nil || dst == self || !ourPin(dst) { + return + } + if !confirm(fmt.Sprintf(" → remove %s, the copy setup pinned there?", dst)) { + return + } + if err := os.Remove(dst); err != nil { + fmt.Printf(" could not remove %s: %v\n", dst, err) + return + } + if receipt, err := pinReceipt(); err == nil { + os.Remove(receipt) + } + fmt.Printf(" removed %s\n", dst) +} + +// runsAsLogos is the resolver's test in plugin/bin/resolve.sh: every logos +// answers --version with "logos …". Bounded, because the file being asked +// may be any program at all. +func runsAsLogos(path string) bool { + _, ok := logosVersion(path) + return ok +} + +// logosVersion is the version a logos at path reports, from the same +// `--version` answer runsAsLogos trusts. +func logosVersion(path string) (string, bool) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, path, "--version").Output() + if err != nil || !strings.HasPrefix(string(out), "logos ") { + return "", false + } + fields := strings.Fields(string(out)) + if len(fields) < 2 { + return "", true + } + return fields[1], true +} + +// newerRelease reports whether a is a later release than b. Anything that is +// not a plain major.minor.patch — a dev build, an empty answer — compares as +// not newer, so an unreadable version never blocks a copy. +func newerRelease(a, b string) bool { + pa, okA := releaseParts(a) + pb, okB := releaseParts(b) + if !okA || !okB { + return false + } + for i := range pa { + if pa[i] != pb[i] { + return pa[i] > pb[i] + } + } + return false +} + +func releaseParts(v string) ([3]int, bool) { + var parts [3]int + v = strings.TrimPrefix(v, "v") + if i := strings.IndexAny(v, "-+"); i >= 0 { + v = v[:i] + } + fields := strings.Split(v, ".") + if len(fields) != 3 { + return parts, false + } + for i, f := range fields { + n, err := strconv.Atoi(f) + if err != nil || n < 0 { + return parts, false + } + parts[i] = n + } + return parts, true +} diff --git a/cmd/logos/setup_mcp.go b/cmd/logos/setup_mcp.go new file mode 100644 index 0000000..d151d87 --- /dev/null +++ b/cmd/logos/setup_mcp.go @@ -0,0 +1,332 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + + "github.com/Coder8124/logos/internal/selfupdate" + "github.com/Coder8124/logos/internal/setup" +) + +// logosServer is the command line and environment any host — known to +// setup.Hosts() or not — needs to reach this logos and this vault. Shared by +// wireHosts, --print-config and --config so that all three describe the exact +// same server; a hand-typed config that differs from what `logos setup` itself +// would have written is a bug users would have no way to notice. +func logosServer(vault string) (setup.Server, error) { + bin, err := selfPath() + if err != nil { + return setup.Server{}, err + } + return serverFor(bin, vault), nil +} + +// terminalCommand is how this install is reached from a shell, for the +// commands setup suggests, with a hint when that is not simply `logos`. Setup +// used to say "logos resume " regardless, and under npx, a source +// build or a release binary run from Downloads there is no logos on PATH. +func terminalCommand(self string) (cmd, hint string) { + switch selfupdate.DetectInstall(self) { + case selfupdate.NPX: + return "npx @noeton/logos", "for a `logos` command, run `npm i -g @noeton/logos`" + case selfupdate.NPMManaged: + // npm's logos is a node shim, not this file, so it cannot be compared + // by path; being on PATH is the whole question. + if _, err := exec.LookPath("logos"); err == nil { + return "logos", "" + } + default: + if found, err := exec.LookPath("logos"); err == nil { + if resolved, err := filepath.EvalSymlinks(found); err == nil && resolved == self { + return "logos", "" + } + } + } + // Asked of the real path, before the quoting below rewrites it. + pinned, dir := ourPin(self), filepath.Dir(self) + // Quoted for both hints, not just the last one: a home directory with a + // space in it is exactly where a command gets pasted and splits in two. + // The directory needs it as much as the binary — it is the argument of the + // other hint, and it is the half that carries the user's name. + self, dir = shellQuote(self), shellQuote(dir) + // setup's own copy is where it is on purpose: the hosts are wired to it and + // the plugin's resolver searches that directory. Telling the user to move + // it would break both, so the fix is to put the directory on PATH. + if pinned { + return self, fmt.Sprintf("add %s to your PATH to type `logos`", dir) + } + // The hosts setup just wired launch this exact path, so moving the file + // breaks every one of them unless setup rewires them to where it went. + return self, "logos is not on your PATH — to type `logos`, move it into a directory that is (for example ~/.local/bin), then run `logos setup` again: the hosts are wired to where it is now" +} + +// probeTarget is what the integration check launches, which is deliberately not +// always what the hosts launch. +// +// Under npx the wired command is `npx -y @noeton/logos mcp serve`, and running +// that here would make `logos doctor` fetch the package whenever npm's cache has +// been pruned — an egress from a command that promises nothing leaves the +// machine, and slow enough that the probe's ten-second handshake deadline +// expires first, reporting a perfectly healthy install as broken. The server +// binary is identical either way; npx only adds the fetch. So probe this binary +// and say out loud that the wired command differs, rather than quietly claiming +// to have tried it. +func probeTarget(self string, srv setup.Server) (bin string, args []string, note string) { + // Only npx's launcher fetches; an absolute path (this binary, or Homebrew's + // opt link to it) is probed as written. + if launchesThroughNpx(srv) { + return self, []string{"mcp", "serve"}, + fmt.Sprintf("probed this binary; hosts launch `%s %s`, which resolves the same server on demand", + srv.Bin, strings.Join(srv.Args, " ")) + } + return srv.Bin, srv.Args, "" +} + +// hostOS is runtime.GOOS, a variable so the Windows launcher can be tested on +// the machines this suite actually runs on. +var hostOS = runtime.GOOS + +// npxServer is the command a host runs to resolve logos through npx. On +// Windows npx is npx.cmd, a batch file, and a host that spawns "npx" directly +// fails to start it with nothing in its log pointing at why; cmd /c is how +// Windows runs a batch file, and is what Claude Code's docs prescribe (#21). +func npxServer(env map[string]string) setup.Server { + args := []string{"-y", "@noeton/logos", "mcp", "serve"} + if hostOS == "windows" { + return setup.Server{Bin: "cmd", Args: append([]string{"/c", "npx"}, args...), Env: env} + } + return setup.Server{Bin: "npx", Args: args, Env: env} +} + +// launchesThroughNpx reports whether srv is npxServer's launcher, on either OS. +func launchesThroughNpx(srv setup.Server) bool { + return srv.Bin == "npx" || (srv.Bin == "cmd" && len(srv.Args) > 1 && srv.Args[0] == "/c" && srv.Args[1] == "npx") +} + +// serverFor is the decision logosServer makes, separated from finding this +// process's own path so it can be tested for a binary this test run is not +// executing from. +// +// The README's own install line is `npx -y @noeton/logos setup`, and under npx +// the binary lives in a cache directory npm prunes. Writing that path into a +// host config produces the worst shape of failure this product has: setup says +// "Working", and weeks later the host fails to launch a binary that is simply +// gone, with nothing tying it back to the install. npx resolves a copy on +// demand, so name the command instead of the file — which is also the config +// npm/README.md tells people to write by hand, "portable between machines, +// which an absolute binary path is not". +func serverFor(bin, vault string) setup.Server { + // Absolute, and always written: a host launches the server from a directory + // nobody chose, and a relative vault would silently resolve somewhere the + // user will never look. + env := map[string]string{"LOGOS_VAULT": vault} + if selfupdate.DetectInstall(bin) == selfupdate.NPX { + return npxServer(env) + } + // Under Homebrew bin is the versioned Cellar path, which `brew upgrade` + // deletes; the opt link follows upgrades. + if stable := selfupdate.HomebrewStablePath(bin); stable != "" { + bin = stable + } + return setup.Server{Bin: bin, Args: []string{"mcp", "serve"}, Env: env} +} + +// resolvedVault is the vault --print-config and --config act on: an explicit +// --vault, falling back to the one this machine already has configured. Never +// created here — printing or merging a config is not the step that brings a +// vault into existence, and doing so behind a flag whose whole point is "just +// show me / just write this" would be the same silent-vault-creation mistake +// chooseVault's own doc comment already explains. +func resolvedVault(args []string) (string, error) { + v := flagStr(args, "--vault", "") + if v == "" { + v = vaultPath() + } + return filepath.Abs(expandHome(v)) +} + +// printConfigCmd is `logos setup --print-config`: the server block by hand, +// for an MCP client that is not one of the four Hosts() knows how to find or +// register. Those clients are real — MCP has more of them than this package +// will ever special-case — and until this existed, the only answer for their +// users was silence. +func printConfigCmd(args []string) error { + vault, err := resolvedVault(args) + if err != nil { + return err + } + srv, err := logosServer(vault) + if err != nil { + return err + } + out, err := setup.RenderConfig(srv, flagStr(args, "--format", "")) + if err != nil { + return err + } + fmt.Print(out) + return nil +} + +// configFileCmd is `logos setup --config `: merge logos into a config +// file at a location logos has no built-in convention for, reusing the exact +// merge (parse-before-touch, backup-before-write, no-op-writes-nothing) +// mergeJSON already gives Claude Desktop and Cursor. +func configFileCmd(args []string, path string) error { + vault, err := resolvedVault(args) + if err != nil { + return err + } + srv, err := logosServer(vault) + if err != nil { + return err + } + abs, err := filepath.Abs(expandHome(path)) + if err != nil { + return err + } + outcome, err := setup.MergeFile(abs, srv) + if err != nil { + return err + } + fmt.Printf(" %-16s %s (%s)\n", "config", outcome, abs) + return nil +} + +// mcpInstallCmd is the wiring on its own, for someone who already has a vault. +func mcpInstallCmd(args []string) error { + if hasFlag(args, "--help") || hasFlag(args, "-h") { + fmt.Print(setupUsage) + return nil + } + args, err := normalizeSetupFlags(args) + if err != nil { + return err + } + vault := flagStr(args, "--vault", "") + if vault == "" { + vault = vaultPath() + } + abs, err := filepath.Abs(expandHome(vault)) + if err != nil { + return err + } + if _, err := os.Stat(abs); err != nil { + return fmt.Errorf("vault not found at %s — run `logos setup` first, or pass --vault", abs) + } + return wireHosts(abs, wireOptsFrom(args)) +} + +// mcpUninstallCmd is `logos mcp uninstall [--host NAME]`, the way back out of +// install. It edits host configs and nothing else: the vault is the user's +// memory, so where it was left is said and deleting it stays their call. +func mcpUninstallCmd(args []string) error { + args, err := normalizeFlags(args, uninstallValueFlags, uninstallBoolFlags) + if err != nil { + return err + } + known := detectHosts() + names := flagStrs(args, "--host") + hosts, unmatched := setup.Only(known, names) + if len(unmatched) > 0 { + return fmt.Errorf("unknown host %s — logos knows: %s", + strings.Join(unmatched, ", "), strings.Join(setup.Names(known), ", ")) + } + // The plugin goes with Claude Code and only with it: `--host cursor` is not + // a run that should take Claude Code's plugin out from under it. + pluginGoing := hasHost(hosts, "Claude Code") && setup.LogosPluginRecord().Installed + going := setup.Names(hosts) + if pluginGoing { + going = append(going, "the Claude Code plugin") + } + // Unwiring more than one thing at a time is asked about, the way wiring them + // is: `--host` matches on a prefix and a mistyped flag used to mean every + // host, so the run that takes logos off the whole machine says what it is + // about to remove before it does it. + if len(going) > 1 && !hasFlag(args, "--yes") && !hasFlag(args, "-y") { + fmt.Printf(" %-*s %s\n", hostColumn, "hosts", strings.Join(going, ", ")) + if !confirm(" Remove logos from all of them?") { + fmt.Println(" nothing was removed") + return nil + } + } + failed := 0 + removals := setup.Uninstall(hosts) + if len(removals) == 0 { + // About the selection, not the machine: "nothing was removed" for a + // host the user named reads as "already clean", and they leave an entry + // in place that is still there. + if len(names) > 0 { + fmt.Printf(" %-*s %s is not installed here (found: %s), so nothing was removed\n", hostColumn, "hosts", + strings.Join(setup.Names(hosts), ", "), strings.Join(setup.Names(setup.Detected(known)), ", ")) + } else { + fmt.Printf(" %-*s none of the hosts logos knows are installed here, so nothing was removed\n", hostColumn, "hosts") + } + } + for _, r := range removals { + switch { + case r.Err != nil: + failed++ + fmt.Printf(" %-*s failed: %v\n", hostColumn, r.Host, r.Err) + case len(r.Removed) == 0: + fmt.Printf(" %-*s not registered\n", hostColumn, r.Host) + default: + fmt.Printf(" %-*s removed %s (%s)\n", hostColumn, r.Host, strings.Join(r.Removed, " and "), r.Where) + } + // Invariant 3: a hook removed silently is one the user keeps looking for. + if r.Unhooked { + fmt.Printf(" %-*s and its session-start hook\n", hostColumn, "") + } + if r.Backup != "" { + fmt.Printf(" %-*s backup of the old config: %s\n", hostColumn, "", r.Backup) + } + } + // The plugin carries its own server, which no host config holds, so it is + // removed through claude's own CLI rather than left running. + if pluginGoing { + switch { + case !setup.SupportsPluginCommands(): + fmt.Printf("\n %-*s still installed — this claude cannot remove plugins from the command line, so remove it in Claude Code with /plugin uninstall logos@logos\n", hostColumn, "plugin") + default: + if err := setup.RunPluginSteps(setup.UninstallPluginSteps()); err != nil { + failed++ + fmt.Printf("\n %-*s could not be removed: %v — remove it in Claude Code with /plugin uninstall logos@logos\n", hostColumn, "plugin", err) + } else { + fmt.Printf("\n %-*s uninstalled with `%s`\n", hostColumn, "plugin", setup.PluginCommand(setup.UninstallPluginSteps()[0])) + } + } + } + fmt.Printf("\n %-*s left untouched at %s — delete it yourself if you want the memory gone too\n", hostColumn, "vault", vaultPath()) + if failed > 0 { + return fmt.Errorf("%d host(s) could not be cleaned — see above", failed) + } + return nil +} + +// otherLogosEntries names the host's registrations, other than the one setup +// just wrote, that also start logos — matched the way doctor's duplicate +// check matches them, so the two never disagree about what counts. +func otherLogosEntries(h setup.Host) []string { + if h.List == nil { + return nil + } + regs, err := h.List() + if err != nil { + return nil + } + var names []string + for _, r := range regs { + if r.Name == setup.Name { + continue + } + if strings.Contains(r.Command, "mcp serve") || strings.HasPrefix(r.Name, "plugin:logos:") { + names = append(names, r.Name) + } + } + sort.Strings(names) + return names +} diff --git a/cmd/logos/setup_models.go b/cmd/logos/setup_models.go new file mode 100644 index 0000000..9f50305 --- /dev/null +++ b/cmd/logos/setup_models.go @@ -0,0 +1,225 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "strings" + "time" + + "github.com/Coder8124/logos/internal/provider" + "github.com/Coder8124/logos/internal/router" +) + +// checkRuntime reports the local model runtime and offers to pull what is +// missing. A machine with no runtime hears nothing about one: lexical retrieval +// and the whole continuity surface need no model, and telling a coding-agent +// user to install Ollama made a tool that needs no configuring look like it did. +// dryRun turns every offer into a description. `--dry-run --yes` used to be a +// combination that downloaded models — several gigabytes, from a command whose +// last line says nothing was written. +func checkRuntime(yes, dryRun bool) { + found := provider.Discover() + if len(found) == 0 { + return + } + p := found[0].Provider + fmt.Printf(" runtime %s at %s\n", p.Name, p.BaseURL) + // Pulling is Ollama's /api/pull. Every other runtime answered it with a 404 + // after the user had already said yes, so they are told what to load instead. + canPull := p.Name == "Ollama" + + have := map[string]bool{} + for _, m := range found[0].Models { + have[m] = true + if base, _, ok := strings.Cut(m, ":"); ok { + have[base] = true + } + } + + // The embedding model and the chat tiers are asked about separately, because + // they are not the same decision and lumping them made the answer harder + // than it needed to be. + // + // T0 is 274MB and buys semantic search. T1 and T2 together are ~26GB and buy + // `ask`, `voice`, `presence` and the nightly rollup — none of which any MCP + // tool touches, so a coding agent needs none of it. Offering all three in one + // prompt asked people to download 26GB to get 274MB of product, with no way + // to say "just the useful one" and no sizes to judge by. + embed := env("LOGOS_EMBED", defaultEmbedModel) + fmt.Printf(" embedding %s %s\n", embed, tick(have[embed])) + if !have[embed] { + if !canPull { + fmt.Printf(" load %s in %s for semantic search — logos can only pull through Ollama\n", embed, p.Name) + } else if dryRun { + fmt.Printf(" would offer to pull %s (%s)\n", embed, modelSize(embed)) + } else if yes || confirm(fmt.Sprintf(" pull %s (%s)? adds semantic search", + embed, modelSize(embed))) { + pull(p.BaseURL, embed) + } else { + fmt.Println(" skipped; retrieval stays lexical, which still works") + } + } + + var chat []string + for _, want := range chatModels() { + if !have[want] { + chat = append(chat, want) + } + fmt.Printf(" model %s %s\n", want, tick(have[want])) + } + if len(chat) == 0 { + return + } + + // Default no, and say what declining costs. With the server no longer + // refusing to start without a runtime, "no" is a safe answer rather than a + // gamble — which is what makes stating the size honest rather than a scare. + fmt.Printf(" %s are optional (%s) — only `logos ask`, `voice`\n", + strings.Join(chat, " and "), totalSize(chat)) + fmt.Println(" and the nightly rollup use them. No MCP tool does.") + if !allModels(os.Args) { + fmt.Println(" skipped; pass --all-models to pull them") + return + } + if !canPull { + fmt.Printf(" load %s in %s — logos can only pull through Ollama\n", strings.Join(chat, " and "), p.Name) + return + } + if dryRun { + fmt.Printf(" would pull %s (%s)\n", strings.Join(chat, " and "), totalSize(chat)) + return + } + for _, m := range chat { + pull(p.BaseURL, m) + } +} + +// pull fetches one model, reporting either way. +func pull(baseURL, model string) { + fmt.Printf(" pulling %s … ", model) + // One updating line: a multi-gigabyte download that printed nothing until it + // finished could not be told apart from a hang. + last := -1 + progress := func(pct int) { + if pct != last { + last = pct + fmt.Printf("\r pulling %s … %d%% ", model, pct) + } + } + if err := pullModel(baseURL, model, progress); err != nil { + fmt.Printf("failed: %v\n", err) + return + } + fmt.Println("done") +} + +func allModels(args []string) bool { return hasFlag(args, "--all-models") } + +// modelSize is what a download actually costs, so "yes" is an informed answer. +// Approximate and clearly so — the exact figure depends on the quantisation the +// registry serves, and a rounded number a user can plan around beats a precise +// one that is wrong on their machine. +func modelSize(model string) string { + switch { + // The default only: a custom LOGOS_EMBED containing "embed" is not this size. + case strings.HasPrefix(model, "nomic-embed-text"): + return "~270 MB" + case strings.HasPrefix(model, "gemma3:4b"): + return "~3.3 GB" + case strings.HasPrefix(model, "qwen3"): + return "~23 GB" + default: + return "size unknown" + } +} + +func totalSize(models []string) string { + var known []string + for _, m := range models { + if s := modelSize(m); s != "size unknown" { + known = append(known, s) + } + } + if len(known) == 0 { + return "size unknown" + } + return strings.Join(known, " + ") +} + +// chatModels is the configured local chat tiers. Read from the router config +// rather than hard-coded, so setup offers what this install would actually use. +// +// Deliberately excludes the embedding model, which is a separate and much +// smaller decision — see checkRuntime. +func chatModels() []string { + cfg, err := router.Load(vaultPath()) + if err != nil { + return nil + } + var out []string + for _, t := range []router.Tier{router.T1, router.T2} { + if tc, ok := cfg.Tiers[t.String()]; ok && tc.Model != "" && tc.BaseURL == "" { + out = append(out, tc.Model) + } + } + return out +} + +func tick(ok bool) string { + if ok { + return "✓" + } + return "✗ missing" +} + +// pullTimeout bounds connecting to Ollama and waiting for it to start +// answering. Not the download: that streams for as long as the model takes. +var pullTimeout = 30 * time.Second + +// pullModel asks Ollama to fetch a model. The response streams progress as +// JSON lines, passed on as a percentage of the layer being downloaded. +func pullModel(baseURL, model string, progress func(pct int)) error { + // Ollama's native API sits alongside the OpenAI-compatible /v1 path. + root := strings.TrimSuffix(strings.TrimSuffix(baseURL, "/"), "/v1") + body, err := json.Marshal(map[string]string{"model": model}) + if err != nil { + return err + } + // The default client has no timeout, so an Ollama that accepted the + // connection and never answered held setup forever. + client := &http.Client{Transport: &http.Transport{ + DialContext: (&net.Dialer{Timeout: pullTimeout}).DialContext, + ResponseHeaderTimeout: pullTimeout, + }} + resp, err := client.Post(root+"/api/pull", "application/json", strings.NewReader(string(body))) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s", resp.Status) + } + sc := bufio.NewScanner(resp.Body) + sc.Buffer(make([]byte, 0, 64*1024), 1<<20) + for sc.Scan() { + var line struct { + Error string `json:"error"` + Total int64 `json:"total"` + Completed int64 `json:"completed"` + } + if json.Unmarshal(sc.Bytes(), &line) != nil { + continue + } + if line.Error != "" { + return fmt.Errorf("%s", line.Error) + } + if line.Total > 0 { + progress(int(line.Completed * 100 / line.Total)) + } + } + return sc.Err() +} diff --git a/cmd/logos/setup_vault.go b/cmd/logos/setup_vault.go new file mode 100644 index 0000000..54381da --- /dev/null +++ b/cmd/logos/setup_vault.go @@ -0,0 +1,315 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/Coder8124/logos/internal/health" + "github.com/Coder8124/logos/internal/index" + "github.com/Coder8124/logos/internal/provider" + "github.com/Coder8124/logos/internal/session" + "github.com/Coder8124/logos/internal/vault" +) + +// chooseVault resolves where the vault lives and, unless this is a dry run, +// makes sure it exists and is the one this machine remembers. +// +// created reports whether the directory was missing, so a dry run can say what +// it would have made without making it. recorded reports whether this vault was +// written down as the machine's, which is not the same question. +// +// A vault named only by LOGOS_VAULT is deliberately not recorded. LOGOS_VAULT +// is a per-process override — it is how the documented scratch-vault workflow +// works, and how an MCP host config pins one server to one vault — so treating +// it as a machine-wide choice means a single `setup` run against a throwaway +// directory silently repoints every front end at it. That shipped: a scratch +// vault under an agent's job directory became the recorded pointer, and because +// the directory still existed, Recorded() kept returning it. Every command, the +// MCP server and the SessionStart hook then read an empty vault and truthfully +// reported nothing, while twenty-eight checkpoints sat in ~/logos. --vault, and +// the default, are choices someone made; an inherited environment variable is +// not. +// recorded says whether this vault became the machine's recorded pointer, and +// why not when it did not. The reason is load-bearing: the three ways to end up +// unrecorded — the environment chose the vault, the write failed, or this was a +// dry run — need three different next steps, and reporting one of them for all +// three told a user whose config directory was unwritable to "pass --vault", +// which is exactly what they had just done. +type recordOutcome int + +const ( + recordedHere recordOutcome = iota // written down + recordSkipEnv // LOGOS_VAULT chose it, so it is this process only + recordFailed // the write was attempted and failed; the error is already printed + recordSkipTemp // a temporary directory, and nobody said to record it anyway + recordSkipMove // this machine already has a vault holding work, and nobody said to move it +) + +func chooseVault(args []string, dryRun bool) (dir string, created bool, rec recordOutcome, err error) { + dir = flagStr(args, "--vault", "") + fromEnv := false + if dir == "" { + if v := os.Getenv("LOGOS_VAULT"); v != "" { + // fromEnv means somebody named a vault for this one run. The host + // pin this process adopted is not that: it is the machine's own + // recorded choice arriving by another road, and treating it as + // per-process made setup inside a host record nothing. + dir, fromEnv = v, !vaultCameFromHostPin(v) + } else { + dir = vaultPath() // the recorded path, then ~/logos + } + } + abs, err := filepath.Abs(expandHome(dir)) + if err != nil { + return "", false, recordFailed, err + } + // Before anything is recorded: the pointer is machine-wide and outlives the + // run, and a file there left every host wired to a path that cannot hold a + // vault, with the only failure printed ten lines above a table of ticks. + // Usually a shell's doing — a tab-completion onto a neighbouring file, or an + // empty $VAR that made the next word the path — not anybody's choice. + if info, err := os.Stat(abs); err == nil && !info.IsDir() { + return "", false, recordFailed, fmt.Errorf("%s is a file, not a directory — pass --vault ; nothing was changed", abs) + } + if _, err := os.Stat(abs); os.IsNotExist(err) && flagStr(args, "--vault", "") == "" && !fromEnv && abs == vault.Pointer() { + // Nobody asked for this directory in this run; it is the recorded vault, + // and it is missing — usually an unmounted drive. Creating it makes an + // empty vault at the mount path. + return "", false, recordFailed, missingVaultError(abs) + } + if flagStr(args, "--vault", "") == "" && !fromEnv && looksLikeSourceTree(abs) { + // Nobody chose this directory; it is the default, and it is a project. + // `git clone …/logos` run in ~ lands exactly on ~/logos, and taking it + // indexes the repository's markdown as notes and puts the user's memory + // inside a tree `git clean` or a re-clone deletes. + return "", false, recordFailed, fmt.Errorf("%s looks like a source checkout, not a vault — pass --vault to choose where the vault goes", abs) + } + if _, err := os.Stat(abs); os.IsNotExist(err) { + created = true + if !dryRun { + // Private from the first mkdir. A vault created world-readable and + // tightened later is a vault that was world-readable for however long + // the user took to run `logos doctor`. + if err := vault.MkdirPrivate(abs); err != nil { + return "", false, recordFailed, fmt.Errorf("creating %s: %w", abs, err) + } + } + } + // A dry run reports the outcome the real run would reach, which under + // LOGOS_VAULT is "not recorded" — the one command whose whole job is + // previewing was promising the opposite of what followed. + // doctor fails a recorded vault that lives under a temp root, because it + // will be empty or gone. By then the pointer has already moved; setup is + // the one place the check can stop it, so a temporary directory is used + // for this run and recorded only when someone says so. + // + // --yes is not the answer to this one. It means "do not ask me questions", + // and a script that passed it was also silently repointing the whole machine + // at a scratch directory — the pointer is one file, and that is how it moved + // without anyone deciding to move it. Waiving the guard needs its own flag. + temp := !fromEnv && health.UnderTempDir(abs) && !hasFlag(args, "--record-temp") + yes := hasFlag(args, "--yes") || hasFlag(args, "-y") + if dryRun { + if fromEnv { + return abs, created, recordSkipEnv, nil + } + if temp { + return abs, created, recordSkipTemp, nil + } + if move, _, _ := movingLoadedVault(args, abs); move { + return abs, created, recordSkipMove, nil + } + return abs, created, recordedHere, nil + } + if fromEnv { + return abs, created, recordSkipEnv, nil + } + if temp { + fmt.Printf(" %s is a temporary directory — it will be empty or gone\n", abs) + // No terminal to ask, so the safe answer is taken and named: a run that + // silently did the dangerous thing is the bug being fixed here. + if yes { + fmt.Println(" not recording it — pass --record-temp to record it anyway") + return abs, created, recordSkipTemp, nil + } + if !confirmNo(" record it as this machine's vault anyway?") { + return abs, created, recordSkipTemp, nil + } + } + // Moving a vault that holds work is the one setup decision worth its own + // answer. The pointer is one file and `--vault B` rewrote it whether or not + // A held every checkpoint this machine has taken — announced afterwards, in + // the same receipt line as everything else. --yes does not answer this one + // either, for the reason above it. + if move, from, holds := movingLoadedVault(args, abs); move { + fmt.Printf(" this machine's vault is %s, and it holds %s\n", from, holds) + if yes { + fmt.Println(" not moving it — pass --move-vault to move it anyway") + return abs, created, recordSkipMove, nil + } + if !confirmNo(fmt.Sprintf(" make %s this machine's vault instead?", abs)) { + return abs, created, recordSkipMove, nil + } + } + // Write the choice down where a process with no shell can read it. A host + // launched from Finder, such as Claude Desktop, inherits no LOGOS_VAULT, so + // without this the server it starts can only find a vault at the default. + if err := vault.Record(abs); err != nil { + fmt.Printf(" could not record this vault for hosts started without LOGOS_VAULT: %v\n", err) + return abs, created, recordFailed, nil + } + return abs, created, recordedHere, nil +} + +// movingLoadedVault reports whether this run would repoint the machine away +// from a recorded vault that has checkpoints in it, names that vault, and says +// what is in it. An empty vault, or the one already recorded, is not a decision +// anybody needs to defend. +// +// The description is the part that makes the question answerable (#90). "It +// holds work" is true of a vault with one checkpoint and of a vault with a +// year of them, and the two deserve opposite answers — so the count is said +// before the prompt, not discovered afterwards by a resume that finds nothing. +func movingLoadedVault(args []string, abs string) (bool, string, string) { + if hasFlag(args, "--move-vault") { + return false, "", "" + } + prev := vault.Recorded() + if prev == "" || filepath.Clean(prev) == abs { + return false, "", "" + } + projects, err := session.Projects(prev) + if err != nil { + return false, "", "" + } + // A directory under sessions/ is not by itself work worth defending: it + // also exists for a project that has only working notes. Asking about one + // produced a warning whose own sentence said there was nothing to lose. + holding, checkpoints := vaultHolding(prev, projects) + if checkpoints == 0 { + return false, "", "" + } + return true, prev, holding +} + +// vaultHolding counts what would be left behind, in the terms the user names it +// in: checkpoints, and the projects they are filed under. A project directory +// that cannot be read counts as nothing rather than failing the move — this +// sentence exists to inform a decision, and refusing to describe the vault is a +// worse answer than describing the part of it that is readable. +func vaultHolding(prev string, projects []string) (string, int) { + checkpoints, held := 0, 0 + for _, p := range projects { + before := checkpoints + checkpoints += checkpointsUnder(filepath.Join(prev, session.CheckpointDir, p)) + // Counted the same way as the checkpoints, for the same reason: a + // project the user would be leaving nothing of is not one of the + // projects this sentence is warning them about. + if checkpoints > before { + held++ + } + } + return fmt.Sprintf("%d %s across %d %s", checkpoints, plural(checkpoints, "checkpoint"), held, plural(held, "project")), checkpoints +} + +// checkpointsUnder counts a project's checkpoints, including the ones a +// worktree keeps in its own subdirectory. +// +// One level down, not a full walk. A worktree scope is spelled +// "project/worktree" and session.Projects returns only the top level, so a +// vault whose work is all on branches counted zero and the "this vault holds +// work" prompt never appeared — setup repointed the machine away from it in +// silence. Two levels is the whole of the layout; recursing further would only +// find whatever else a user has put in their own directory. +func checkpointsUnder(dir string) int { + entries, err := os.ReadDir(dir) + if err != nil { + // A project directory that cannot be read counts as nothing rather + // than failing the move: this sentence exists to inform a decision, + // and refusing to describe the vault is worse than describing the + // part of it that is readable. + return 0 + } + n := 0 + for _, e := range entries { + if e.IsDir() { + n += worktreeCheckpoints(filepath.Join(dir, e.Name())) + continue + } + // The same predicate session.Read and doctor count with: a session + // directory also holds the project's working notes, and calling those + // a checkpoint overstates what the vault holds. + if session.IsCheckpointFile(e.Name()) { + n++ + } + } + return n +} + +// worktreeCheckpoints counts the checkpoint files directly inside one +// worktree's directory, and does not descend again. +func worktreeCheckpoints(dir string) int { + entries, err := os.ReadDir(dir) + if err != nil { + return 0 + } + n := 0 + for _, e := range entries { + if !e.IsDir() && session.IsCheckpointFile(e.Name()) { + n++ + } + } + return n +} + +// indexVault runs the first index so the vault is queryable immediately. +func indexVault(dir string) error { + // The same guard `logos index` runs, and the one that matters most here: + // setup is the path every user takes on day one, and `git init && git add + // -A` in a vault without it commits index.db and #88's activity log — every + // command and file path a host reported. + if wrote, err := vault.EnsureGitignore(dir); err != nil { + fmt.Printf(" index could not write .gitignore: %v\n", err) + } else if wrote { + fmt.Println(" index .gitignore now keeps .logos/ and activity/ out of git") + } + + // Returned, not printed and dropped: the caller goes on to wire every host + // to this vault, and a vault it could not index is not one to wire them to. + ix, err := index.Open(dir) + if err != nil { + return err + } + defer ix.Close() + + rep, err := ix.Sync() + if err != nil { + return err + } + // provider.Discover rather than findProvider: the latter prints a banner of + // its own, which would interrupt this report mid-table. + embedModel := env("LOGOS_EMBED", defaultEmbedModel) + if found := provider.Discover(); len(found) > 0 { + // Said before it starts: a large vault takes minutes to embed, and + // silence for that long reads as a hang with the hosts prompt stuck + // behind it. + var pending int + ix.DB.QueryRow(`SELECT COUNT(*) FROM notes n LEFT JOIN embeddings e ON e.slug = n.slug WHERE e.slug IS NULL`).Scan(&pending) + if pending > 0 { + fmt.Printf(" index embedding %d %s with %s — search already works without it…\n", pending, plural(pending, "note"), embedModel) + } + if _, err := ix.EmbedPending(found[0].Provider, embedModel, 32); err != nil { + fmt.Printf(" index embedding failed: %v — search is lexical until `logos index` succeeds\n", err) + } + ix.SyncMemories(found[0].Provider, embedModel) + } + notes, _ := ix.NoteCount() + edges, _ := ix.EdgeCount() + fmt.Printf(" index %d notes, %d edges", notes, edges) + if rep.Skipped > 0 { + fmt.Printf(" (%d skipped)", rep.Skipped) + } + fmt.Println() + return nil +} From 1c0bf0e6c9b8036dc00fbf9ec5867b737b754e08 Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 1 Oct 2026 17:24:54 -0700 Subject: [PATCH 08/16] main.go holds the command dispatch, with help, flag parsing, doctor and indexing each in a file of their own --- cmd/logos/doctor.go | 365 ++++++++++++++++ cmd/logos/flags.go | 196 +++++++++ cmd/logos/help.go | 224 ++++++++++ cmd/logos/index.go | 263 +++++++++++ cmd/logos/main.go | 1010 ------------------------------------------- 5 files changed, 1048 insertions(+), 1010 deletions(-) create mode 100644 cmd/logos/doctor.go create mode 100644 cmd/logos/flags.go create mode 100644 cmd/logos/help.go create mode 100644 cmd/logos/index.go diff --git a/cmd/logos/doctor.go b/cmd/logos/doctor.go new file mode 100644 index 0000000..3b96642 --- /dev/null +++ b/cmd/logos/doctor.go @@ -0,0 +1,365 @@ +package main + +import ( + "fmt" + "os" + "slices" + "strings" + + "github.com/Coder8124/logos/internal/buildinfo" + "github.com/Coder8124/logos/internal/health" + "github.com/Coder8124/logos/internal/index" + "github.com/Coder8124/logos/internal/mcpserver" + "github.com/Coder8124/logos/internal/provider" + "github.com/Coder8124/logos/internal/router" + "github.com/Coder8124/logos/internal/session" + "github.com/Coder8124/logos/internal/setup" +) + +func doctor(probe, verbose bool) error { + // The product first, the model plumbing second. This used to be the other + // way round — and in fact only ever reported the plumbing, so a vault that + // did not exist and an index a week stale both passed silently. + // + // It also used to return an error when no runtime answered, which made the + // one command a confused user reaches for refuse to run precisely when + // something was wrong. + rep := gatherHealth() + fmt.Println("─── logos ───") + // Width from the longest check name rather than a constant. "abandoned + // sessions" is eighteen characters and used to push its own state out of the + // column every other row lined up in, which reads as a rendering bug in the + // one command someone runs when they already suspect something is wrong. + w := 0 + for _, c := range rep.Checks { + if len(c.Name) > w { + w = len(c.Name) + } + } + for _, c := range leadWith(rep.Checks, "vault", "agent hosts", "continuity") { + fmt.Printf(" %-*s %s\n", w, c.Name, renderState(c.State)) + if c.Detail != "" { + fmt.Printf(" %-*s %s\n", w, "", c.Detail) + } + if c.Fix != "" { + fmt.Printf(" %-*s → %s\n", w, "", c.Fix) + } + } + ok, warn, failed, unknown := rep.Counts() + fmt.Printf("\n %d ok · %d to do · %d failed · %d unchecked\n", ok, warn, failed, unknown) + + // Runtimes, tiers and the web bridge are for `ask`, the rollup and the + // browser extension. No continuity tool uses them, and printed by default + // they were most of the report and made logos look like it needed a model. + if !verbose && !probe { + fmt.Println("\nrun `logos doctor --verbose` for the web bridge, model runtimes and tiers") + return doctorVerdict(failed) + } + + if mcpserver.HasToken(vaultPath()) { + fmt.Println("\nweb bridge: paired — `logos mcp serve --http` will reuse the existing token") + } else { + fmt.Println("\nweb bridge: not paired — `logos mcp serve --http` will mint a token on first run") + } + + found := provider.Resolve() + if len(found) == 0 && provider.Configured() != nil { + // The user named a runtime and it is down. The check above already + // failed on it; closing on "nothing depends on one" would tell them + // to ignore the one failure they asked for. + fmt.Printf("\nLOGOS_RUNTIME names %s, and it did not answer — search is lexical until it does.\n", provider.Configured().BaseURL) + return doctorVerdict(failed) + } + if len(found) == 0 { + // Not an error. Every continuity tool works without a model, and search + // falls back to lexical; the report above already said so. + fmt.Println("\nNo local model runtime — nothing above depends on one.") + return doctorVerdict(failed) + } + fmt.Println("\n─── runtimes ───") + for _, d := range found { + fmt.Printf("%s — %s\n", d.Provider.Name, d.Provider.BaseURL) + for _, m := range d.Models { + fmt.Printf(" %s\n", m) + } + } + + cfg, err := router.Load(vaultPath()) + if err != nil { + return err + } + rt, err := router.New(cfg, vaultPath()) + if err != nil { + return err + } + + fmt.Println("\n─── tiers ───") + for _, line := range rt.Available() { + fmt.Println(" ", line) + } + + if !probe { + fmt.Println("\nrun `logos doctor --probe` to verify each model actually loads") + return doctorVerdict(failed) + } + + // Listing a model proves nothing: a corrupt pull lists fine and fails on + // load. Probing is what catches it before a rollup does at 3am. + // + // And a model that fails to load counts towards the verdict, or the probe + // is the one check whose result nothing can act on: `failed` is totalled + // before this loop runs, so `logos doctor --probe && deploy` used to print + // FAILS TO LOAD in red and then exit 0 into the next command. + fmt.Println("\n─── probe ───") + for _, t := range []router.Tier{router.T1, router.T2} { + model, err := rt.Model(t) + if err != nil { + fmt.Printf(" %s %v\n", t, err) + continue + } + line, broken := probeRow(t, model, rt.Probe(model)) + fmt.Println(line) + if broken { + failed++ + } + } + return doctorVerdict(failed) +} + +// leadWith moves the named checks to the front, in that order, and keeps the +// rest as they were: what a coding-agent user runs doctor for is whether the +// vault is there, whether their agents are wired to it, and where the last +// session stopped. +func leadWith(checks []health.Check, names ...string) []health.Check { + out := make([]health.Check, 0, len(checks)) + for _, n := range names { + for _, c := range checks { + if c.Name == n { + out = append(out, c) + } + } + } + for _, c := range checks { + if !slices.Contains(names, c.Name) { + out = append(out, c) + } + } + return out +} + +// probeRow renders one probe result and says whether it counts as a failure. +// It is a function of its own so the verdict can be tested without a live model +// runtime: the bug it exists to stop — FAILS TO LOAD printed in the report while +// the command exits 0 — is only visible where the row and the count are decided +// together. +// +// A model that loads but ignores JSON schemas is not a failure. Every tier +// degrades to prose in that case, which is worse output, not a broken install. +func probeRow(t router.Tier, model string, cap router.Capability) (line string, failed bool) { + switch { + case !cap.Loads: + return fmt.Sprintf(" %s %-24s FAILS TO LOAD — %s", t, model, truncate(cap.Err, 70)), true + case !cap.StructuredOutput: + return fmt.Sprintf(" %s %-24s loads, but ignores JSON schemas", t, model), false + default: + return fmt.Sprintf(" %s %-24s ok, honours JSON schemas", t, model), false + } +} + +// doctorVerdict turns the report into an exit code. The rows already say what +// is wrong in words; this is for everything that reads the status instead — a +// pre-flight check, a CI step, a shell `&&`. An unchecked row is not a failure: +// doctor deliberately does not fail because no model runtime answered, since +// every continuity verb works without one. +func doctorVerdict(failed int) error { + if failed == 0 { + return nil + } + return fmt.Errorf("%d check(s) failed — see the report above", failed) +} + +// doctorIntegration is the difference between "logos is installed" and "your +// agents can reach this vault". It is the same probe setup runs, exposed so it +// can be re-run after a host update or a config edit. +func doctorIntegration() error { + vault := vaultPath() + // The same description setup writes into every host config, so this check + // launches what the hosts launch — under npx that is the `npx` command, not + // the cached binary this process happens to be running from. + srv, err := logosServer(vault) + if err != nil { + return err + } + + self, err := selfPath() + if err != nil { + return err + } + + // What the hosts have registered, not what this process happens to be + // running from (#89). Someone who moved the binary onto their PATH, as the + // end of setup told them to, left every host naming a file that is gone — + // and this check said "Working", because it rebuilt the command from the + // binary it found itself in. The question being asked is whether the + // agents can reach the vault, and only their own entries can answer it. + targets, unreadable := registeredTargets(vault, srv) + failed := len(unreadable) + for _, u := range unreadable { + fmt.Printf("─── integration ───\n host %s\n its registrations could not be read, so nothing here says whether it reaches this vault\n\n", u) + } + for i, t := range targets { + if i > 0 { + fmt.Println() + } + fmt.Printf("─── integration ───\n host %s\n binary %s %s\n vault %s\n", + t.host, t.srv.Bin, strings.Join(t.srv.Args, " "), t.vault) + probeBin, probeArgs, note := probeTarget(self, t.srv) + if note != "" { + fmt.Printf(" note %s\n", note) + } + fmt.Println() + for _, c := range integrationChecks(probeBin, probeArgs, t.vault) { + fmt.Printf(" %-12s %s\n", c.Name, renderState(c.State)) + if c.Detail != "" { + fmt.Printf(" %-12s %s\n", "", c.Detail) + } + if c.Fix != "" { + fmt.Printf(" %-12s → %s\n", "", c.Fix) + } + if c.State == health.Failed { + failed++ + } + } + } + if failed > 0 { + // Named by count, not by "no host": one unreadable config among several + // that read perfectly well sent the user to look for a permission + // problem that was not there. + if len(unreadable) > 0 { + return fmt.Errorf("%d host(s) could not say what they have registered", len(unreadable)) + } + return fmt.Errorf("integration is not working") + } + fmt.Println("\n Working. The hosts launching these commands reach this vault.") + return nil +} + +// probe is one command to launch and the vault it is expected to reach, named +// by whoever registered it. +type probe struct { + host string + srv setup.Server + vault string +} + +// registeredTargets is the logos entry each detected host actually holds, and +// separately the hosts that could not be asked. A host with no logos in its +// config contributes nothing — it is not wired, so there is no wiring to check. +// A host whose config cannot be read is not that: it is the question going +// unanswered, so it is returned to be reported rather than dropped. +// +// Falling back to the command setup would write is what makes this check usable +// on a machine with no host registered yet: without it, `doctor --integration` +// on a fresh install would have nothing to probe and would report success by +// having asked nothing. +func registeredTargets(vault string, srv setup.Server) ([]probe, []string) { + var out []probe + var unreadable []string + seen := map[string]bool{} + for _, h := range detectHosts() { + if h.List == nil || (h.Detect != nil && !h.Detect()) { + continue + } + regs, err := h.List() + if err != nil { + // A host that cannot say what it has registered is not a host with + // nothing registered. Swallowing this left the fallback probing + // the command setup would write and the check closing "Working", + // having failed to ask the only question it exists to ask. + unreadable = append(unreadable, fmt.Sprintf("%s: %v", h.Name, err)) + continue + } + for _, r := range regs { + if !strings.Contains(r.Command, "mcp serve") { + continue + } + // Split on spaces, which is how the command was joined. A binary + // path with a space in it is not reconstructed, and lands as a + // command that fails to launch — visibly, which is the point. + fields := strings.Fields(r.Command) + v := r.Vault + if v == "" { + v = vault + } + // Keyed by the vault as well as the command: two hosts commonly + // register the same binary against different vaults, and that + // split is the thing this check exists to catch. Keyed by command + // alone, the second host's vault was never probed. + key := r.Command + "\x00" + v + if len(fields) == 0 || seen[key] { + continue + } + seen[key] = true + out = append(out, probe{h.Name, setup.Server{Bin: fields[0], Args: fields[1:]}, v}) + } + } + if len(out) == 0 && len(unreadable) == 0 { + return []probe{{"none registered — probing what setup would write", srv, vault}}, nil + } + return out, unreadable +} + +// gatherHealth assembles what the checks need, tolerating every piece of it +// being missing. A vault that will not open, an index that is not there and a +// runtime that is not running each become Unknown rather than an early return — +// the point of the report is to work when things are broken. +func gatherHealth() health.Report { + vault := vaultPath() + in := health.Input{Vault: vault, EmbedModel: env("LOGOS_EMBED", defaultEmbedModel), Hosts: setup.Hosts(), Version: buildinfo.Version} + if self, err := selfPath(); err == nil { + in.Self = self + } + + // Stat before opening, because index.Open creates /.logos and that + // brings the vault itself into existence. Opening it here meant doctor made + // the vault it was about to check and then pronounced it healthy — the + // "does not exist" branch in checkVault could not fire from the CLI at all. + // A mistyped LOGOS_VAULT, or doctor run before setup, produced a second + // empty vault with a clean bill of health, which is exactly the "healthy + // zero of everything" that internal/vault/path.go exists to prevent. + // + // A vault that exists but has never been indexed is a different case, and + // index.Open creating .logos for that one is wanted. + if _, err := os.Stat(vault); err == nil { + if ix, err := index.Open(vault); err == nil { + defer ix.Close() + session.Init(ix.DB) // so the abandonment check reads a table rather than an error + in.DB = ix.DB + } + } + // Resolve, not Discover: doctor has to report the runtime the server will + // use, and with LOGOS_RUNTIME set that is never whatever is on localhost. + if found := provider.Resolve(); len(found) > 0 { + in.Runtime = found[0].Provider + } + in.Configured = os.Getenv("LOGOS_RUNTIME") + + return health.Run(in) +} + +func renderState(s health.State) string { + switch s { + case health.OK: + return "ok" + case health.Warn: + // Lower case and unshouted on purpose: this row is a chore waiting for + // the user, and rendering it the way a broken index is rendered is what + // made people stop reading the report. + return "to do" + case health.Failed: + return "FAILED" + default: + // Spelled out, because the whole point is that this is not "fine". + return "unchecked" + } +} diff --git a/cmd/logos/flags.go b/cmd/logos/flags.go new file mode 100644 index 0000000..120f36d --- /dev/null +++ b/cmd/logos/flags.go @@ -0,0 +1,196 @@ +package main + +import ( + "fmt" + "slices" + "strconv" + "strings" +) + +func hasFlag(args []string, name string) bool { + for _, a := range args { + if a == name { + return true + } + } + return false +} + +func flagInt(args []string, name string, def int) int { + for i, a := range args { + if a == name && i+1 < len(args) { + if v, err := strconv.Atoi(args[i+1]); err == nil { + return v + } + } + } + return def +} + +func joinArgs(a []string) string { return strings.Join(a, " ") } + +func parseID(args []string) int64 { + if len(args) >= 2 { + var id int64 + fmt.Sscan(args[1], &id) + return id + } + return 0 +} + +func firstNonFlag(args []string) string { + for i := 0; i < len(args); i++ { + if len(args[i]) >= 2 && args[i][:2] == "--" { + i++ // skip a flag's value too + continue + } + return args[i] + } + return "" +} + +// isFlagToken reports whether a word is another flag rather than a value, so a +// flag given with nothing after it falls back to its default instead of eating +// the next one. `--project --kind decision` recorded the project as "--kind" +// and said nothing; invariant 4 says a missing value is reported as missing. +// +// A lone "-" is a value: it is the conventional name for stdin. +func isFlagToken(a string) bool { + return strings.HasPrefix(a, "-") && a != "-" +} + +// flagSpec is every flag one command understands. Anything else that starts +// with -- is refused by name before the command runs: `note --agent A` filed +// the note under a project called "agent", `memory add --kind fact` stored the +// flag inside the fact, and `tried x --bogus` answered "nothing rules this out" +// — each with a success message. A single dash is left alone, because "-" is +// stdin and a note or project may legitimately start with one. +type flagSpec struct { + valued []string // take the next word as their value + numeric []string // valued, and a value that is given must be a positive whole number + // orDefault is numeric, except that 0 is accepted and asks for the default. + // Refusing it read as a broken command: --budget 0 is how a script says + // "whatever you normally use", and a pack with no budget is no pack at all. + orDefault []string + bare []string +} + +// commandFlags covers the commands whose flags were parsed by picking out the +// known ones and ignoring the rest. Commands with their own strict parser +// (checkpoint, setup, update, mcp install) are not listed. +var commandFlags = map[string]flagSpec{ + "version": {}, + "note": {}, + "reflect": {}, + "index": {bare: []string{"--watch"}}, + "migrate": {bare: []string{"--dry-run", "--yes", "-y"}}, + "replay": {bare: []string{"--peek"}}, + "doctor": {bare: []string{"--verbose", "--probe", "--integration", "--report"}}, + "resume": {valued: []string{"--since"}, orDefault: []string{"--budget", "-b"}}, + "sessions": {valued: []string{"--close"}}, + "why": {numeric: []string{"--limit", "-n"}}, + "usage": {valued: []string{"--usd"}}, + "graph": {orDefault: []string{"--hops"}, bare: []string{"--similar", "--list"}}, + "tried": {valued: []string{"--project", "--ruled-out", "--layer", "--scope", "--degree", + "--action", "--instead"}}, + "context": {valued: []string{"--project", "-p", "--since", "--pin", "--exclude", "--unpin"}, + orDefault: []string{"--budget", "-b"}, bare: []string{"--rules"}}, +} + +// checkCommandFlags refuses a flag cmd does not know, or a number it cannot +// use. A command not in commandFlags is not checked here. +func checkCommandFlags(cmd string, args []string) error { + spec, ok := commandFlags[cmd] + if !ok { + return nil + } + return checkFlags("logos "+cmd, args, spec) +} + +func checkFlags(what string, args []string, spec flagSpec) error { + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case slices.Contains(spec.bare, a): + case slices.Contains(spec.valued, a): + if i+1 < len(args) && !isFlagToken(args[i+1]) { + i++ + } + case slices.Contains(spec.numeric, a): + // "-5" is a value the user typed, not a flag, so it is taken and + // judged here rather than skipped as #116's missing value. + if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") { + i++ + if n, err := strconv.Atoi(args[i]); err != nil || n <= 0 { + return fmt.Errorf("%s needs a positive whole number, not %q", a, args[i]) + } + } + case slices.Contains(spec.orDefault, a): + if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") { + i++ + if n, err := strconv.Atoi(args[i]); err != nil || n < 0 { + return fmt.Errorf("%s needs a whole number, or 0 for the default, not %q", a, args[i]) + } + } + case strings.HasPrefix(a, "--"): + known := slices.Concat(spec.valued, spec.numeric, spec.orDefault, spec.bare) + if len(known) == 0 { + return fmt.Errorf("unknown flag %q — %s takes no flags; nothing was done", a, what) + } + return fmt.Errorf("unknown flag %q — %s takes %s; nothing was done", a, what, strings.Join(known, ", ")) + } + } + return nil +} + +func flagStr(args []string, name, def string) string { + for i, a := range args { + if a == name && i+1 < len(args) && !isFlagToken(args[i+1]) { + return args[i+1] + } + } + return def +} + +// dropFlag removes a value flag and its value from an argument list, so a +// command whose remaining words are free text can take flags at all. +// +// Without it, `memory add --project kestrel` stores the flag as part of +// the fact — the memory reads as though it were scoped and is in fact scoped to +// nothing, which is worse than the flag simply not existing. +func dropFlag(args []string, name string) []string { + out := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + if args[i] == name { + // Skip the value too, unless the flag was given last with + // nothing after it, or what follows is another flag — in which + // case there is no value to skip and dropping a word would take + // the next flag out of the line with it. + if i+1 < len(args) && !isFlagToken(args[i+1]) { + i++ + } + continue + } + out = append(out, args[i]) + } + return out +} + +// flagStrs collects a flag that may be given more than once, and also accepts a +// comma-separated list, so `--host claude-code --host codex` and +// `--host claude-code,codex` both work. Whichever a user reaches for first is +// the one that should have worked. +func flagStrs(args []string, name string) []string { + var out []string + for i, a := range args { + if a != name || i+1 >= len(args) || isFlagToken(args[i+1]) { + continue + } + for _, part := range strings.Split(args[i+1], ",") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + } + return out +} diff --git a/cmd/logos/help.go b/cmd/logos/help.go new file mode 100644 index 0000000..29a0c1a --- /dev/null +++ b/cmd/logos/help.go @@ -0,0 +1,224 @@ +package main + +import ( + "fmt" + "io" + "os" + "strings" +) + +// The help is two surfaces, and the split is a product decision rather than a +// tidying one. logos grew about forty verbs, and printing all of them was an +// honest inventory that answered the wrong question: a first-time reader wants +// to know what this is *for*, and forty lines of episodic capture, voice and +// benchmarks say "a grab-bag" no matter what the first line claims. +// +// So the default is the three journeys the product is actually about — hand +// off, brief, intercept — plus the three commands that get you there. Nothing +// is hidden: `logos help all` is the old inventory, grouped, and every verb +// still works exactly as it did. This changes what help prints, not what logos +// does. + +// helpShort is what `logos`, `logos help` and `logos --help` all print. It is +// deliberately one screen: the handoff is the centre of the product, and it is +// what a reader should be able to try in the next thirty seconds. +func helpShort(w io.Writer) { + fmt.Fprint(w, `logos — local-first memory and continuity for AI agents + +Agents forget the moment a session ends. logos is the memory they hand to one +another: one stops, the next picks up exactly where it left off. + +THE HANDOFF — an agent finishes, and another continues + logos note [project] + record progress; uncommitted until you checkpoint + logos checkpoint [project] [--task ..] [--next ..] [--failed ..] [--agent ] [--handoff ] + commit where you stopped, as a note in the vault + logos resume [project] pick up where the last agent left off + the project defaults to the directory you are in + +THE BRIEF — what bears on the work, before the work starts + logos context [--project

] [--budget ] + everything bearing on a task, budgeted (also an MCP tool) + +THE INTERCEPT — the dead end nobody remembers recording + logos tried [--project X] + has this already been ruled out? ask before proposing + logos tried --ruled-out [--layer L] [--scope S] + record one now, without waiting for a checkpoint + +GETTING THERE + logos setup [--vault DIR] [--host NAME] [--no-hosts] [--dry-run] [--yes] [--downgrade] + connect logos to the AI agents on this machine + logos mcp serve | mcp install serve the memory to MCP hosts; wire the ones found + logos mcp uninstall [--host NAME] take logos back out of the hosts; the vault is left alone + logos doctor [--verbose] [--probe] [--integration] [--report] + health of vault, index, hosts; --integration proves reach + --report prints a paste-able bundle for a bug report + logos update [--check] check GitHub for a newer release, verify it, replace this binary + + LOGOS_VAULT points at the vault (default ~/logos) + +`+"`logos help all`"+` lists the rest — memory, retrieval, benchmarks. +`) +} + +// helpAll is the full inventory, grouped. It exists so that demoting the +// general surface does not amount to hiding it: everything logos has ever +// accepted is here, spelled the way you type it. +func helpAll(w io.Writer) { + fmt.Fprintf(w, `logos — local-first memory and continuity for AI agents + +CONTINUITY + logos note [project] + record progress; uncommitted until you checkpoint + logos checkpoint [project] [--task ..] [--intent ..] [--state ..] [--next ..] [--decided ..] + [--verified ..] [--failed ..] [--blocker ..] [--ran ..] + [--question ..] [--file ..] [--agent ] [--handoff ] + commit where you stopped, as a note in the vault + repeat --decided, --verified, --failed, --blocker, + --ran, --question and --file to add more than one + logos resume [project] pick up where the last agent left off + the project defaults to the directory you are in + logos ingest [project] [--harness N] [--path FILE|ID] [--dry-run] [--all-projects] + harvest other agents' transcripts into checkpoint candidates + --path is a file, or for a txcript harness a session id + logos ingest review [--promote | --reject ] + review candidates before they become checkpoints + logos ingest status candidates by tier, and how many can still be distilled + logos ingest archive

copy the cited transcripts somewhere you keep them + logos sessions [project] checkpoint history for a project, and any abandoned ones + logos plans [project] plan-mode plans saved when ExitPlanMode is approved + logos continuity vault-wide: which projects checkpoint, which have gone quiet + logos bootstrap [project] [--dir DIR] [--dry-run] [--months N] + seed a cold vault from this repo's git history + logos context [--project

] [--budget ] + everything bearing on a task, budgeted (also an MCP tool) + logos tried [--project X] + has this already been ruled out? ask before proposing + logos tried --ruled-out [--layer L] [--scope S] + record one now, without waiting for a checkpoint + logos insights [project] patterns already in the vault: a recurring blocker, a dormant memory + logos usage [project] [--usd RATE] + what the budget left out of context packs, and + dead ends handed back before a retry + logos usage off | on stop or resume counting (LOGOS_USAGE=off for one process) + logos why [--limit N] what was being decided when this file was touched + logos projects | project auto-detected projects and their dossiers + logos project-name [dir] the project name for a directory, as the hooks compute it + logos hook session-start + what a host's session-start hook runs; prints the handoff as JSON + logos plugin autoupdate [--notice] + update the Claude Code plugin when it is older than this + binary, at most once a day; --notice prints what it did, once + logos project rename [--dry-run] [--merge] + rename a project, carrying its history with it; + --merge combines it into an existing project instead of refusing + +MEMORY + logos memory [add |forget |log|history |graph|diff] persistent memory + logos memory [health|consolidate|pin |unpin |exclude ] + what it knows about itself, and what to keep or ignore + logos memory log [--project P] [--n N] what changed in what it knows, newest first + logos activity [--project P] [--kind K] [--tool T] [--days N] [--json] + every prompt, tool call and turn the host reported — + recorded automatically, not by the model's choice + logos activity --projects which projects are being recorded + logos activity [off|on] stop or resume recording it, for this vault + logos announce [on|quiet|off] how loudly Logos reports its own work + logos prompt the instructions agents are given (LOGOSPROMPT.md) + logos demo [--fast] ninety seconds showing what this is for, in a scratch vault + logos memory diff [subject] [--since D] [--until D] [--days N] what changed, instant & offline + logos loop [list|add|done|drop] list or manage open loops (commitments) + logos graph [focus] [--hops N] [--similar] [--list] + draw a project or note with its checkpoints and memories + (default: this directory's project); --list prints it as text + +RETRIEVAL + logos search retrieve only, no generation + logos ask retrieve and answer from the vault + logos index [--watch] sync vault into the cache and embed + logos replay [--peek] catch up on what changed since you were last here + logos reflect descriptive stats over your memory (composition, growth, what it leans on) + logos review [--all] accept or reject quarantined memories + logos dream [--date YYYY-MM-DD] [--phase nrem|rem] [--dry-run] + nightly consolidation: replay, fade, recombine + logos dream review | accept|reject + review the connections REM proposed overnight + logos think [off|low|medium|high] how much the model reasons before answering + +SETUP AND DIAGNOSTICS + logos setup [--vault DIR] [--host NAME] [--no-hosts] [--dry-run] [--yes] [--downgrade] + connect logos to the AI agents on this machine + logos setup --print-config [--vault DIR] [--format json|toml] + print the server block by hand, for any MCP client not listed above + logos setup --config [--vault DIR] + merge logos into a config file at a location logos does not know by convention + logos mcp serve serve the memory layer to MCP hosts (Claude Desktop, Cursor, your own apps) + logos mcp serve --tools continuity serve 11 of the 17 tools, for hosts that load every tool on every turn + logos mcp serve --http [--port N] serve over a local WebSocket for the browser extension (ChatGPT/Claude.ai/ + Perplexity web UIs) — needs LOGOS_BRIDGE_ORIGIN set; never leaves localhost + logos mcp install [--vault DIR] [--host NAME] [--dry-run] [--yes] + register this logos with the MCP hosts found + logos mcp uninstall [--host NAME] remove logos from the MCP hosts found; never touches the vault + logos migrate [--dry-run] [--yes] move a 0.4 vault from ~/brain to ~/logos, leaving a link behind, + and re-pin the hosts that named the old path + logos doctor [--verbose] [--probe] [--integration] [--report] + health of vault, index, hosts; --verbose adds runtimes and tiers; --integration proves a host can reach it + logos key set|rm manage API keys in the macOS keychain + logos update [--check] check GitHub for a newer release, verify it, replace this binary + logos version which build this is + logos help [all] the three core journeys, or this list + +BENCHMARKS + logos bench continuity [list] [--only X] [--verbose] [--logos-only] [--variants] + the handoff + memory suite, against every system installed + logos bench memory | bench pipeline + LongMemEval retrieval recall; the extract→recall loop + +ENV + LOGOS_VAULT path to the vault (default ~/logos) + LOGOS_MODEL chat model (default %s) + LOGOS_EMBED embed model (default %s); "off" disables embeddings, search stays lexical + LOGOS_RUNTIME OpenAI-compatible base URL to use instead of auto-discovery + (LOGOS_RUNTIME_KEY for a bearer token) +`, defaultChatModel, defaultEmbedModel) +} + +// commandHelp prints the lines of the full help that describe cmd, each with +// the explanation indented under it, and reports whether there were any. +func commandHelp(w io.Writer, cmd string) bool { + if cmd == "" || strings.HasPrefix(cmd, "-") { + return false + } + var all strings.Builder + helpAll(&all) + var out strings.Builder + inEntry := false + for _, line := range strings.Split(all.String(), "\n") { + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, "logos "): + inEntry = trimmed == "logos "+cmd || strings.HasPrefix(trimmed, "logos "+cmd+" ") || + strings.Contains(trimmed, "| "+cmd+" ") + case trimmed == "" || !strings.HasPrefix(line, " "): + inEntry = false + } + if inEntry { + out.WriteString(line + "\n") + } + } + if out.Len() == 0 { + return false + } + fmt.Fprint(w, out.String()) + return true +} + +// usage is the failure path — no arguments, or a verb nobody recognises. It +// prints the short help to stderr and exits non-zero, because a command line +// that could not be parsed is an error even though the text is identical to +// what `logos help` prints on success. +func usage() { + helpShort(os.Stderr) + os.Exit(2) +} diff --git a/cmd/logos/index.go b/cmd/logos/index.go new file mode 100644 index 0000000..c843b08 --- /dev/null +++ b/cmd/logos/index.go @@ -0,0 +1,263 @@ +package main + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/Coder8124/logos/internal/dream" + "github.com/Coder8124/logos/internal/index" + "github.com/Coder8124/logos/internal/vault" +) + +func runIndex(watch bool) error { + ix, err := openIndex() + if err != nil { + return err + } + defer ix.Close() + + // A vault someone put under git must never be offered .logos/ to commit — + // it is a rebuildable cache, and two people sharing a vault over git would + // otherwise fight a merge conflict in a SQLite file on every pull — nor the + // activity log, which is every command and file path a host reported. Runs + // every time and reports only the run that actually changed something, so + // `logos index` calling this on every invocation never turns into noise. + if wrote, err := vault.EnsureGitignore(ix.Vault); err != nil { + fmt.Fprintln(os.Stderr, "· could not update .gitignore:", err) + } else if wrote { + fmt.Println("· .gitignore now keeps .logos/ and activity/ out of git") + } + + // Sync is pure file reading — it needs no model, and it is what keeps the + // FTS table current. Only the embedding passes need a provider. + // + // Requiring one here left a hole in the middle of the no-runtime story: + // lexical search worked, but the command that refreshes what it searches did + // not, so editing a note on a machine without Ollama meant the change was + // invisible until a model appeared. Worse, `logos checkpoint` tells the user + // to run exactly this command. + embed, embedOK := embedModel() + p, perr := findProvider() + switch { + case !embedOK: + fmt.Fprintln(os.Stderr, + "· embeddings off (LOGOS_EMBED) — indexing text only") + p = nil // the pass below keys the embedding work off a nil provider + case perr != nil: + fmt.Fprintln(os.Stderr, + "· no model runtime — indexing text only; run this again with Ollama up to add embeddings") + } + + pass := func() error { + rep, err := ix.Sync() + if err != nil { + return err + } + notes, _ := ix.NoteCount() + edges, _ := ix.EdgeCount() + + // Working notes come back before anything that needs a model, because + // restoring them needs nothing but the file — and this is the command a + // user runs after deleting the index, which is precisely when they are + // gone. Announced when there were any: a rebuild that silently recovered + // in-flight work is indistinguishable from one that lost it. + restoredNotes, rescuedNotes, err := ix.SyncNotes() + if err != nil { + fmt.Fprintln(os.Stderr, "· could not restore working notes:", err) + } + if restoredNotes > 0 { + fmt.Printf("restored %d uncommitted working %s\n", restoredNotes, plural(restoredNotes, "note")) + } + // The other direction, and said out loud for the reason the rescued + // proposals are: these were in the cache alone because a write to the + // vault failed earlier, and the user was told that once, by a process + // that has since exited. Silence here would make this run look like an + // ordinary one while it repaired real data loss. + if rescuedNotes > 0 { + fmt.Printf("wrote %d working %s to the vault — %s only in the index\n", + rescuedNotes, plural(rescuedNotes, "note"), wasWere(rescuedNotes)) + } + + // Memories and the review queue come back with or without a model. + // Import needs a provider only to re-embed, and passing a nil one skips + // exactly that — so this used to sit behind the `p == nil` return + // below, which meant a rebuild on a machine with no runtime restored + // the notes and left every remembered fact out of the cache until some + // later run happened to have Ollama up. "Delete the index, lose + // nothing" cannot depend on a model being reachable. + mems, rescuedMems, err := ix.SyncMemories(p, embed) + if err != nil { + return err + } + // Same again for memories, and this is the count the bug was about: a + // memory stranded in the cache used to be reaped here as a line the + // user had deleted by hand, and reported under `-0`. + if rescuedMems > 0 { + fmt.Printf("wrote %d memor%s to the vault — %s only in the index\n", + rescuedMems, pluralY(rescuedMems), wasWere(rescuedMems)) + } + + // The review queue, after the memories, so an accepted proposal is + // already an active memory before the queue is consulted about its id. + if queued, rescued, err := ix.SyncPending(); err != nil { + fmt.Fprintln(os.Stderr, "· could not restore the review queue:", err) + } else { + if queued > 0 { + fmt.Printf("restored %d memor%s awaiting review — run `logos review`\n", + queued, pluralY(queued)) + } + // Said out loud because it is a repair the user did not ask for and + // would otherwise never know happened — and because it means their + // queue was, until this run, one `rm -rf .logos` from gone. + if rescued > 0 { + fmt.Printf("wrote %d memor%s awaiting review to the vault — they were only in the index\n", + rescued, pluralY(rescued)) + } + } + + // The timeline, after both. Announced because the alternative — a silent + // repair — is how the old failure hid: `logos memory log` answered + // confidently after a rebuild, with dates invented on the spot, and + // nothing on stdout ever said the history had been touched. + if events, err := ix.SyncLog(); err != nil { + fmt.Fprintln(os.Stderr, "· could not restore the memory timeline:", err) + } else if events > 0 { + fmt.Printf("restored %d memory %s — run `logos memory log`\n", events, plural(events, "event")) + } + + // Open loops, which need no model either. Announced for the reason the + // working notes are: an empty `logos loop` after a rebuild reads as a + // list the user finished, not one the rebuild threw away. The count is + // every loop put back, closed ones included — they are what stops a + // dismissed commitment being extracted and surfaced all over again. + if loops, err := ix.SyncLoops(); err != nil { + fmt.Fprintln(os.Stderr, "· could not restore open loops:", err) + } else if loops > 0 { + fmt.Printf("restored %d tracked %s — run `logos loop`\n", loops, plural(loops, "loop")) + } + + // Dreamed insights, after the memories they cite. Announced for the + // reason the rest are: an empty `logos dream review` after a rebuild + // reads as a queue the user has already been through, not one the + // rebuild threw away. The count is every insight put back, reviewed + // ones included — they are what stops a rejected connection being + // proposed all over again. + if seen, err := ix.SyncInsights(); err != nil { + fmt.Fprintln(os.Stderr, "· could not restore dreamed insights:", err) + } else if seen > 0 { + // Rejections are restored too — they are the record of what the user + // already refused. Only point at the review command when there is + // actually something waiting behind it. + line := fmt.Sprintf("restored %d dreamed %s", seen, plural(seen, "insight")) + if n, err := dream.PendingCount(ix.DB); err == nil && n > 0 { + line += " — run `logos dream review`" + } + fmt.Println(line) + } + + if p == nil { + fmt.Printf("+%d ~%d -%d =%d · %d notes, %d edges, %d memories · lexical only\n", + rep.Added, rep.Updated, rep.Removed, rep.Unchanged, notes, edges, mems) + return nil + } + + embedded, err := ix.EmbedPending(p, embed, 32) + if err != nil { + return err + } + fmt.Printf("+%d ~%d -%d =%d · embedded %d · %d notes, %d edges, %d memories\n", + rep.Added, rep.Updated, rep.Removed, rep.Unchanged, embedded, notes, edges, mems) + return nil + } + + if err := pass(); err != nil || !watch { + return err + } + + fmt.Printf("watching %s …\n", ix.Vault) + // Poll rather than fsnotify: the vault is small, a 2s tick is imperceptible, + // and it sidesteps the editor-save event storms that make watchers fire + // three times per file. + for range time.Tick(2 * time.Second) { + if err := pass(); err != nil { + fmt.Fprintln(os.Stderr, "· sync error:", err) + } + } + return nil +} + +func search(query string) error { + ix, err := openIndex() + if err != nil { + return err + } + defer ix.Close() + + // No runtime is not a failure: FTS5 is in the index either way, so fall back + // to the lexical arm alone. Exact terms — names, error codes, IDs — are found + // as well as they ever were; only paraphrase suffers. + var hits []index.Hit + if model, ok := embedModel(); !ok { + fmt.Fprintln(os.Stderr, "· embeddings off (LOGOS_EMBED) — searching lexically") + hits, err = ix.LexicalSearch(query, 8) + } else if p, perr := findProvider(); perr == nil { + hits, err = ix.HybridSearch(p, model, query, 8) + } else { + fmt.Fprintln(os.Stderr, "· no model runtime — searching lexically") + hits, err = ix.LexicalSearch(query, 8) + } + if err != nil { + return err + } + // Zero hits printed nothing at all, which reads the same as a crash: a + // first-time user searching for a typo could not tell whether the command + // had worked. `logos ask` already says so in words; match it. + if len(hits) == 0 { + fmt.Printf("Nothing in the vault matches %q yet.\n", query) + return nil + } + for _, h := range hits { + fmt.Printf("%.3f %-28s %s\n", h.Score, h.Slug, h.Title) + } + return nil +} + +func ask(question string) error { + ix, err := openIndex() + if err != nil { + return err + } + defer ix.Close() + + p, err := findProvider() + if err != nil { + return err + } + + // ask still needs a chat model to synthesise the answer; an empty embed + // model (LOGOS_EMBED=off) only sends retrieval down the lexical arm inside + // HybridSearch rather than 404ing "off" at the runtime. + model, ok := embedModel() + if !ok { + fmt.Fprintln(os.Stderr, "· embeddings off (LOGOS_EMBED) — retrieving lexically") + } + answer, hits, err := ix.Ask(p, model, + env("LOGOS_MODEL", defaultChatModel), + question, 6, 6000) + if err != nil { + return err + } + + fmt.Printf("\n%s\n\n", strings.TrimSpace(answer)) + fmt.Println("─── context ───") + for _, h := range hits { + if h.Via != "" { + fmt.Printf(" %-28s via %s\n", h.Slug, h.Via) + } else { + fmt.Printf(" %-28s %.3f\n", h.Slug, h.Score) + } + } + return nil +} diff --git a/cmd/logos/main.go b/cmd/logos/main.go index 5fac798..0e6de69 100644 --- a/cmd/logos/main.go +++ b/cmd/logos/main.go @@ -8,19 +8,12 @@ import ( "io" "os" "runtime" - "slices" - "strconv" "strings" - "time" "github.com/Coder8124/logos/internal/buildinfo" - "github.com/Coder8124/logos/internal/dream" - "github.com/Coder8124/logos/internal/health" "github.com/Coder8124/logos/internal/index" - "github.com/Coder8124/logos/internal/mcpserver" "github.com/Coder8124/logos/internal/provider" "github.com/Coder8124/logos/internal/router" - "github.com/Coder8124/logos/internal/session" "github.com/Coder8124/logos/internal/setup" "github.com/Coder8124/logos/internal/vault" ) @@ -38,222 +31,6 @@ const ( defaultChatModel = "qwen3.6" ) -// The help is two surfaces, and the split is a product decision rather than a -// tidying one. logos grew about forty verbs, and printing all of them was an -// honest inventory that answered the wrong question: a first-time reader wants -// to know what this is *for*, and forty lines of episodic capture, voice and -// benchmarks say "a grab-bag" no matter what the first line claims. -// -// So the default is the three journeys the product is actually about — hand -// off, brief, intercept — plus the three commands that get you there. Nothing -// is hidden: `logos help all` is the old inventory, grouped, and every verb -// still works exactly as it did. This changes what help prints, not what logos -// does. - -// helpShort is what `logos`, `logos help` and `logos --help` all print. It is -// deliberately one screen: the handoff is the centre of the product, and it is -// what a reader should be able to try in the next thirty seconds. -func helpShort(w io.Writer) { - fmt.Fprint(w, `logos — local-first memory and continuity for AI agents - -Agents forget the moment a session ends. logos is the memory they hand to one -another: one stops, the next picks up exactly where it left off. - -THE HANDOFF — an agent finishes, and another continues - logos note [project] - record progress; uncommitted until you checkpoint - logos checkpoint [project] [--task ..] [--next ..] [--failed ..] [--agent ] [--handoff ] - commit where you stopped, as a note in the vault - logos resume [project] pick up where the last agent left off - the project defaults to the directory you are in - -THE BRIEF — what bears on the work, before the work starts - logos context [--project

] [--budget ] - everything bearing on a task, budgeted (also an MCP tool) - -THE INTERCEPT — the dead end nobody remembers recording - logos tried [--project X] - has this already been ruled out? ask before proposing - logos tried --ruled-out [--layer L] [--scope S] - record one now, without waiting for a checkpoint - -GETTING THERE - logos setup [--vault DIR] [--host NAME] [--no-hosts] [--dry-run] [--yes] [--downgrade] - connect logos to the AI agents on this machine - logos mcp serve | mcp install serve the memory to MCP hosts; wire the ones found - logos mcp uninstall [--host NAME] take logos back out of the hosts; the vault is left alone - logos doctor [--verbose] [--probe] [--integration] [--report] - health of vault, index, hosts; --integration proves reach - --report prints a paste-able bundle for a bug report - logos update [--check] check GitHub for a newer release, verify it, replace this binary - - LOGOS_VAULT points at the vault (default ~/logos) - -`+"`logos help all`"+` lists the rest — memory, retrieval, benchmarks. -`) -} - -// helpAll is the full inventory, grouped. It exists so that demoting the -// general surface does not amount to hiding it: everything logos has ever -// accepted is here, spelled the way you type it. -func helpAll(w io.Writer) { - fmt.Fprintf(w, `logos — local-first memory and continuity for AI agents - -CONTINUITY - logos note [project] - record progress; uncommitted until you checkpoint - logos checkpoint [project] [--task ..] [--intent ..] [--state ..] [--next ..] [--decided ..] - [--verified ..] [--failed ..] [--blocker ..] [--ran ..] - [--question ..] [--file ..] [--agent ] [--handoff ] - commit where you stopped, as a note in the vault - repeat --decided, --verified, --failed, --blocker, - --ran, --question and --file to add more than one - logos resume [project] pick up where the last agent left off - the project defaults to the directory you are in - logos ingest [project] [--harness N] [--path FILE|ID] [--dry-run] [--all-projects] - harvest other agents' transcripts into checkpoint candidates - --path is a file, or for a txcript harness a session id - logos ingest review [--promote | --reject ] - review candidates before they become checkpoints - logos ingest status candidates by tier, and how many can still be distilled - logos ingest archive

copy the cited transcripts somewhere you keep them - logos sessions [project] checkpoint history for a project, and any abandoned ones - logos plans [project] plan-mode plans saved when ExitPlanMode is approved - logos continuity vault-wide: which projects checkpoint, which have gone quiet - logos bootstrap [project] [--dir DIR] [--dry-run] [--months N] - seed a cold vault from this repo's git history - logos context [--project

] [--budget ] - everything bearing on a task, budgeted (also an MCP tool) - logos tried [--project X] - has this already been ruled out? ask before proposing - logos tried --ruled-out [--layer L] [--scope S] - record one now, without waiting for a checkpoint - logos insights [project] patterns already in the vault: a recurring blocker, a dormant memory - logos usage [project] [--usd RATE] - what the budget left out of context packs, and - dead ends handed back before a retry - logos usage off | on stop or resume counting (LOGOS_USAGE=off for one process) - logos why [--limit N] what was being decided when this file was touched - logos projects | project auto-detected projects and their dossiers - logos project-name [dir] the project name for a directory, as the hooks compute it - logos hook session-start - what a host's session-start hook runs; prints the handoff as JSON - logos plugin autoupdate [--notice] - update the Claude Code plugin when it is older than this - binary, at most once a day; --notice prints what it did, once - logos project rename [--dry-run] [--merge] - rename a project, carrying its history with it; - --merge combines it into an existing project instead of refusing - -MEMORY - logos memory [add |forget |log|history |graph|diff] persistent memory - logos memory [health|consolidate|pin |unpin |exclude ] - what it knows about itself, and what to keep or ignore - logos memory log [--project P] [--n N] what changed in what it knows, newest first - logos activity [--project P] [--kind K] [--tool T] [--days N] [--json] - every prompt, tool call and turn the host reported — - recorded automatically, not by the model's choice - logos activity --projects which projects are being recorded - logos activity [off|on] stop or resume recording it, for this vault - logos announce [on|quiet|off] how loudly Logos reports its own work - logos prompt the instructions agents are given (LOGOSPROMPT.md) - logos demo [--fast] ninety seconds showing what this is for, in a scratch vault - logos memory diff [subject] [--since D] [--until D] [--days N] what changed, instant & offline - logos loop [list|add|done|drop] list or manage open loops (commitments) - logos graph [focus] [--hops N] [--similar] [--list] - draw a project or note with its checkpoints and memories - (default: this directory's project); --list prints it as text - -RETRIEVAL - logos search retrieve only, no generation - logos ask retrieve and answer from the vault - logos index [--watch] sync vault into the cache and embed - logos replay [--peek] catch up on what changed since you were last here - logos reflect descriptive stats over your memory (composition, growth, what it leans on) - logos review [--all] accept or reject quarantined memories - logos dream [--date YYYY-MM-DD] [--phase nrem|rem] [--dry-run] - nightly consolidation: replay, fade, recombine - logos dream review | accept|reject - review the connections REM proposed overnight - logos think [off|low|medium|high] how much the model reasons before answering - -SETUP AND DIAGNOSTICS - logos setup [--vault DIR] [--host NAME] [--no-hosts] [--dry-run] [--yes] [--downgrade] - connect logos to the AI agents on this machine - logos setup --print-config [--vault DIR] [--format json|toml] - print the server block by hand, for any MCP client not listed above - logos setup --config [--vault DIR] - merge logos into a config file at a location logos does not know by convention - logos mcp serve serve the memory layer to MCP hosts (Claude Desktop, Cursor, your own apps) - logos mcp serve --tools continuity serve 11 of the 17 tools, for hosts that load every tool on every turn - logos mcp serve --http [--port N] serve over a local WebSocket for the browser extension (ChatGPT/Claude.ai/ - Perplexity web UIs) — needs LOGOS_BRIDGE_ORIGIN set; never leaves localhost - logos mcp install [--vault DIR] [--host NAME] [--dry-run] [--yes] - register this logos with the MCP hosts found - logos mcp uninstall [--host NAME] remove logos from the MCP hosts found; never touches the vault - logos migrate [--dry-run] [--yes] move a 0.4 vault from ~/brain to ~/logos, leaving a link behind, - and re-pin the hosts that named the old path - logos doctor [--verbose] [--probe] [--integration] [--report] - health of vault, index, hosts; --verbose adds runtimes and tiers; --integration proves a host can reach it - logos key set|rm manage API keys in the macOS keychain - logos update [--check] check GitHub for a newer release, verify it, replace this binary - logos version which build this is - logos help [all] the three core journeys, or this list - -BENCHMARKS - logos bench continuity [list] [--only X] [--verbose] [--logos-only] [--variants] - the handoff + memory suite, against every system installed - logos bench memory | bench pipeline - LongMemEval retrieval recall; the extract→recall loop - -ENV - LOGOS_VAULT path to the vault (default ~/logos) - LOGOS_MODEL chat model (default %s) - LOGOS_EMBED embed model (default %s); "off" disables embeddings, search stays lexical - LOGOS_RUNTIME OpenAI-compatible base URL to use instead of auto-discovery - (LOGOS_RUNTIME_KEY for a bearer token) -`, defaultChatModel, defaultEmbedModel) -} - -// commandHelp prints the lines of the full help that describe cmd, each with -// the explanation indented under it, and reports whether there were any. -func commandHelp(w io.Writer, cmd string) bool { - if cmd == "" || strings.HasPrefix(cmd, "-") { - return false - } - var all strings.Builder - helpAll(&all) - var out strings.Builder - inEntry := false - for _, line := range strings.Split(all.String(), "\n") { - trimmed := strings.TrimSpace(line) - switch { - case strings.HasPrefix(trimmed, "logos "): - inEntry = trimmed == "logos "+cmd || strings.HasPrefix(trimmed, "logos "+cmd+" ") || - strings.Contains(trimmed, "| "+cmd+" ") - case trimmed == "" || !strings.HasPrefix(line, " "): - inEntry = false - } - if inEntry { - out.WriteString(line + "\n") - } - } - if out.Len() == 0 { - return false - } - fmt.Fprint(w, out.String()) - return true -} - -// usage is the failure path — no arguments, or a verb nobody recognises. It -// prints the short help to stderr and exits non-zero, because a command line -// that could not be parsed is an error even though the text is identical to -// what `logos help` prints on success. -func usage() { - helpShort(os.Stderr) - os.Exit(2) -} - // start settles which vault this run uses before anything reads one. // // #95: inside a host, that host's own pin beats the machine pointer, so one @@ -439,194 +216,6 @@ func main() { } } -func hasFlag(args []string, name string) bool { - for _, a := range args { - if a == name { - return true - } - } - return false -} - -func flagInt(args []string, name string, def int) int { - for i, a := range args { - if a == name && i+1 < len(args) { - if v, err := strconv.Atoi(args[i+1]); err == nil { - return v - } - } - } - return def -} - -func joinArgs(a []string) string { return strings.Join(a, " ") } - -func parseID(args []string) int64 { - if len(args) >= 2 { - var id int64 - fmt.Sscan(args[1], &id) - return id - } - return 0 -} - -func firstNonFlag(args []string) string { - for i := 0; i < len(args); i++ { - if len(args[i]) >= 2 && args[i][:2] == "--" { - i++ // skip a flag's value too - continue - } - return args[i] - } - return "" -} - -// isFlagToken reports whether a word is another flag rather than a value, so a -// flag given with nothing after it falls back to its default instead of eating -// the next one. `--project --kind decision` recorded the project as "--kind" -// and said nothing; invariant 4 says a missing value is reported as missing. -// -// A lone "-" is a value: it is the conventional name for stdin. -func isFlagToken(a string) bool { - return strings.HasPrefix(a, "-") && a != "-" -} - -// flagSpec is every flag one command understands. Anything else that starts -// with -- is refused by name before the command runs: `note --agent A` filed -// the note under a project called "agent", `memory add --kind fact` stored the -// flag inside the fact, and `tried x --bogus` answered "nothing rules this out" -// — each with a success message. A single dash is left alone, because "-" is -// stdin and a note or project may legitimately start with one. -type flagSpec struct { - valued []string // take the next word as their value - numeric []string // valued, and a value that is given must be a positive whole number - // orDefault is numeric, except that 0 is accepted and asks for the default. - // Refusing it read as a broken command: --budget 0 is how a script says - // "whatever you normally use", and a pack with no budget is no pack at all. - orDefault []string - bare []string -} - -// commandFlags covers the commands whose flags were parsed by picking out the -// known ones and ignoring the rest. Commands with their own strict parser -// (checkpoint, setup, update, mcp install) are not listed. -var commandFlags = map[string]flagSpec{ - "version": {}, - "note": {}, - "reflect": {}, - "index": {bare: []string{"--watch"}}, - "migrate": {bare: []string{"--dry-run", "--yes", "-y"}}, - "replay": {bare: []string{"--peek"}}, - "doctor": {bare: []string{"--verbose", "--probe", "--integration", "--report"}}, - "resume": {valued: []string{"--since"}, orDefault: []string{"--budget", "-b"}}, - "sessions": {valued: []string{"--close"}}, - "why": {numeric: []string{"--limit", "-n"}}, - "usage": {valued: []string{"--usd"}}, - "graph": {orDefault: []string{"--hops"}, bare: []string{"--similar", "--list"}}, - "tried": {valued: []string{"--project", "--ruled-out", "--layer", "--scope", "--degree", - "--action", "--instead"}}, - "context": {valued: []string{"--project", "-p", "--since", "--pin", "--exclude", "--unpin"}, - orDefault: []string{"--budget", "-b"}, bare: []string{"--rules"}}, -} - -// checkCommandFlags refuses a flag cmd does not know, or a number it cannot -// use. A command not in commandFlags is not checked here. -func checkCommandFlags(cmd string, args []string) error { - spec, ok := commandFlags[cmd] - if !ok { - return nil - } - return checkFlags("logos "+cmd, args, spec) -} - -func checkFlags(what string, args []string, spec flagSpec) error { - for i := 0; i < len(args); i++ { - a := args[i] - switch { - case slices.Contains(spec.bare, a): - case slices.Contains(spec.valued, a): - if i+1 < len(args) && !isFlagToken(args[i+1]) { - i++ - } - case slices.Contains(spec.numeric, a): - // "-5" is a value the user typed, not a flag, so it is taken and - // judged here rather than skipped as #116's missing value. - if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") { - i++ - if n, err := strconv.Atoi(args[i]); err != nil || n <= 0 { - return fmt.Errorf("%s needs a positive whole number, not %q", a, args[i]) - } - } - case slices.Contains(spec.orDefault, a): - if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") { - i++ - if n, err := strconv.Atoi(args[i]); err != nil || n < 0 { - return fmt.Errorf("%s needs a whole number, or 0 for the default, not %q", a, args[i]) - } - } - case strings.HasPrefix(a, "--"): - known := slices.Concat(spec.valued, spec.numeric, spec.orDefault, spec.bare) - if len(known) == 0 { - return fmt.Errorf("unknown flag %q — %s takes no flags; nothing was done", a, what) - } - return fmt.Errorf("unknown flag %q — %s takes %s; nothing was done", a, what, strings.Join(known, ", ")) - } - } - return nil -} - -func flagStr(args []string, name, def string) string { - for i, a := range args { - if a == name && i+1 < len(args) && !isFlagToken(args[i+1]) { - return args[i+1] - } - } - return def -} - -// dropFlag removes a value flag and its value from an argument list, so a -// command whose remaining words are free text can take flags at all. -// -// Without it, `memory add --project kestrel` stores the flag as part of -// the fact — the memory reads as though it were scoped and is in fact scoped to -// nothing, which is worse than the flag simply not existing. -func dropFlag(args []string, name string) []string { - out := make([]string, 0, len(args)) - for i := 0; i < len(args); i++ { - if args[i] == name { - // Skip the value too, unless the flag was given last with - // nothing after it, or what follows is another flag — in which - // case there is no value to skip and dropping a word would take - // the next flag out of the line with it. - if i+1 < len(args) && !isFlagToken(args[i+1]) { - i++ - } - continue - } - out = append(out, args[i]) - } - return out -} - -// flagStrs collects a flag that may be given more than once, and also accepts a -// comma-separated list, so `--host claude-code --host codex` and -// `--host claude-code,codex` both work. Whichever a user reaches for first is -// the one that should have worked. -func flagStrs(args []string, name string) []string { - var out []string - for i, a := range args { - if a != name || i+1 >= len(args) || isFlagToken(args[i+1]) { - continue - } - for _, part := range strings.Split(args[i+1], ",") { - if part = strings.TrimSpace(part); part != "" { - out = append(out, part) - } - } - } - return out -} - func env(key, def string) string { if v := os.Getenv(key); v != "" { return v @@ -749,354 +338,6 @@ func openEvents() (*index.Index, error) { return openIndex() } -func doctor(probe, verbose bool) error { - // The product first, the model plumbing second. This used to be the other - // way round — and in fact only ever reported the plumbing, so a vault that - // did not exist and an index a week stale both passed silently. - // - // It also used to return an error when no runtime answered, which made the - // one command a confused user reaches for refuse to run precisely when - // something was wrong. - rep := gatherHealth() - fmt.Println("─── logos ───") - // Width from the longest check name rather than a constant. "abandoned - // sessions" is eighteen characters and used to push its own state out of the - // column every other row lined up in, which reads as a rendering bug in the - // one command someone runs when they already suspect something is wrong. - w := 0 - for _, c := range rep.Checks { - if len(c.Name) > w { - w = len(c.Name) - } - } - for _, c := range leadWith(rep.Checks, "vault", "agent hosts", "continuity") { - fmt.Printf(" %-*s %s\n", w, c.Name, renderState(c.State)) - if c.Detail != "" { - fmt.Printf(" %-*s %s\n", w, "", c.Detail) - } - if c.Fix != "" { - fmt.Printf(" %-*s → %s\n", w, "", c.Fix) - } - } - ok, warn, failed, unknown := rep.Counts() - fmt.Printf("\n %d ok · %d to do · %d failed · %d unchecked\n", ok, warn, failed, unknown) - - // Runtimes, tiers and the web bridge are for `ask`, the rollup and the - // browser extension. No continuity tool uses them, and printed by default - // they were most of the report and made logos look like it needed a model. - if !verbose && !probe { - fmt.Println("\nrun `logos doctor --verbose` for the web bridge, model runtimes and tiers") - return doctorVerdict(failed) - } - - if mcpserver.HasToken(vaultPath()) { - fmt.Println("\nweb bridge: paired — `logos mcp serve --http` will reuse the existing token") - } else { - fmt.Println("\nweb bridge: not paired — `logos mcp serve --http` will mint a token on first run") - } - - found := provider.Resolve() - if len(found) == 0 && provider.Configured() != nil { - // The user named a runtime and it is down. The check above already - // failed on it; closing on "nothing depends on one" would tell them - // to ignore the one failure they asked for. - fmt.Printf("\nLOGOS_RUNTIME names %s, and it did not answer — search is lexical until it does.\n", provider.Configured().BaseURL) - return doctorVerdict(failed) - } - if len(found) == 0 { - // Not an error. Every continuity tool works without a model, and search - // falls back to lexical; the report above already said so. - fmt.Println("\nNo local model runtime — nothing above depends on one.") - return doctorVerdict(failed) - } - fmt.Println("\n─── runtimes ───") - for _, d := range found { - fmt.Printf("%s — %s\n", d.Provider.Name, d.Provider.BaseURL) - for _, m := range d.Models { - fmt.Printf(" %s\n", m) - } - } - - cfg, err := router.Load(vaultPath()) - if err != nil { - return err - } - rt, err := router.New(cfg, vaultPath()) - if err != nil { - return err - } - - fmt.Println("\n─── tiers ───") - for _, line := range rt.Available() { - fmt.Println(" ", line) - } - - if !probe { - fmt.Println("\nrun `logos doctor --probe` to verify each model actually loads") - return doctorVerdict(failed) - } - - // Listing a model proves nothing: a corrupt pull lists fine and fails on - // load. Probing is what catches it before a rollup does at 3am. - // - // And a model that fails to load counts towards the verdict, or the probe - // is the one check whose result nothing can act on: `failed` is totalled - // before this loop runs, so `logos doctor --probe && deploy` used to print - // FAILS TO LOAD in red and then exit 0 into the next command. - fmt.Println("\n─── probe ───") - for _, t := range []router.Tier{router.T1, router.T2} { - model, err := rt.Model(t) - if err != nil { - fmt.Printf(" %s %v\n", t, err) - continue - } - line, broken := probeRow(t, model, rt.Probe(model)) - fmt.Println(line) - if broken { - failed++ - } - } - return doctorVerdict(failed) -} - -// leadWith moves the named checks to the front, in that order, and keeps the -// rest as they were: what a coding-agent user runs doctor for is whether the -// vault is there, whether their agents are wired to it, and where the last -// session stopped. -func leadWith(checks []health.Check, names ...string) []health.Check { - out := make([]health.Check, 0, len(checks)) - for _, n := range names { - for _, c := range checks { - if c.Name == n { - out = append(out, c) - } - } - } - for _, c := range checks { - if !slices.Contains(names, c.Name) { - out = append(out, c) - } - } - return out -} - -// probeRow renders one probe result and says whether it counts as a failure. -// It is a function of its own so the verdict can be tested without a live model -// runtime: the bug it exists to stop — FAILS TO LOAD printed in the report while -// the command exits 0 — is only visible where the row and the count are decided -// together. -// -// A model that loads but ignores JSON schemas is not a failure. Every tier -// degrades to prose in that case, which is worse output, not a broken install. -func probeRow(t router.Tier, model string, cap router.Capability) (line string, failed bool) { - switch { - case !cap.Loads: - return fmt.Sprintf(" %s %-24s FAILS TO LOAD — %s", t, model, truncate(cap.Err, 70)), true - case !cap.StructuredOutput: - return fmt.Sprintf(" %s %-24s loads, but ignores JSON schemas", t, model), false - default: - return fmt.Sprintf(" %s %-24s ok, honours JSON schemas", t, model), false - } -} - -// doctorVerdict turns the report into an exit code. The rows already say what -// is wrong in words; this is for everything that reads the status instead — a -// pre-flight check, a CI step, a shell `&&`. An unchecked row is not a failure: -// doctor deliberately does not fail because no model runtime answered, since -// every continuity verb works without one. -func doctorVerdict(failed int) error { - if failed == 0 { - return nil - } - return fmt.Errorf("%d check(s) failed — see the report above", failed) -} - -// doctorIntegration is the difference between "logos is installed" and "your -// agents can reach this vault". It is the same probe setup runs, exposed so it -// can be re-run after a host update or a config edit. -func doctorIntegration() error { - vault := vaultPath() - // The same description setup writes into every host config, so this check - // launches what the hosts launch — under npx that is the `npx` command, not - // the cached binary this process happens to be running from. - srv, err := logosServer(vault) - if err != nil { - return err - } - - self, err := selfPath() - if err != nil { - return err - } - - // What the hosts have registered, not what this process happens to be - // running from (#89). Someone who moved the binary onto their PATH, as the - // end of setup told them to, left every host naming a file that is gone — - // and this check said "Working", because it rebuilt the command from the - // binary it found itself in. The question being asked is whether the - // agents can reach the vault, and only their own entries can answer it. - targets, unreadable := registeredTargets(vault, srv) - failed := len(unreadable) - for _, u := range unreadable { - fmt.Printf("─── integration ───\n host %s\n its registrations could not be read, so nothing here says whether it reaches this vault\n\n", u) - } - for i, t := range targets { - if i > 0 { - fmt.Println() - } - fmt.Printf("─── integration ───\n host %s\n binary %s %s\n vault %s\n", - t.host, t.srv.Bin, strings.Join(t.srv.Args, " "), t.vault) - probeBin, probeArgs, note := probeTarget(self, t.srv) - if note != "" { - fmt.Printf(" note %s\n", note) - } - fmt.Println() - for _, c := range integrationChecks(probeBin, probeArgs, t.vault) { - fmt.Printf(" %-12s %s\n", c.Name, renderState(c.State)) - if c.Detail != "" { - fmt.Printf(" %-12s %s\n", "", c.Detail) - } - if c.Fix != "" { - fmt.Printf(" %-12s → %s\n", "", c.Fix) - } - if c.State == health.Failed { - failed++ - } - } - } - if failed > 0 { - // Named by count, not by "no host": one unreadable config among several - // that read perfectly well sent the user to look for a permission - // problem that was not there. - if len(unreadable) > 0 { - return fmt.Errorf("%d host(s) could not say what they have registered", len(unreadable)) - } - return fmt.Errorf("integration is not working") - } - fmt.Println("\n Working. The hosts launching these commands reach this vault.") - return nil -} - -// probe is one command to launch and the vault it is expected to reach, named -// by whoever registered it. -type probe struct { - host string - srv setup.Server - vault string -} - -// registeredTargets is the logos entry each detected host actually holds, and -// separately the hosts that could not be asked. A host with no logos in its -// config contributes nothing — it is not wired, so there is no wiring to check. -// A host whose config cannot be read is not that: it is the question going -// unanswered, so it is returned to be reported rather than dropped. -// -// Falling back to the command setup would write is what makes this check usable -// on a machine with no host registered yet: without it, `doctor --integration` -// on a fresh install would have nothing to probe and would report success by -// having asked nothing. -func registeredTargets(vault string, srv setup.Server) ([]probe, []string) { - var out []probe - var unreadable []string - seen := map[string]bool{} - for _, h := range detectHosts() { - if h.List == nil || (h.Detect != nil && !h.Detect()) { - continue - } - regs, err := h.List() - if err != nil { - // A host that cannot say what it has registered is not a host with - // nothing registered. Swallowing this left the fallback probing - // the command setup would write and the check closing "Working", - // having failed to ask the only question it exists to ask. - unreadable = append(unreadable, fmt.Sprintf("%s: %v", h.Name, err)) - continue - } - for _, r := range regs { - if !strings.Contains(r.Command, "mcp serve") { - continue - } - // Split on spaces, which is how the command was joined. A binary - // path with a space in it is not reconstructed, and lands as a - // command that fails to launch — visibly, which is the point. - fields := strings.Fields(r.Command) - v := r.Vault - if v == "" { - v = vault - } - // Keyed by the vault as well as the command: two hosts commonly - // register the same binary against different vaults, and that - // split is the thing this check exists to catch. Keyed by command - // alone, the second host's vault was never probed. - key := r.Command + "\x00" + v - if len(fields) == 0 || seen[key] { - continue - } - seen[key] = true - out = append(out, probe{h.Name, setup.Server{Bin: fields[0], Args: fields[1:]}, v}) - } - } - if len(out) == 0 && len(unreadable) == 0 { - return []probe{{"none registered — probing what setup would write", srv, vault}}, nil - } - return out, unreadable -} - -// gatherHealth assembles what the checks need, tolerating every piece of it -// being missing. A vault that will not open, an index that is not there and a -// runtime that is not running each become Unknown rather than an early return — -// the point of the report is to work when things are broken. -func gatherHealth() health.Report { - vault := vaultPath() - in := health.Input{Vault: vault, EmbedModel: env("LOGOS_EMBED", defaultEmbedModel), Hosts: setup.Hosts(), Version: buildinfo.Version} - if self, err := selfPath(); err == nil { - in.Self = self - } - - // Stat before opening, because index.Open creates /.logos and that - // brings the vault itself into existence. Opening it here meant doctor made - // the vault it was about to check and then pronounced it healthy — the - // "does not exist" branch in checkVault could not fire from the CLI at all. - // A mistyped LOGOS_VAULT, or doctor run before setup, produced a second - // empty vault with a clean bill of health, which is exactly the "healthy - // zero of everything" that internal/vault/path.go exists to prevent. - // - // A vault that exists but has never been indexed is a different case, and - // index.Open creating .logos for that one is wanted. - if _, err := os.Stat(vault); err == nil { - if ix, err := index.Open(vault); err == nil { - defer ix.Close() - session.Init(ix.DB) // so the abandonment check reads a table rather than an error - in.DB = ix.DB - } - } - // Resolve, not Discover: doctor has to report the runtime the server will - // use, and with LOGOS_RUNTIME set that is never whatever is on localhost. - if found := provider.Resolve(); len(found) > 0 { - in.Runtime = found[0].Provider - } - in.Configured = os.Getenv("LOGOS_RUNTIME") - - return health.Run(in) -} - -func renderState(s health.State) string { - switch s { - case health.OK: - return "ok" - case health.Warn: - // Lower case and unshouted on purpose: this row is a chore waiting for - // the user, and rendering it the way a broken index is rendered is what - // made people stop reading the report. - return "to do" - case health.Failed: - return "FAILED" - default: - // Spelled out, because the whole point is that this is not "fine". - return "unchecked" - } -} - func truncate(s string, n int) string { s = strings.ReplaceAll(s, "\n", " ") if len(s) > n { @@ -1129,257 +370,6 @@ func keyCmd(args []string) error { return fmt.Errorf("usage: logos key set|rm ") } -func runIndex(watch bool) error { - ix, err := openIndex() - if err != nil { - return err - } - defer ix.Close() - - // A vault someone put under git must never be offered .logos/ to commit — - // it is a rebuildable cache, and two people sharing a vault over git would - // otherwise fight a merge conflict in a SQLite file on every pull — nor the - // activity log, which is every command and file path a host reported. Runs - // every time and reports only the run that actually changed something, so - // `logos index` calling this on every invocation never turns into noise. - if wrote, err := vault.EnsureGitignore(ix.Vault); err != nil { - fmt.Fprintln(os.Stderr, "· could not update .gitignore:", err) - } else if wrote { - fmt.Println("· .gitignore now keeps .logos/ and activity/ out of git") - } - - // Sync is pure file reading — it needs no model, and it is what keeps the - // FTS table current. Only the embedding passes need a provider. - // - // Requiring one here left a hole in the middle of the no-runtime story: - // lexical search worked, but the command that refreshes what it searches did - // not, so editing a note on a machine without Ollama meant the change was - // invisible until a model appeared. Worse, `logos checkpoint` tells the user - // to run exactly this command. - embed, embedOK := embedModel() - p, perr := findProvider() - switch { - case !embedOK: - fmt.Fprintln(os.Stderr, - "· embeddings off (LOGOS_EMBED) — indexing text only") - p = nil // the pass below keys the embedding work off a nil provider - case perr != nil: - fmt.Fprintln(os.Stderr, - "· no model runtime — indexing text only; run this again with Ollama up to add embeddings") - } - - pass := func() error { - rep, err := ix.Sync() - if err != nil { - return err - } - notes, _ := ix.NoteCount() - edges, _ := ix.EdgeCount() - - // Working notes come back before anything that needs a model, because - // restoring them needs nothing but the file — and this is the command a - // user runs after deleting the index, which is precisely when they are - // gone. Announced when there were any: a rebuild that silently recovered - // in-flight work is indistinguishable from one that lost it. - restoredNotes, rescuedNotes, err := ix.SyncNotes() - if err != nil { - fmt.Fprintln(os.Stderr, "· could not restore working notes:", err) - } - if restoredNotes > 0 { - fmt.Printf("restored %d uncommitted working %s\n", restoredNotes, plural(restoredNotes, "note")) - } - // The other direction, and said out loud for the reason the rescued - // proposals are: these were in the cache alone because a write to the - // vault failed earlier, and the user was told that once, by a process - // that has since exited. Silence here would make this run look like an - // ordinary one while it repaired real data loss. - if rescuedNotes > 0 { - fmt.Printf("wrote %d working %s to the vault — %s only in the index\n", - rescuedNotes, plural(rescuedNotes, "note"), wasWere(rescuedNotes)) - } - - // Memories and the review queue come back with or without a model. - // Import needs a provider only to re-embed, and passing a nil one skips - // exactly that — so this used to sit behind the `p == nil` return - // below, which meant a rebuild on a machine with no runtime restored - // the notes and left every remembered fact out of the cache until some - // later run happened to have Ollama up. "Delete the index, lose - // nothing" cannot depend on a model being reachable. - mems, rescuedMems, err := ix.SyncMemories(p, embed) - if err != nil { - return err - } - // Same again for memories, and this is the count the bug was about: a - // memory stranded in the cache used to be reaped here as a line the - // user had deleted by hand, and reported under `-0`. - if rescuedMems > 0 { - fmt.Printf("wrote %d memor%s to the vault — %s only in the index\n", - rescuedMems, pluralY(rescuedMems), wasWere(rescuedMems)) - } - - // The review queue, after the memories, so an accepted proposal is - // already an active memory before the queue is consulted about its id. - if queued, rescued, err := ix.SyncPending(); err != nil { - fmt.Fprintln(os.Stderr, "· could not restore the review queue:", err) - } else { - if queued > 0 { - fmt.Printf("restored %d memor%s awaiting review — run `logos review`\n", - queued, pluralY(queued)) - } - // Said out loud because it is a repair the user did not ask for and - // would otherwise never know happened — and because it means their - // queue was, until this run, one `rm -rf .logos` from gone. - if rescued > 0 { - fmt.Printf("wrote %d memor%s awaiting review to the vault — they were only in the index\n", - rescued, pluralY(rescued)) - } - } - - // The timeline, after both. Announced because the alternative — a silent - // repair — is how the old failure hid: `logos memory log` answered - // confidently after a rebuild, with dates invented on the spot, and - // nothing on stdout ever said the history had been touched. - if events, err := ix.SyncLog(); err != nil { - fmt.Fprintln(os.Stderr, "· could not restore the memory timeline:", err) - } else if events > 0 { - fmt.Printf("restored %d memory %s — run `logos memory log`\n", events, plural(events, "event")) - } - - // Open loops, which need no model either. Announced for the reason the - // working notes are: an empty `logos loop` after a rebuild reads as a - // list the user finished, not one the rebuild threw away. The count is - // every loop put back, closed ones included — they are what stops a - // dismissed commitment being extracted and surfaced all over again. - if loops, err := ix.SyncLoops(); err != nil { - fmt.Fprintln(os.Stderr, "· could not restore open loops:", err) - } else if loops > 0 { - fmt.Printf("restored %d tracked %s — run `logos loop`\n", loops, plural(loops, "loop")) - } - - // Dreamed insights, after the memories they cite. Announced for the - // reason the rest are: an empty `logos dream review` after a rebuild - // reads as a queue the user has already been through, not one the - // rebuild threw away. The count is every insight put back, reviewed - // ones included — they are what stops a rejected connection being - // proposed all over again. - if seen, err := ix.SyncInsights(); err != nil { - fmt.Fprintln(os.Stderr, "· could not restore dreamed insights:", err) - } else if seen > 0 { - // Rejections are restored too — they are the record of what the user - // already refused. Only point at the review command when there is - // actually something waiting behind it. - line := fmt.Sprintf("restored %d dreamed %s", seen, plural(seen, "insight")) - if n, err := dream.PendingCount(ix.DB); err == nil && n > 0 { - line += " — run `logos dream review`" - } - fmt.Println(line) - } - - if p == nil { - fmt.Printf("+%d ~%d -%d =%d · %d notes, %d edges, %d memories · lexical only\n", - rep.Added, rep.Updated, rep.Removed, rep.Unchanged, notes, edges, mems) - return nil - } - - embedded, err := ix.EmbedPending(p, embed, 32) - if err != nil { - return err - } - fmt.Printf("+%d ~%d -%d =%d · embedded %d · %d notes, %d edges, %d memories\n", - rep.Added, rep.Updated, rep.Removed, rep.Unchanged, embedded, notes, edges, mems) - return nil - } - - if err := pass(); err != nil || !watch { - return err - } - - fmt.Printf("watching %s …\n", ix.Vault) - // Poll rather than fsnotify: the vault is small, a 2s tick is imperceptible, - // and it sidesteps the editor-save event storms that make watchers fire - // three times per file. - for range time.Tick(2 * time.Second) { - if err := pass(); err != nil { - fmt.Fprintln(os.Stderr, "· sync error:", err) - } - } - return nil -} - -func search(query string) error { - ix, err := openIndex() - if err != nil { - return err - } - defer ix.Close() - - // No runtime is not a failure: FTS5 is in the index either way, so fall back - // to the lexical arm alone. Exact terms — names, error codes, IDs — are found - // as well as they ever were; only paraphrase suffers. - var hits []index.Hit - if model, ok := embedModel(); !ok { - fmt.Fprintln(os.Stderr, "· embeddings off (LOGOS_EMBED) — searching lexically") - hits, err = ix.LexicalSearch(query, 8) - } else if p, perr := findProvider(); perr == nil { - hits, err = ix.HybridSearch(p, model, query, 8) - } else { - fmt.Fprintln(os.Stderr, "· no model runtime — searching lexically") - hits, err = ix.LexicalSearch(query, 8) - } - if err != nil { - return err - } - // Zero hits printed nothing at all, which reads the same as a crash: a - // first-time user searching for a typo could not tell whether the command - // had worked. `logos ask` already says so in words; match it. - if len(hits) == 0 { - fmt.Printf("Nothing in the vault matches %q yet.\n", query) - return nil - } - for _, h := range hits { - fmt.Printf("%.3f %-28s %s\n", h.Score, h.Slug, h.Title) - } - return nil -} - -func ask(question string) error { - ix, err := openIndex() - if err != nil { - return err - } - defer ix.Close() - - p, err := findProvider() - if err != nil { - return err - } - - // ask still needs a chat model to synthesise the answer; an empty embed - // model (LOGOS_EMBED=off) only sends retrieval down the lexical arm inside - // HybridSearch rather than 404ing "off" at the runtime. - model, ok := embedModel() - if !ok { - fmt.Fprintln(os.Stderr, "· embeddings off (LOGOS_EMBED) — retrieving lexically") - } - answer, hits, err := ix.Ask(p, model, - env("LOGOS_MODEL", defaultChatModel), - question, 6, 6000) - if err != nil { - return err - } - - fmt.Printf("\n%s\n\n", strings.TrimSpace(answer)) - fmt.Println("─── context ───") - for _, h := range hits { - if h.Via != "" { - fmt.Printf(" %-28s via %s\n", h.Slug, h.Via) - } else { - fmt.Printf(" %-28s %.3f\n", h.Slug, h.Score) - } - } - return nil -} - // wasWere keeps the rescue receipts readable when exactly one thing was // rescued, which is the commonest case — one failed write, one memory. func wasWere(n int) string { From 5369ee93686f4814e91129830087b7fb10837856 Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 1 Oct 2026 17:25:19 -0700 Subject: [PATCH 09/16] Doctor's host, install, vault and wording checks live in their own files, so health.go holds the report and the index checks --- internal/health/health.go | 775 ------------------------------------ internal/health/helpers.go | 168 ++++++++ internal/health/hosts.go | 269 +++++++++++++ internal/health/installs.go | 92 +++++ internal/health/vault.go | 286 +++++++++++++ 5 files changed, 815 insertions(+), 775 deletions(-) create mode 100644 internal/health/helpers.go create mode 100644 internal/health/hosts.go create mode 100644 internal/health/installs.go create mode 100644 internal/health/vault.go diff --git a/internal/health/health.go b/internal/health/health.go index f69512b..6599b60 100644 --- a/internal/health/health.go +++ b/internal/health/health.go @@ -14,21 +14,15 @@ package health import ( - "context" "database/sql" "fmt" - "io/fs" "os" - "os/exec" - "path/filepath" "strings" "time" - "github.com/Coder8124/logos/internal/buildinfo" "github.com/Coder8124/logos/internal/ingest" "github.com/Coder8124/logos/internal/memory" "github.com/Coder8124/logos/internal/provider" - "github.com/Coder8124/logos/internal/selfupdate" "github.com/Coder8124/logos/internal/session" "github.com/Coder8124/logos/internal/setup" "github.com/Coder8124/logos/internal/transcript" @@ -166,211 +160,6 @@ func Run(in Input) Report { return r } -// The vault is the product. If it is missing or unwritable, nothing else -// matters, so this runs first and says exactly which of the two it is. -func checkVault(dir string) Check { - c := Check{Name: "vault"} - if strings.TrimSpace(dir) == "" { - c.State, c.Detail = Failed, "no vault path resolved" - c.Fix = "set LOGOS_VAULT, or run `logos setup`" - return c - } - info, err := os.Stat(dir) - if os.IsNotExist(err) { - c.State, c.Detail = Failed, dir+" does not exist" - c.Fix = "run `logos setup --vault " + shellArg(dir) + "`" - // This machine's recorded vault being absent is usually an unmounted - // drive, and setup at that path makes an empty vault where it mounts. - if os.Getenv("LOGOS_VAULT") == "" && dir == vault.Pointer() { - c.Detail = dir + " does not exist — it is the vault recorded for this machine" - c.Fix = "reconnect the drive it is on; to use a different vault, run `logos setup --vault `" - } - return c - } - if err != nil { - c.State, c.Detail = Failed, err.Error() - return c - } - if !info.IsDir() { - c.State, c.Detail = Failed, dir+" is a file, not a directory" - return c - } - // Readable is not enough: logos writes checkpoints here, and finding that - // out at handoff time is finding out too late. - probe := filepath.Join(dir, ".logos-write-probe") - if err := os.WriteFile(probe, []byte("x"), 0o600); err != nil { - c.State, c.Detail = Failed, dir+" is not writable: "+err.Error() - c.Fix = "check permissions; checkpoints cannot be saved" - return c - } - os.Remove(probe) - - // The vault is writable and real. One question left: is it the vault the - // user meant, or a scratch directory that outlived the command that made it? - // - // `logos setup --vault

` records its target in - // os.UserConfigDir()/logos/vault-path, and that pointer is what every front - // end reads when LOGOS_VAULT is unset — including a host launched from - // Finder, which inherits no shell and has no other way to find the vault. So running setup - // against a scratch vault, which CONTRIBUTING.md tells contributors to do, - // silently repoints the real installation at a temporary directory. Nothing - // then fails: index.Open creates whatever it is handed, so the vault is - // present, writable and empty, and every check downstream honestly reports - // zero. The author's own pointer named a /var/folders temp path for a day - // while doctor called it healthy. - // - // The recorded pointer is what is checked, not the resolved directory. An - // explicit LOGOS_VAULT is a deliberate choice scoped to one command and is - // nobody's business to complain about; the pointer outlives the session. - if rec := vault.Recorded(); rec != "" && UnderTempDir(rec) { - c.State = Failed - c.Detail = rec + " is a temporary directory, recorded as the vault every front end opens — it will be empty or gone" - c.Fix = "run `logos setup --vault ` to repoint it, or `logos doctor` with LOGOS_VAULT set to check a scratch vault without recording it" - return c - } - - // A blocklist of temporary roots is always one directory short: the pointer - // that actually did the damage named ~/.claude/jobs//tmp/survey-vault, - // which is not a system temp root at all, and UnderTempDir walked straight - // past it. So ask the question that does not depend on knowing where the - // next harness will put its scratch directories. - // - // Neither half is a fault on its own. A vault with no checkpoints is a - // perfectly good new install, and history in a second vault is a perfectly - // good second vault. It is the pair that means the pointer is wrong — and - // the pair is precisely what the user cannot see, because every command - // they run reads the empty one and truthfully reports nothing. - // - // Scoped to the recorded pointer for the same reason the check above is: an - // explicit LOGOS_VAULT is a scratch vault someone chose for this one - // command, and it is supposed to be empty. Complaining about it would make - // the documented workflow print a failure on every run. - if os.Getenv("LOGOS_VAULT") == "" { - if other, n := populatedVaultElsewhere(dir); n > 0 { - c.State = Failed - c.Detail = fmt.Sprintf("no checkpoints here, but %s holds %d — every front end is reading this empty vault instead", other, n) - c.Fix = "run `logos setup --vault " + shellArg(other) + "` to repoint this machine" - return c - } - } - - c.State, c.Detail = OK, dir - return c -} - -// populatedVaultElsewhere reports another vault on disk that has history in it, -// when the vault in use has none. Only the default location is looked at: it is -// where a vault is unless somebody moved it, and searching the disk for vaults -// would be a slow answer to a question doctor asks on every run. -func populatedVaultElsewhere(dir string) (string, int) { - // Only "is there any", so stop at the first one. This runs on every doctor. - if checkpointCount(dir, 1) > 0 { - return "", 0 - } - home, err := os.UserHomeDir() - if err != nil { - return "", 0 - } - def := filepath.Join(home, "logos") - for _, a := range forms(def) { - for _, b := range forms(dir) { - if a == b { - return "", 0 - } - } - } - // The real total here: it is printed, and "28 checkpoints sit in ~/logos" is - // the number that tells the user which vault is the one they meant. - if n := checkpointCount(def, 0); n > 0 { - return def, n - } - return "", 0 -} - -// checkpointCount totals the checkpoints across every project in a vault, -// reading the markdown rather than the index — the index of the vault nobody is -// using is exactly the one that will not be built. -// -// stopAt bounds the work for the caller that only needs "is there any": names -// are counted rather than files parsed, because doctor runs this on every -// invocation over two vaults, and parsing a user's entire history to answer a -// yes/no question makes the command slower the longer they have used it. -func checkpointCount(dir string, stopAt int) int { - // Scopes, so doctor's "is there any work here" answer is not no on a vault - // whose every checkpoint was written from a git worktree. - projects, err := session.Scopes(dir) - if err != nil { - return 0 - } - total := 0 - for _, p := range projects { - entries, err := os.ReadDir(filepath.Join(dir, session.CheckpointDir, p)) - if err != nil { - continue - } - for _, e := range entries { - if e.IsDir() || !session.IsCheckpointFile(e.Name()) { - continue - } - total++ - if stopAt > 0 && total >= stopAt { - return total - } - } - } - return total -} - -// UnderTempDir reports whether path sits inside a system temporary directory. -// -// Every well-known temp root is checked, not just os.TempDir(). os.TempDir() -// answers $TMPDIR, which on macOS is a per-user directory under /var/folders — -// and the directory that actually repointed a real installation was under -// /tmp, which shares no prefix with it. Agent scratchpads, mktemp -d scripts -// and half the shell in this repository use /tmp; a guard that cannot see it is -// a guard against the one case that has never happened. -// -// Both sides are resolved through symlinks first: /tmp answers to /private/tmp -// and /var/folders/... to /private/var/folders/..., and a string compare of the -// two forms says they are unrelated. -func UnderTempDir(path string) bool { - // An agent's per-job scratch directory is a temporary root that lives under - // $HOME, so none of the system roots below match it. ~/.claude/jobs//tmp - // is where the pointer that broke a real installation was made. - roots := []string{os.TempDir(), "/tmp", "/private/tmp", "/var/tmp", "/private/var/tmp"} - if home, err := os.UserHomeDir(); err == nil { - roots = append(roots, filepath.Join(home, ".claude", "jobs"), filepath.Join(home, ".claude", "tmp")) - } - for _, p := range forms(path) { - for _, root := range roots { - for _, t := range forms(root) { - // Separator-anchored, so /tmpfoo is not read as living under /tmp. - if p == t || strings.HasPrefix(p, t+string(filepath.Separator)) { - return true - } - } - } - } - return false -} - -// forms returns the spellings of a path that have to be compared: the cleaned -// path, and its symlink-resolved form when it has one. Both are needed because -// only one side of the comparison usually exists on disk — EvalSymlinks("/tmp") -// yields /private/tmp, but EvalSymlinks("/tmp/a-vault-that-was-deleted") fails -// and leaves the literal spelling, so resolving only what resolves would make -// the two halves disagree about the same directory. -func forms(path string) []string { - path = filepath.Clean(path) - out := []string{path} - if real, err := filepath.EvalSymlinks(path); err == nil { - if real = filepath.Clean(real); real != path { - out = append(out, real) - } - } - return out -} - // checkDurability answers the one question the other eleven checks never asked: // is there anything the cache holds that the vault does not? // @@ -436,15 +225,6 @@ func checkDurability(db *sql.DB) Check { return c } -// pluralWord is the ordinary -s pluraliser. The package's own plural() is the -// y/ies one, which memories need and notes do not. -func pluralWord(n int, word string) string { - if n == 1 { - return word - } - return word + "s" -} - func checkNotes(dir string, db *sql.DB) Check { c := Check{Name: "notes"} if db == nil { @@ -768,14 +548,6 @@ func checkAbandonment(db *sql.DB) Check { return c } -// pluralS is the plain -s plural; plural() above is the y/ies one. -func pluralS(n int) string { - if n == 1 { - return "" - } - return "s" -} - // checkMemoryReview is the PRODUCT RULE applied to quarantine: a feature that // silently queues machine-proposed memories and never says so is no better // than the unreviewed writes it replaced — the queue just fills up somewhere @@ -805,550 +577,3 @@ func checkMemoryReview(db *sql.DB) Check { c.Fix = "run `logos review` to accept or reject them" return c } - -func isAre(n int) string { - if n == 1 { - return "is" - } - return "are" -} - -func plural(n int) string { - if n == 1 { - return "y" - } - return "ies" -} - -// Hosts is the difference between "logos is installed" and "your agents can -// reach it", which are not the same thing and were never distinguished. -func checkHosts() Check { - var wired []string - for _, r := range setup.Plan(setup.Hosts()) { - if r.Outcome == setup.Pending { - wired = append(wired, r.Host) - } - } - return hostsCheck(wired) -} - -// hostsCheck is checkHosts's message-building split out from its detection, -// so the wording can be tested against a chosen list of detected hosts rather -// than whatever happens to be on the machine running the test. -// -// setup.Hosts() is a closed, curated list (see internal/setup's -// package doc) — never the whole set of MCP clients that exist. Every host -// this check names still leaves an open question about the ones it does not -// know, so both branches point at `logos setup --print-config`: the one -// answer that works regardless of which client the user is actually running. -func hostsCheck(wired []string) Check { - c := Check{Name: "agent hosts"} - if len(wired) == 0 { - c.State = Unknown - c.Detail = "no MCP hosts detected on this machine" - c.Fix = "install an MCP host such as Claude Code, Cursor, Codex, Cline, Devin or GitHub Copilot, then run `logos mcp install` " + - "— or run `logos setup --print-config` to wire any other MCP client by hand" - return c - } - // Detected is not the same as wired — Plan reports what is installed, not - // what points at logos. Say what was actually established. - c.State = OK - c.Detail = "detected: " + strings.Join(wired, ", ") - c.Fix = "run `logos doctor --integration` to prove they can reach this vault" + - "; for any other MCP client, `logos setup --print-config`" - return c -} - -// A binary registered twice pays its fixed per-session cost twice. This is not -// hypothetical: it doubled a real measured session from ~7,759 to ~13,161 -// tokens, the one configuration that breached the 10k-token ceiling on -// unmodified code (see the memory architecture plan's Step 0). -// -// Detection leans on logos's own signature rather than a path comparison: every -// registration setup writes invokes "mcp serve" (setup.go's Server.Args), so -// two entries under one host whose command both contain that phrase are the -// same binary reached two ways. The plugin is the exception that shipped: its -// launcher is bare `bin/mcp.sh` and types "mcp serve" inside the script, so a -// plugin next to a setup registration — the most common duplicate, since the -// README offers both routes — passed as healthy. The plugin is recognised by -// the name Claude Code gives it instead. -func checkDuplicateRegistration(hosts []setup.Host) Check { - c := Check{Name: "duplicate registration"} - checked := false - for _, h := range hosts { - if h.List == nil || h.Detect == nil || !h.Detect() { - continue - } - regs, err := h.List() - if err != nil { - continue - } - checked = true - var dupes []string - for _, r := range regs { - if strings.Contains(r.Command, "mcp serve") || strings.HasPrefix(r.Name, "plugin:logos:") { - dupes = append(dupes, r.Name) - } - } - if len(dupes) > 1 { - c.State = Failed - c.Detail = fmt.Sprintf("%s has logos registered %d times: %s", h.Name, len(dupes), strings.Join(dupes, ", ")) - c.Fix = "remove all but one of these entries — each one pays the fixed per-session cost again" - return c - } - } - if !checked { - c.State = Unknown - c.Detail = "no host exposed a readable registration list" - return c - } - c.State = OK - return c -} - -// checkCachedRegistration finds a host launching logos from inside npm's npx -// cache. `npx … setup` on 0.4.2 wired that path, every check passed, and weeks -// later npm pruned the file and the host could not start the server, with -// nothing tying it back to setup. ok is false when no such entry exists. -func checkCachedRegistration(hosts []setup.Host) (Check, bool) { - for _, h := range hosts { - if h.List == nil || h.Detect == nil || !h.Detect() { - continue - } - regs, err := h.List() - if err != nil { - continue - } - for _, r := range regs { - if !strings.Contains(r.Command, "mcp serve") { - continue - } - if strings.Contains(r.Command, "/_npx/") || strings.Contains(r.Command, `\_npx\`) { - return Check{ - Name: "host command", - State: Failed, - Detail: fmt.Sprintf("%s runs %s from npm's npx cache, which npm deletes when it prunes", h.Name, r.Name), - Fix: "run `npx -y @noeton/logos setup` again — it registers a command that does not live in the cache", - }, true - } - } - } - return Check{}, false -} - -// checkMissingRegistration finds a host launching logos from a path that no -// longer exists. Setup wires hosts to the binary it was run as, so a release -// binary run from Downloads and then moved onto PATH left every host pointing -// at nothing, while doctor said the hosts were fine. Only absolute paths are -// checked: `npx …` or a bare `logos` is resolved at launch, not a file here. -// ok is false when every registration's binary exists. -func checkMissingRegistration(hosts []setup.Host) (Check, bool) { - for _, h := range hosts { - if h.List == nil || h.Detect == nil || !h.Detect() { - continue - } - regs, err := h.List() - if err != nil { - continue - } - for _, r := range regs { - bin, _, ok := strings.Cut(r.Command, " mcp serve") - if !ok || !filepath.IsAbs(bin) { - continue - } - if _, err := os.Stat(bin); err != nil && os.IsNotExist(err) { - return Check{ - Name: "host command", - State: Failed, - Detail: fmt.Sprintf("%s runs %s from %s, which no longer exists", h.Name, r.Name, bin), - Fix: "run `logos setup` again from where logos is now — it rewires the hosts to that path", - }, true - } - } - } - return Check{}, false -} - -// checkOtherVault finds a host pinned to a vault other than the one recorded -// for this machine. `logos setup --vault B --host cursor` moved the record to B -// and left the other hosts on A, so a handoff between them silently split and -// doctor still listed every host as fine. ok is false when there is no -// recorded vault or every host that shows its vault is on it. -func checkOtherVault(hosts []setup.Host, recorded string) (Check, bool) { - if recorded == "" { - return Check{}, false - } - names, vaults := setup.OnOtherVault(hosts, recorded) - if len(names) == 0 { - return Check{}, false - } - return Check{ - Name: "hosts on another vault", - State: Failed, - Detail: fmt.Sprintf("%s uses %s, but this machine's vault is %s — checkpoints there are not seen here", names[0], vaults[0], recorded), - Fix: "run `logos mcp install` to point every host at " + recorded, - }, true -} - -// checkOldHostPin finds a host whose logos entry pins its vault as -// BRAIN_VAULT, the name 0.4.0 to 0.4.2 wrote. 0.5.0 does not read it, so that -// host's server opens the machine's vault — or an empty ~/logos — and the only -// thing that says so is a line on the MCP server's stderr, which no host shows. -// Doctor is where someone looks when their memory seems gone. ok is false when -// no entry pins the old name. -func checkOldHostPin(hosts []setup.Host) (Check, bool) { - for _, h := range hosts { - if h.Detect == nil || !h.Detect() { - continue - } - for _, e := range setup.PinnedEntries(h) { - old := e.Server.Env["BRAIN_VAULT"] - if old == "" || e.Vault != "" { - continue - } - return Check{ - Name: "host on a 0.4 pin", - State: Failed, - Detail: fmt.Sprintf("%s starts logos with BRAIN_VAULT=%s, which is not read since 0.5.0 — that host is not using %s", h.Name, old, old), - Fix: fmt.Sprintf("run `logos setup --vault %s` to re-pin it as LOGOS_VAULT", old), - }, true - } - } - return Check{}, false -} - -// checkPlugin compares the Logos plugin installed in Claude Code with this -// binary. Claude Code does not update a third-party marketplace by default and -// `logos update` replaces only the binary, so a plugin installed early keeps -// running its old hooks against a new server indefinitely — a machine sat on -// 0.1.2 against 0.4.2 with nothing saying so. ok is false when no plugin is -// installed: most people running doctor never used it. -func checkPlugin(version string) (c Check, ok bool) { - c = CheckPlugin(version) - return c, c.Name != "" -} - -// CheckPlugin is checkPlugin for setup, which skips Claude Code on the -// plugin's account and so owes the same warning. A zero Check means no plugin -// connects Claude Code. -func CheckPlugin(version string) (c Check) { - r := setup.LogosPluginRecord() - if !r.Installed { - return Check{} - } - c = Check{Name: "Claude Code plugin"} - // An installed plugin that Claude Code never loads — turned off in - // /plugin, or installed for one project — runs no hooks, so nothing - // restores the last checkpoint when a session starts, and doctor used to - // say nothing at all about it. - if !r.Connects { - c.State = Warn - // A project's own settings can still enable it, and they cannot be - // read from here, so the claim is hedged the way setup hedges it. - c.Detail = "the Logos plugin is " + r.Why + ", so unless a project enables it no session starts with your last checkpoint" - c.Fix = "enable it for your user in Claude Code's /plugin, then `claude mcp remove --scope user logos` so logos is not registered twice" - return c - } - // An installed, enabled, current plugin whose server Claude Code is - // refusing to start looks perfect to every other check here, and the line - // Claude Code prints about it scrolls past at session start. Until the - // window is out, this machine has no Logos in any new session. - if left, skipped := setup.PluginConnectionSkipped(time.Now()); skipped { - c.State = Warn - c.Detail = fmt.Sprintf("Claude Code cached a failed start of the Logos plugin's server and is skipping it for another %s", left.Round(time.Second)) - c.Fix = "reconnect with /mcp in Claude Code, or start a session after that — and make sure LOGOS_VAULT is unset or right, since a start against the wrong vault is what caches this" - return c - } - pluginVersion := r.Version - stale, ranked := buildinfo.Older(pluginVersion, version) - if !ranked { - c.State = Unknown - c.Detail = fmt.Sprintf("plugin %q installed; this logos (%s) has no release number to compare it with", pluginVersion, version) - return c - } - if stale { - c.State = Warn - c.Detail = fmt.Sprintf("the Logos plugin is %s but this logos is %s — its hooks are older than the server", pluginVersion, strings.TrimPrefix(version, "v")) - c.Fix = "run `claude plugin marketplace update logos && claude plugin update logos@logos`, then restart Claude Code" - return c - } - c.State = OK - c.Detail = "plugin " + pluginVersion - return c -} - -// HomebrewPrefixes are where a Homebrew logos is looked for: HOMEBREW_PREFIX, -// which `brew shellenv` sets, then brew's defaults on Apple silicon, Intel -// macOS and Linux. A variable so a test never finds the machine's real brew. -var HomebrewPrefixes = func() []string { - prefixes := []string{"/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"} - if p := os.Getenv("HOMEBREW_PREFIX"); p != "" { - prefixes = append([]string{p}, prefixes...) - } - return prefixes -} - -// CheckOtherInstall finds a second logos that runs instead of this one. An npx -// setup pins a copy in ~/.local/bin, which Claude Code's installer puts ahead -// of Homebrew on PATH, so after a later `brew install` the shell, setup and so -// every host kept running the old copy, `brew upgrade` reached nothing that -// ran, and nothing said there were two. A zero Check means none was found. -func CheckOtherInstall(self, version string) Check { - c := Check{Name: "logos installs", State: Warn} - version = strings.TrimPrefix(version, "v") - switch selfupdate.DetectInstall(self) { - case selfupdate.Homebrew: - found, err := exec.LookPath("logos") - if err != nil { - return Check{} - } - if resolved, err := filepath.EvalSymlinks(found); err != nil || resolved == self { - return Check{} - } - theirs, ok := logosVersion(found) - if !ok { - return Check{} - } - brew := self - if stable := selfupdate.HomebrewStablePath(self); stable != "" { - brew = stable - } - c.Detail = fmt.Sprintf("`logos` in a shell runs %s (logos %s), not Homebrew's logos %s — setup run as `logos` wires hosts to that copy, and `brew upgrade` never reaches it", found, theirs, version) - c.Fix = fmt.Sprintf("remove %s if it is an old copy, then run `%s setup` again", found, brew) - return c - case selfupdate.Standalone: - if opt, theirs := HomebrewInstall(self); opt != "" { - c.Detail = fmt.Sprintf("Homebrew's logos %s is installed at %s, but this is %s (logos %s) — hosts wired by setup from here launch this copy, which `brew upgrade` never reaches", theirs, opt, self, version) - c.Fix = fmt.Sprintf("remove %s if it is an old copy, then run `%s setup` again", self, opt) - return c - } - } - return Check{} -} - -// HomebrewInstall is the logos Homebrew has installed, and its version, or "" -// when there is none other than self. The opt path is returned rather than the -// Cellar one it links to: that link is what survives an upgrade, so it is the -// path a host config should name. -func HomebrewInstall(self string) (path, version string) { - for _, prefix := range HomebrewPrefixes() { - opt := filepath.Join(prefix, "opt", selfupdate.HomebrewFormula, "bin", "logos") - if resolved, err := filepath.EvalSymlinks(opt); err != nil || resolved == self { - continue - } - if v, ok := logosVersion(opt); ok { - return opt, v - } - } - return "", "" -} - -// logosVersion is what a logos at path answers to --version. Bounded, because -// the file may be any program with that name. -func logosVersion(path string) (string, bool) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - out, err := exec.CommandContext(ctx, path, "--version").Output() - fields := strings.Fields(string(out)) - if err != nil || len(fields) < 2 || fields[0] != "logos" { - return "", false - } - return strings.TrimPrefix(fields[1], "v"), true -} - -// --- helpers ----------------------------------------------------------------- - -func newestMarkdown(dir string) (time.Time, error) { - var newest time.Time - err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return nil // an unreadable corner should not fail the whole check - } - if d.IsDir() && strings.HasPrefix(d.Name(), ".") { - return filepath.SkipDir - } - if d.IsDir() || !strings.HasSuffix(strings.ToLower(d.Name()), ".md") { - return nil - } - info, err := d.Info() - if err != nil { - return nil - } - if info.ModTime().After(newest) { - newest = info.ModTime() - } - return nil - }) - return newest, err -} - -// latestCheckpoint reads the most recent checkpoint across every project, off -// disk rather than from the index — the file is the record, and this check must -// work on a vault that has never been indexed. -func latestCheckpoint(vault string) (ts time.Time, project, agent string, err error) { - // Walked, not listed. Checkpoints live at sessions//.md, so - // reading only the top level of sessions/ reports "no checkpoints yet" on a - // vault full of them — which would make this check quietly useless in - // exactly the case it exists to report on. - dir := filepath.Join(vault, session.CheckpointDir) - if _, err := os.Stat(dir); os.IsNotExist(err) { - return time.Time{}, "", "", nil - } - err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { - // Not every .md under sessions/ is a checkpoint. uncommitted.md holds - // working notes and is rewritten on every note_progress, so it is almost - // always the newest file here — which made this check answer "last - // checkpoint 5 hours ago" for a vault whose last actual checkpoint was - // days old. That is the precise failure the continuity check exists to - // catch, reported as its own opposite. - if err != nil || d.IsDir() || !session.IsCheckpointFile(d.Name()) { - return nil - } - info, err := d.Info() - if err != nil { - return nil - } - if info.ModTime().After(ts) { - ts = info.ModTime() - project, agent = describeCheckpoint(path) - } - return nil - }) - if err != nil { - return time.Time{}, "", "", err - } - return ts, project, agent, nil -} - -// describeCheckpoint pulls the project and agent out of a checkpoint's -// frontmatter. Best effort: a checkpoint that does not parse still counts as a -// checkpoint, it just cannot name itself. -func describeCheckpoint(path string) (project, agent string) { - raw, err := os.ReadFile(path) - if err != nil { - return "", "" - } - for _, line := range strings.Split(string(raw), "\n") { - line = strings.TrimSpace(line) - if v, ok := strings.CutPrefix(line, "project:"); ok { - project = strings.TrimSpace(v) - } - if v, ok := strings.CutPrefix(line, "agent:"); ok { - agent = strings.TrimSpace(v) - } - if line == "---" && project != "" { - break - } - } - return project, agent -} - -// roughly renders a duration the way a person would say it. -func roughly(d time.Duration) string { - switch { - case d < time.Minute: - return "moments" - case d < time.Hour: - return count(int(d.Minutes()), "minute") - case d < 48*time.Hour: - return count(int(d.Hours()), "hour") - default: - return count(int(d.Hours()/24), "day") - } -} - -// count saves the "1 hours" that makes a tool feel unfinished. -func count(n int, unit string) string { - if n == 1 { - return "1 " + unit - } - return fmt.Sprintf("%d %ss", n, unit) -} - -// checkPrivacy reports what the rest of the machine can read. -// -// Everything Logos knows lives in one directory in the user's home. On a -// personal laptop that is nobody but them; on a shared box, a work machine with -// a management agent, or anything with another account on it, the mode bits are -// the only thing standing between a second user and every prompt the first one -// typed. That is worth one line in `logos doctor` whether or not it is worth -// worrying about, because "who can read this" is not a question you can answer -// by looking at the app. -// -// It reports rather than repairs. New files and directories are created private -// (see internal/vault.FileMode), but a vault that predates that, or one the user -// deliberately opened up to sync it, is theirs — silently chmod-ing somebody's -// filesystem is exactly the kind of unrequested help this product does not do. -// So: name the paths, give the command, let them decide. -func checkPrivacy(dir string) Check { - c := Check{Name: "privacy"} - if strings.TrimSpace(dir) == "" { - c.State, c.Detail = Unknown, "no vault path resolved" - return c - } - info, err := os.Stat(dir) - if err != nil { - c.State, c.Detail = Unknown, "vault not readable: "+err.Error() - return c - } - // Name what is actually exposed rather than the directory alone. "0755 on a - // folder" means nothing to most people; "your prompt log and the database - // holding every note" means something. - // - // The contents are checked even when the directory itself is locked down, - // and that is the whole point of the list. index.Open sets the mode on - // index.db advisorily — `_ = vault.PrivateSiblings(...)`, with a comment - // naming this check as what would catch a failure — so answering from the - // directory's mode alone reported "readable only by you" over a 0644 copy of - // every note, memory and checkpoint in the vault. A private directory is - // also one chmod, one sync client or one backup away from not being one. - var open []string - for _, rel := range []string{"activity", ".logos/index.db", ".logos/index.db-wal", "memories", "sessions"} { - p := filepath.Join(dir, rel) - fi, err := os.Stat(p) - if err != nil { - continue - } - if fi.Mode().Perm()&0o077 != 0 { - open = append(open, rel) - } - } - if info.Mode().Perm()&0o077 == 0 && len(open) == 0 { - c.State, c.Detail = OK, "the vault is readable only by you" - return c - } - - c.State = Failed - if info.Mode().Perm()&0o077 != 0 { - c.Detail = fmt.Sprintf("%s is readable by other users on this machine", dir) - if len(open) > 0 { - c.Detail += " — so is " + strings.Join(open, ", ") - } - } else { - // The directory is closed but something inside it is not. Say so - // precisely: the fix is the same command, but "your vault is fine except - // for the file holding all of it" is a different sentence. - c.Detail = fmt.Sprintf("%s is private, but %s inside it %s readable by other users on this machine", - dir, strings.Join(open, ", "), isAre(len(open))) - } - c.Fix = "run `chmod -R go-rwx " + shellArg(dir) + "` if this machine has other accounts on it" - return c -} - -// shellArg makes a path safe to paste into a shell as one argument. A Fix is a -// command the user copies, and a vault under "~/My Drive" pasted bare is two -// arguments that set up the wrong directory. Single quotes, because inside them -// the shell expands nothing — a path with a "$" in it stays that path. -func shellArg(s string) string { - plain := s != "" - for _, r := range s { - if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("/._-~+,:@%=", r)) { - plain = false - break - } - } - if plain { - return s - } - return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" -} diff --git a/internal/health/helpers.go b/internal/health/helpers.go new file mode 100644 index 0000000..e181660 --- /dev/null +++ b/internal/health/helpers.go @@ -0,0 +1,168 @@ +package health + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Coder8124/logos/internal/session" +) + +// pluralWord is the ordinary -s pluraliser. The package's own plural() is the +// y/ies one, which memories need and notes do not. +func pluralWord(n int, word string) string { + if n == 1 { + return word + } + return word + "s" +} + +// pluralS is the plain -s plural; plural() above is the y/ies one. +func pluralS(n int) string { + if n == 1 { + return "" + } + return "s" +} + +func isAre(n int) string { + if n == 1 { + return "is" + } + return "are" +} + +func plural(n int) string { + if n == 1 { + return "y" + } + return "ies" +} + +func newestMarkdown(dir string) (time.Time, error) { + var newest time.Time + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil // an unreadable corner should not fail the whole check + } + if d.IsDir() && strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } + if d.IsDir() || !strings.HasSuffix(strings.ToLower(d.Name()), ".md") { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + if info.ModTime().After(newest) { + newest = info.ModTime() + } + return nil + }) + return newest, err +} + +// latestCheckpoint reads the most recent checkpoint across every project, off +// disk rather than from the index — the file is the record, and this check must +// work on a vault that has never been indexed. +func latestCheckpoint(vault string) (ts time.Time, project, agent string, err error) { + // Walked, not listed. Checkpoints live at sessions//.md, so + // reading only the top level of sessions/ reports "no checkpoints yet" on a + // vault full of them — which would make this check quietly useless in + // exactly the case it exists to report on. + dir := filepath.Join(vault, session.CheckpointDir) + if _, err := os.Stat(dir); os.IsNotExist(err) { + return time.Time{}, "", "", nil + } + err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + // Not every .md under sessions/ is a checkpoint. uncommitted.md holds + // working notes and is rewritten on every note_progress, so it is almost + // always the newest file here — which made this check answer "last + // checkpoint 5 hours ago" for a vault whose last actual checkpoint was + // days old. That is the precise failure the continuity check exists to + // catch, reported as its own opposite. + if err != nil || d.IsDir() || !session.IsCheckpointFile(d.Name()) { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + if info.ModTime().After(ts) { + ts = info.ModTime() + project, agent = describeCheckpoint(path) + } + return nil + }) + if err != nil { + return time.Time{}, "", "", err + } + return ts, project, agent, nil +} + +// describeCheckpoint pulls the project and agent out of a checkpoint's +// frontmatter. Best effort: a checkpoint that does not parse still counts as a +// checkpoint, it just cannot name itself. +func describeCheckpoint(path string) (project, agent string) { + raw, err := os.ReadFile(path) + if err != nil { + return "", "" + } + for _, line := range strings.Split(string(raw), "\n") { + line = strings.TrimSpace(line) + if v, ok := strings.CutPrefix(line, "project:"); ok { + project = strings.TrimSpace(v) + } + if v, ok := strings.CutPrefix(line, "agent:"); ok { + agent = strings.TrimSpace(v) + } + if line == "---" && project != "" { + break + } + } + return project, agent +} + +// roughly renders a duration the way a person would say it. +func roughly(d time.Duration) string { + switch { + case d < time.Minute: + return "moments" + case d < time.Hour: + return count(int(d.Minutes()), "minute") + case d < 48*time.Hour: + return count(int(d.Hours()), "hour") + default: + return count(int(d.Hours()/24), "day") + } +} + +// count saves the "1 hours" that makes a tool feel unfinished. +func count(n int, unit string) string { + if n == 1 { + return "1 " + unit + } + return fmt.Sprintf("%d %ss", n, unit) +} + +// shellArg makes a path safe to paste into a shell as one argument. A Fix is a +// command the user copies, and a vault under "~/My Drive" pasted bare is two +// arguments that set up the wrong directory. Single quotes, because inside them +// the shell expands nothing — a path with a "$" in it stays that path. +func shellArg(s string) string { + plain := s != "" + for _, r := range s { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("/._-~+,:@%=", r)) { + plain = false + break + } + } + if plain { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/internal/health/hosts.go b/internal/health/hosts.go new file mode 100644 index 0000000..c2b24aa --- /dev/null +++ b/internal/health/hosts.go @@ -0,0 +1,269 @@ +package health + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Coder8124/logos/internal/buildinfo" + "github.com/Coder8124/logos/internal/setup" +) + +// Hosts is the difference between "logos is installed" and "your agents can +// reach it", which are not the same thing and were never distinguished. +func checkHosts() Check { + var wired []string + for _, r := range setup.Plan(setup.Hosts()) { + if r.Outcome == setup.Pending { + wired = append(wired, r.Host) + } + } + return hostsCheck(wired) +} + +// hostsCheck is checkHosts's message-building split out from its detection, +// so the wording can be tested against a chosen list of detected hosts rather +// than whatever happens to be on the machine running the test. +// +// setup.Hosts() is a closed, curated list (see internal/setup's +// package doc) — never the whole set of MCP clients that exist. Every host +// this check names still leaves an open question about the ones it does not +// know, so both branches point at `logos setup --print-config`: the one +// answer that works regardless of which client the user is actually running. +func hostsCheck(wired []string) Check { + c := Check{Name: "agent hosts"} + if len(wired) == 0 { + c.State = Unknown + c.Detail = "no MCP hosts detected on this machine" + c.Fix = "install an MCP host such as Claude Code, Cursor, Codex, Cline, Devin or GitHub Copilot, then run `logos mcp install` " + + "— or run `logos setup --print-config` to wire any other MCP client by hand" + return c + } + // Detected is not the same as wired — Plan reports what is installed, not + // what points at logos. Say what was actually established. + c.State = OK + c.Detail = "detected: " + strings.Join(wired, ", ") + c.Fix = "run `logos doctor --integration` to prove they can reach this vault" + + "; for any other MCP client, `logos setup --print-config`" + return c +} + +// A binary registered twice pays its fixed per-session cost twice. This is not +// hypothetical: it doubled a real measured session from ~7,759 to ~13,161 +// tokens, the one configuration that breached the 10k-token ceiling on +// unmodified code (see the memory architecture plan's Step 0). +// +// Detection leans on logos's own signature rather than a path comparison: every +// registration setup writes invokes "mcp serve" (setup.go's Server.Args), so +// two entries under one host whose command both contain that phrase are the +// same binary reached two ways. The plugin is the exception that shipped: its +// launcher is bare `bin/mcp.sh` and types "mcp serve" inside the script, so a +// plugin next to a setup registration — the most common duplicate, since the +// README offers both routes — passed as healthy. The plugin is recognised by +// the name Claude Code gives it instead. +func checkDuplicateRegistration(hosts []setup.Host) Check { + c := Check{Name: "duplicate registration"} + checked := false + for _, h := range hosts { + if h.List == nil || h.Detect == nil || !h.Detect() { + continue + } + regs, err := h.List() + if err != nil { + continue + } + checked = true + var dupes []string + for _, r := range regs { + if strings.Contains(r.Command, "mcp serve") || strings.HasPrefix(r.Name, "plugin:logos:") { + dupes = append(dupes, r.Name) + } + } + if len(dupes) > 1 { + c.State = Failed + c.Detail = fmt.Sprintf("%s has logos registered %d times: %s", h.Name, len(dupes), strings.Join(dupes, ", ")) + c.Fix = "remove all but one of these entries — each one pays the fixed per-session cost again" + return c + } + } + if !checked { + c.State = Unknown + c.Detail = "no host exposed a readable registration list" + return c + } + c.State = OK + return c +} + +// checkCachedRegistration finds a host launching logos from inside npm's npx +// cache. `npx … setup` on 0.4.2 wired that path, every check passed, and weeks +// later npm pruned the file and the host could not start the server, with +// nothing tying it back to setup. ok is false when no such entry exists. +func checkCachedRegistration(hosts []setup.Host) (Check, bool) { + for _, h := range hosts { + if h.List == nil || h.Detect == nil || !h.Detect() { + continue + } + regs, err := h.List() + if err != nil { + continue + } + for _, r := range regs { + if !strings.Contains(r.Command, "mcp serve") { + continue + } + if strings.Contains(r.Command, "/_npx/") || strings.Contains(r.Command, `\_npx\`) { + return Check{ + Name: "host command", + State: Failed, + Detail: fmt.Sprintf("%s runs %s from npm's npx cache, which npm deletes when it prunes", h.Name, r.Name), + Fix: "run `npx -y @noeton/logos setup` again — it registers a command that does not live in the cache", + }, true + } + } + } + return Check{}, false +} + +// checkMissingRegistration finds a host launching logos from a path that no +// longer exists. Setup wires hosts to the binary it was run as, so a release +// binary run from Downloads and then moved onto PATH left every host pointing +// at nothing, while doctor said the hosts were fine. Only absolute paths are +// checked: `npx …` or a bare `logos` is resolved at launch, not a file here. +// ok is false when every registration's binary exists. +func checkMissingRegistration(hosts []setup.Host) (Check, bool) { + for _, h := range hosts { + if h.List == nil || h.Detect == nil || !h.Detect() { + continue + } + regs, err := h.List() + if err != nil { + continue + } + for _, r := range regs { + bin, _, ok := strings.Cut(r.Command, " mcp serve") + if !ok || !filepath.IsAbs(bin) { + continue + } + if _, err := os.Stat(bin); err != nil && os.IsNotExist(err) { + return Check{ + Name: "host command", + State: Failed, + Detail: fmt.Sprintf("%s runs %s from %s, which no longer exists", h.Name, r.Name, bin), + Fix: "run `logos setup` again from where logos is now — it rewires the hosts to that path", + }, true + } + } + } + return Check{}, false +} + +// checkOtherVault finds a host pinned to a vault other than the one recorded +// for this machine. `logos setup --vault B --host cursor` moved the record to B +// and left the other hosts on A, so a handoff between them silently split and +// doctor still listed every host as fine. ok is false when there is no +// recorded vault or every host that shows its vault is on it. +func checkOtherVault(hosts []setup.Host, recorded string) (Check, bool) { + if recorded == "" { + return Check{}, false + } + names, vaults := setup.OnOtherVault(hosts, recorded) + if len(names) == 0 { + return Check{}, false + } + return Check{ + Name: "hosts on another vault", + State: Failed, + Detail: fmt.Sprintf("%s uses %s, but this machine's vault is %s — checkpoints there are not seen here", names[0], vaults[0], recorded), + Fix: "run `logos mcp install` to point every host at " + recorded, + }, true +} + +// checkOldHostPin finds a host whose logos entry pins its vault as +// BRAIN_VAULT, the name 0.4.0 to 0.4.2 wrote. 0.5.0 does not read it, so that +// host's server opens the machine's vault — or an empty ~/logos — and the only +// thing that says so is a line on the MCP server's stderr, which no host shows. +// Doctor is where someone looks when their memory seems gone. ok is false when +// no entry pins the old name. +func checkOldHostPin(hosts []setup.Host) (Check, bool) { + for _, h := range hosts { + if h.Detect == nil || !h.Detect() { + continue + } + for _, e := range setup.PinnedEntries(h) { + old := e.Server.Env["BRAIN_VAULT"] + if old == "" || e.Vault != "" { + continue + } + return Check{ + Name: "host on a 0.4 pin", + State: Failed, + Detail: fmt.Sprintf("%s starts logos with BRAIN_VAULT=%s, which is not read since 0.5.0 — that host is not using %s", h.Name, old, old), + Fix: fmt.Sprintf("run `logos setup --vault %s` to re-pin it as LOGOS_VAULT", old), + }, true + } + } + return Check{}, false +} + +// checkPlugin compares the Logos plugin installed in Claude Code with this +// binary. Claude Code does not update a third-party marketplace by default and +// `logos update` replaces only the binary, so a plugin installed early keeps +// running its old hooks against a new server indefinitely — a machine sat on +// 0.1.2 against 0.4.2 with nothing saying so. ok is false when no plugin is +// installed: most people running doctor never used it. +func checkPlugin(version string) (c Check, ok bool) { + c = CheckPlugin(version) + return c, c.Name != "" +} + +// CheckPlugin is checkPlugin for setup, which skips Claude Code on the +// plugin's account and so owes the same warning. A zero Check means no plugin +// connects Claude Code. +func CheckPlugin(version string) (c Check) { + r := setup.LogosPluginRecord() + if !r.Installed { + return Check{} + } + c = Check{Name: "Claude Code plugin"} + // An installed plugin that Claude Code never loads — turned off in + // /plugin, or installed for one project — runs no hooks, so nothing + // restores the last checkpoint when a session starts, and doctor used to + // say nothing at all about it. + if !r.Connects { + c.State = Warn + // A project's own settings can still enable it, and they cannot be + // read from here, so the claim is hedged the way setup hedges it. + c.Detail = "the Logos plugin is " + r.Why + ", so unless a project enables it no session starts with your last checkpoint" + c.Fix = "enable it for your user in Claude Code's /plugin, then `claude mcp remove --scope user logos` so logos is not registered twice" + return c + } + // An installed, enabled, current plugin whose server Claude Code is + // refusing to start looks perfect to every other check here, and the line + // Claude Code prints about it scrolls past at session start. Until the + // window is out, this machine has no Logos in any new session. + if left, skipped := setup.PluginConnectionSkipped(time.Now()); skipped { + c.State = Warn + c.Detail = fmt.Sprintf("Claude Code cached a failed start of the Logos plugin's server and is skipping it for another %s", left.Round(time.Second)) + c.Fix = "reconnect with /mcp in Claude Code, or start a session after that — and make sure LOGOS_VAULT is unset or right, since a start against the wrong vault is what caches this" + return c + } + pluginVersion := r.Version + stale, ranked := buildinfo.Older(pluginVersion, version) + if !ranked { + c.State = Unknown + c.Detail = fmt.Sprintf("plugin %q installed; this logos (%s) has no release number to compare it with", pluginVersion, version) + return c + } + if stale { + c.State = Warn + c.Detail = fmt.Sprintf("the Logos plugin is %s but this logos is %s — its hooks are older than the server", pluginVersion, strings.TrimPrefix(version, "v")) + c.Fix = "run `claude plugin marketplace update logos && claude plugin update logos@logos`, then restart Claude Code" + return c + } + c.State = OK + c.Detail = "plugin " + pluginVersion + return c +} diff --git a/internal/health/installs.go b/internal/health/installs.go new file mode 100644 index 0000000..c671374 --- /dev/null +++ b/internal/health/installs.go @@ -0,0 +1,92 @@ +package health + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/Coder8124/logos/internal/selfupdate" +) + +// HomebrewPrefixes are where a Homebrew logos is looked for: HOMEBREW_PREFIX, +// which `brew shellenv` sets, then brew's defaults on Apple silicon, Intel +// macOS and Linux. A variable so a test never finds the machine's real brew. +var HomebrewPrefixes = func() []string { + prefixes := []string{"/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"} + if p := os.Getenv("HOMEBREW_PREFIX"); p != "" { + prefixes = append([]string{p}, prefixes...) + } + return prefixes +} + +// CheckOtherInstall finds a second logos that runs instead of this one. An npx +// setup pins a copy in ~/.local/bin, which Claude Code's installer puts ahead +// of Homebrew on PATH, so after a later `brew install` the shell, setup and so +// every host kept running the old copy, `brew upgrade` reached nothing that +// ran, and nothing said there were two. A zero Check means none was found. +func CheckOtherInstall(self, version string) Check { + c := Check{Name: "logos installs", State: Warn} + version = strings.TrimPrefix(version, "v") + switch selfupdate.DetectInstall(self) { + case selfupdate.Homebrew: + found, err := exec.LookPath("logos") + if err != nil { + return Check{} + } + if resolved, err := filepath.EvalSymlinks(found); err != nil || resolved == self { + return Check{} + } + theirs, ok := logosVersion(found) + if !ok { + return Check{} + } + brew := self + if stable := selfupdate.HomebrewStablePath(self); stable != "" { + brew = stable + } + c.Detail = fmt.Sprintf("`logos` in a shell runs %s (logos %s), not Homebrew's logos %s — setup run as `logos` wires hosts to that copy, and `brew upgrade` never reaches it", found, theirs, version) + c.Fix = fmt.Sprintf("remove %s if it is an old copy, then run `%s setup` again", found, brew) + return c + case selfupdate.Standalone: + if opt, theirs := HomebrewInstall(self); opt != "" { + c.Detail = fmt.Sprintf("Homebrew's logos %s is installed at %s, but this is %s (logos %s) — hosts wired by setup from here launch this copy, which `brew upgrade` never reaches", theirs, opt, self, version) + c.Fix = fmt.Sprintf("remove %s if it is an old copy, then run `%s setup` again", self, opt) + return c + } + } + return Check{} +} + +// HomebrewInstall is the logos Homebrew has installed, and its version, or "" +// when there is none other than self. The opt path is returned rather than the +// Cellar one it links to: that link is what survives an upgrade, so it is the +// path a host config should name. +func HomebrewInstall(self string) (path, version string) { + for _, prefix := range HomebrewPrefixes() { + opt := filepath.Join(prefix, "opt", selfupdate.HomebrewFormula, "bin", "logos") + if resolved, err := filepath.EvalSymlinks(opt); err != nil || resolved == self { + continue + } + if v, ok := logosVersion(opt); ok { + return opt, v + } + } + return "", "" +} + +// logosVersion is what a logos at path answers to --version. Bounded, because +// the file may be any program with that name. +func logosVersion(path string) (string, bool) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, path, "--version").Output() + fields := strings.Fields(string(out)) + if err != nil || len(fields) < 2 || fields[0] != "logos" { + return "", false + } + return strings.TrimPrefix(fields[1], "v"), true +} diff --git a/internal/health/vault.go b/internal/health/vault.go new file mode 100644 index 0000000..7dd5dbd --- /dev/null +++ b/internal/health/vault.go @@ -0,0 +1,286 @@ +package health + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Coder8124/logos/internal/session" + "github.com/Coder8124/logos/internal/vault" +) + +// The vault is the product. If it is missing or unwritable, nothing else +// matters, so this runs first and says exactly which of the two it is. +func checkVault(dir string) Check { + c := Check{Name: "vault"} + if strings.TrimSpace(dir) == "" { + c.State, c.Detail = Failed, "no vault path resolved" + c.Fix = "set LOGOS_VAULT, or run `logos setup`" + return c + } + info, err := os.Stat(dir) + if os.IsNotExist(err) { + c.State, c.Detail = Failed, dir+" does not exist" + c.Fix = "run `logos setup --vault " + shellArg(dir) + "`" + // This machine's recorded vault being absent is usually an unmounted + // drive, and setup at that path makes an empty vault where it mounts. + if os.Getenv("LOGOS_VAULT") == "" && dir == vault.Pointer() { + c.Detail = dir + " does not exist — it is the vault recorded for this machine" + c.Fix = "reconnect the drive it is on; to use a different vault, run `logos setup --vault `" + } + return c + } + if err != nil { + c.State, c.Detail = Failed, err.Error() + return c + } + if !info.IsDir() { + c.State, c.Detail = Failed, dir+" is a file, not a directory" + return c + } + // Readable is not enough: logos writes checkpoints here, and finding that + // out at handoff time is finding out too late. + probe := filepath.Join(dir, ".logos-write-probe") + if err := os.WriteFile(probe, []byte("x"), 0o600); err != nil { + c.State, c.Detail = Failed, dir+" is not writable: "+err.Error() + c.Fix = "check permissions; checkpoints cannot be saved" + return c + } + os.Remove(probe) + + // The vault is writable and real. One question left: is it the vault the + // user meant, or a scratch directory that outlived the command that made it? + // + // `logos setup --vault ` records its target in + // os.UserConfigDir()/logos/vault-path, and that pointer is what every front + // end reads when LOGOS_VAULT is unset — including a host launched from + // Finder, which inherits no shell and has no other way to find the vault. So running setup + // against a scratch vault, which CONTRIBUTING.md tells contributors to do, + // silently repoints the real installation at a temporary directory. Nothing + // then fails: index.Open creates whatever it is handed, so the vault is + // present, writable and empty, and every check downstream honestly reports + // zero. The author's own pointer named a /var/folders temp path for a day + // while doctor called it healthy. + // + // The recorded pointer is what is checked, not the resolved directory. An + // explicit LOGOS_VAULT is a deliberate choice scoped to one command and is + // nobody's business to complain about; the pointer outlives the session. + if rec := vault.Recorded(); rec != "" && UnderTempDir(rec) { + c.State = Failed + c.Detail = rec + " is a temporary directory, recorded as the vault every front end opens — it will be empty or gone" + c.Fix = "run `logos setup --vault ` to repoint it, or `logos doctor` with LOGOS_VAULT set to check a scratch vault without recording it" + return c + } + + // A blocklist of temporary roots is always one directory short: the pointer + // that actually did the damage named ~/.claude/jobs//tmp/survey-vault, + // which is not a system temp root at all, and UnderTempDir walked straight + // past it. So ask the question that does not depend on knowing where the + // next harness will put its scratch directories. + // + // Neither half is a fault on its own. A vault with no checkpoints is a + // perfectly good new install, and history in a second vault is a perfectly + // good second vault. It is the pair that means the pointer is wrong — and + // the pair is precisely what the user cannot see, because every command + // they run reads the empty one and truthfully reports nothing. + // + // Scoped to the recorded pointer for the same reason the check above is: an + // explicit LOGOS_VAULT is a scratch vault someone chose for this one + // command, and it is supposed to be empty. Complaining about it would make + // the documented workflow print a failure on every run. + if os.Getenv("LOGOS_VAULT") == "" { + if other, n := populatedVaultElsewhere(dir); n > 0 { + c.State = Failed + c.Detail = fmt.Sprintf("no checkpoints here, but %s holds %d — every front end is reading this empty vault instead", other, n) + c.Fix = "run `logos setup --vault " + shellArg(other) + "` to repoint this machine" + return c + } + } + + c.State, c.Detail = OK, dir + return c +} + +// populatedVaultElsewhere reports another vault on disk that has history in it, +// when the vault in use has none. Only the default location is looked at: it is +// where a vault is unless somebody moved it, and searching the disk for vaults +// would be a slow answer to a question doctor asks on every run. +func populatedVaultElsewhere(dir string) (string, int) { + // Only "is there any", so stop at the first one. This runs on every doctor. + if checkpointCount(dir, 1) > 0 { + return "", 0 + } + home, err := os.UserHomeDir() + if err != nil { + return "", 0 + } + def := filepath.Join(home, "logos") + for _, a := range forms(def) { + for _, b := range forms(dir) { + if a == b { + return "", 0 + } + } + } + // The real total here: it is printed, and "28 checkpoints sit in ~/logos" is + // the number that tells the user which vault is the one they meant. + if n := checkpointCount(def, 0); n > 0 { + return def, n + } + return "", 0 +} + +// checkpointCount totals the checkpoints across every project in a vault, +// reading the markdown rather than the index — the index of the vault nobody is +// using is exactly the one that will not be built. +// +// stopAt bounds the work for the caller that only needs "is there any": names +// are counted rather than files parsed, because doctor runs this on every +// invocation over two vaults, and parsing a user's entire history to answer a +// yes/no question makes the command slower the longer they have used it. +func checkpointCount(dir string, stopAt int) int { + // Scopes, so doctor's "is there any work here" answer is not no on a vault + // whose every checkpoint was written from a git worktree. + projects, err := session.Scopes(dir) + if err != nil { + return 0 + } + total := 0 + for _, p := range projects { + entries, err := os.ReadDir(filepath.Join(dir, session.CheckpointDir, p)) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() || !session.IsCheckpointFile(e.Name()) { + continue + } + total++ + if stopAt > 0 && total >= stopAt { + return total + } + } + } + return total +} + +// UnderTempDir reports whether path sits inside a system temporary directory. +// +// Every well-known temp root is checked, not just os.TempDir(). os.TempDir() +// answers $TMPDIR, which on macOS is a per-user directory under /var/folders — +// and the directory that actually repointed a real installation was under +// /tmp, which shares no prefix with it. Agent scratchpads, mktemp -d scripts +// and half the shell in this repository use /tmp; a guard that cannot see it is +// a guard against the one case that has never happened. +// +// Both sides are resolved through symlinks first: /tmp answers to /private/tmp +// and /var/folders/... to /private/var/folders/..., and a string compare of the +// two forms says they are unrelated. +func UnderTempDir(path string) bool { + // An agent's per-job scratch directory is a temporary root that lives under + // $HOME, so none of the system roots below match it. ~/.claude/jobs//tmp + // is where the pointer that broke a real installation was made. + roots := []string{os.TempDir(), "/tmp", "/private/tmp", "/var/tmp", "/private/var/tmp"} + if home, err := os.UserHomeDir(); err == nil { + roots = append(roots, filepath.Join(home, ".claude", "jobs"), filepath.Join(home, ".claude", "tmp")) + } + for _, p := range forms(path) { + for _, root := range roots { + for _, t := range forms(root) { + // Separator-anchored, so /tmpfoo is not read as living under /tmp. + if p == t || strings.HasPrefix(p, t+string(filepath.Separator)) { + return true + } + } + } + } + return false +} + +// forms returns the spellings of a path that have to be compared: the cleaned +// path, and its symlink-resolved form when it has one. Both are needed because +// only one side of the comparison usually exists on disk — EvalSymlinks("/tmp") +// yields /private/tmp, but EvalSymlinks("/tmp/a-vault-that-was-deleted") fails +// and leaves the literal spelling, so resolving only what resolves would make +// the two halves disagree about the same directory. +func forms(path string) []string { + path = filepath.Clean(path) + out := []string{path} + if real, err := filepath.EvalSymlinks(path); err == nil { + if real = filepath.Clean(real); real != path { + out = append(out, real) + } + } + return out +} + +// checkPrivacy reports what the rest of the machine can read. +// +// Everything Logos knows lives in one directory in the user's home. On a +// personal laptop that is nobody but them; on a shared box, a work machine with +// a management agent, or anything with another account on it, the mode bits are +// the only thing standing between a second user and every prompt the first one +// typed. That is worth one line in `logos doctor` whether or not it is worth +// worrying about, because "who can read this" is not a question you can answer +// by looking at the app. +// +// It reports rather than repairs. New files and directories are created private +// (see internal/vault.FileMode), but a vault that predates that, or one the user +// deliberately opened up to sync it, is theirs — silently chmod-ing somebody's +// filesystem is exactly the kind of unrequested help this product does not do. +// So: name the paths, give the command, let them decide. +func checkPrivacy(dir string) Check { + c := Check{Name: "privacy"} + if strings.TrimSpace(dir) == "" { + c.State, c.Detail = Unknown, "no vault path resolved" + return c + } + info, err := os.Stat(dir) + if err != nil { + c.State, c.Detail = Unknown, "vault not readable: "+err.Error() + return c + } + // Name what is actually exposed rather than the directory alone. "0755 on a + // folder" means nothing to most people; "your prompt log and the database + // holding every note" means something. + // + // The contents are checked even when the directory itself is locked down, + // and that is the whole point of the list. index.Open sets the mode on + // index.db advisorily — `_ = vault.PrivateSiblings(...)`, with a comment + // naming this check as what would catch a failure — so answering from the + // directory's mode alone reported "readable only by you" over a 0644 copy of + // every note, memory and checkpoint in the vault. A private directory is + // also one chmod, one sync client or one backup away from not being one. + var open []string + for _, rel := range []string{"activity", ".logos/index.db", ".logos/index.db-wal", "memories", "sessions"} { + p := filepath.Join(dir, rel) + fi, err := os.Stat(p) + if err != nil { + continue + } + if fi.Mode().Perm()&0o077 != 0 { + open = append(open, rel) + } + } + if info.Mode().Perm()&0o077 == 0 && len(open) == 0 { + c.State, c.Detail = OK, "the vault is readable only by you" + return c + } + + c.State = Failed + if info.Mode().Perm()&0o077 != 0 { + c.Detail = fmt.Sprintf("%s is readable by other users on this machine", dir) + if len(open) > 0 { + c.Detail += " — so is " + strings.Join(open, ", ") + } + } else { + // The directory is closed but something inside it is not. Say so + // precisely: the fix is the same command, but "your vault is fine except + // for the file holding all of it" is a different sentence. + c.Detail = fmt.Sprintf("%s is private, but %s inside it %s readable by other users on this machine", + dir, strings.Join(open, ", "), isAre(len(open))) + } + c.Fix = "run `chmod -R go-rwx " + shellArg(dir) + "` if this machine has other accounts on it" + return c +} From e1ccf26d8cfd57a319a3348cc8b659c0ea15cf8e Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 1 Oct 2026 17:29:42 -0700 Subject: [PATCH 10/16] textmatch has its own tests, pinning the examples its comments give for each rule --- internal/textmatch/textmatch_test.go | 139 +++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 internal/textmatch/textmatch_test.go diff --git a/internal/textmatch/textmatch_test.go b/internal/textmatch/textmatch_test.go new file mode 100644 index 0000000..a6d0e0c --- /dev/null +++ b/internal/textmatch/textmatch_test.go @@ -0,0 +1,139 @@ +package textmatch + +import "testing" + +// These pin the examples the package's comments give as the reason for each +// rule. Every one of them is a case that went wrong once, in memory dedup, +// conflict detection or dead-end matching, and the consumers' own tests only +// reach them through a model-free path that happens to call in. + +func TestSubjectKeepsOnlyTheDistinctiveWords(t *testing.T) { + got := Subject("What should I do about the waveguide quote for 2026?") + for _, w := range []string{"waveguide", "quote"} { + if !got[w] { + t.Errorf("Subject dropped %q: %v", w, got) + } + } + for _, w := range []string{"what", "should", "about", "the", "2026", "for"} { + if got[w] { + t.Errorf("Subject kept %q, which says nothing about the subject: %v", w, got) + } + } +} + +func TestAkinMatchesInflectionsButNotShortWords(t *testing.T) { + for _, p := range [][2]string{{"manufacture", "manufacturer"}, {"proposal", "proposals"}, {"quote", "quoted"}} { + if !Akin(p[0], p[1]) { + t.Errorf("Akin(%q, %q) = false, want the same term", p[0], p[1]) + } + } + // Under five characters a shared prefix is a coincidence, not a stem. + if Akin("cart", "carton") { + t.Error(`Akin("cart", "carton") = true, want false`) + } +} + +// Jaccard scored this pair at 0.29, so a superseded price was handed over as +// current. Containment asks whether the shorter statement is about the same +// thing, and it is. +func TestOverlapIsContainmentSoALongerStatementIsNotPunished(t *testing.T) { + a := Subject("we are targeting a $199 retail price") + b := Subject("final call: retail price is $249, that is locked for launch") + if got := Overlap(a, b); got < Related { + t.Errorf("Overlap = %.2f, want at least %.2f", got, Related) + } + if got := Overlap(a, map[string]bool{}); got != 0 { + t.Errorf("Overlap with an empty side = %.2f, want 0", got) + } +} + +func TestValuesNormaliseMoneyAndUnits(t *testing.T) { + got := Values("the run is $1,200 over 3 weeks at 15%.") + for _, v := range []string{"1200", "3 weeks", "15%"} { + if !got[v] { + t.Errorf("Values missed %q: %v", v, got) + } + } +} + +// Zero is not a claim to the conflict detector, and is one to dedup: "retries +// at 0" and "retries at 3" are two decisions. +func TestZeroCountsAsAValueOnlyWhenDecidingWhetherTwoFactsAreOne(t *testing.T) { + a, b := "retries at 0", "retries at 3" + if DifferingValues(a, b) { + t.Error("DifferingValues read 0 as a claim") + } + if !DifferingFactValues(a, b) { + t.Error("DifferingFactValues merged retries at 0 into retries at 3") + } + if DifferingValues("price is $249", "price is $249 and locked") { + t.Error("two statements sharing their value were read as a contradiction") + } +} + +func TestDifferentSubjectsKeepsParallelFactsAndMergesRestatements(t *testing.T) { + if !DifferentSubjects("kestrel handles checkout through a dedicated service", + "kestrel handles pricing through a dedicated service") { + t.Error("checkout and pricing read as one fact, so one of them would be destroyed") + } + if DifferentSubjects("I prefer terse replies with no preamble", + "I like my replies terse, without any preamble") { + t.Error("a restatement that only adds words read as a second fact") + } + // The short names are what tell developer facts apart. + if !DifferentSubjects("deploys go out through the web build", "deploys go out through the ios build") { + t.Error("web and ios read as the same subject") + } +} + +func TestNegatedCatchesAPlanBeingCalledOff(t *testing.T) { + if !Negated("We decided against Kubernetes") { + t.Error("decided against was not read as calling something off") + } + if Negated("deploy on Friday") { + t.Error("a plain plan read as called off") + } +} + +func TestReversesCatchesADenialOrASwappedPredicate(t *testing.T) { + for _, p := range [][2]string{ + {"the staging database is postgres", "the staging database is mysql"}, + {"retries are enabled", "retries are disabled"}, + {"we use docker", "we don't use docker"}, + {"we do use docker", "we do not use docker"}, + {"the cache is redis", "the cache is no longer redis"}, + } { + if !Reverses(p[0], p[1]) || !Reverses(p[1], p[0]) { + t.Errorf("Reverses(%q, %q) = false, want a reversal both ways", p[0], p[1]) + } + } +} + +func TestReversesLeavesAgreementsAndParallelFactsAlone(t *testing.T) { + for _, p := range [][2]string{ + // A "not" naming the rejected alternative agrees with the fact. + {"the database is postgres", "the database is postgres, not mysql"}, + // Two denials can both be true. + {"the cache is not redis", "the cache is not memcached"}, + // A modifier changed, not the value. + {"the database is a postgres instance", "the database is the postgres instance"}, + // A swapped subject is a second fact, which DifferentSubjects keeps. + {"kestrel handles billing through a dedicated service", "kestrel handles search through a dedicated service"}, + // A full stop is not a different value. + {"retries are on.", "retries are on"}, + // A denial with nothing left denies nothing in particular. + {"Never.", "retries are on"}, + // Numbers are Values' to compare. + {"the timeout is 30", "the timeout is 60"}, + } { + if Reverses(p[0], p[1]) { + t.Errorf("Reverses(%q, %q) = true, want false", p[0], p[1]) + } + } +} + +func TestFlattenCollapsesWhitespaceWithoutShortening(t *testing.T) { + if got := Flatten(" one\n\ttwo three \n"); got != "one two three" { + t.Errorf("Flatten = %q", got) + } +} From 33f84d8913fa13caa54c720f671facd45c6ac8a4 Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 1 Oct 2026 17:29:42 -0700 Subject: [PATCH 11/16] provider has its own tests against a loopback runtime: the stall cooloff, a configured runtime, errors, schemas and both streaming paths --- internal/provider/provider_test.go | 249 +++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 internal/provider/provider_test.go diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 0000000..fce08d2 --- /dev/null +++ b/internal/provider/provider_test.go @@ -0,0 +1,249 @@ +package provider + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// Every test here talks to an httptest server on loopback: nothing leaves the +// machine, and no test depends on a model runtime being installed. + +// A runtime that listed its models and then never answered embeddings held an +// MCP tool call for minutes, three times over in resume. An Interactive +// provider fails after its timeout and then skips the runtime for the +// cooloff, so the calls after it fail at once instead of each paying again. +func TestAnInteractiveProviderThatTimesOutSkipsEmbeddingsUntilItCoolsOff(t *testing.T) { + var calls atomic.Int32 + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + <-release + })) + defer srv.Close() + defer close(release) + + p := New("Ollama", srv.URL+"/v1", "").Interactive(50 * time.Millisecond) + if _, err := p.Embed("nomic-embed-text", []string{"waveguide"}); err == nil { + t.Fatal("a runtime that never answered returned embeddings") + } + start := time.Now() + _, err := p.Embed("nomic-embed-text", []string{"waveguide"}) + if err == nil || !strings.Contains(err.Error(), "cooled off") { + t.Fatalf("second call during the cooloff: err = %v, want one that says it was skipped", err) + } + if waited := time.Since(start); waited > 40*time.Millisecond { + t.Errorf("the skipped call waited %s; it should not reach the runtime at all", waited) + } + if n := calls.Load(); n != 1 { + t.Errorf("the runtime got %d requests, want 1 — the cooloff did not hold", n) + } + if n := p.Stalls(); n != 2 { + t.Errorf("Stalls = %d, want 2 (the timeout and the skip), so the caller can say the result was built without them", n) + } +} + +func TestAProviderThatIsNotInteractiveReportsNoStalls(t *testing.T) { + if n := New("Ollama", "http://127.0.0.1:1/v1", "").Stalls(); n != 0 { + t.Errorf("Stalls = %d, want 0", n) + } + var none *Provider + if n := none.Stalls(); n != 0 { + t.Errorf("Stalls on a nil provider = %d, want 0", n) + } +} + +// The user named where the model runs; quietly using whatever answers on +// localhost instead is how vectors end up from two models. +func TestAConfiguredRuntimeThatDoesNotAnswerIsNoneRatherThanADiscoveredOne(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + url := srv.URL + "/v1" + srv.Close() + t.Setenv("LOGOS_RUNTIME", url) + if got := Resolve(); got != nil { + t.Errorf("Resolve with a dead LOGOS_RUNTIME = %+v, want nothing", got) + } +} + +func TestAConfiguredRuntimeIsTheOnlyCandidateAndGetsItsKey(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" || r.Header.Get("Authorization") != "Bearer sekrit" { + http.Error(w, "no", http.StatusUnauthorized) + return + } + fmt.Fprint(w, `{"data":[{"id":"qwen3.6"},{"id":"nomic-embed-text"}]}`) + })) + defer srv.Close() + t.Setenv("LOGOS_RUNTIME", srv.URL+"/v1/") + t.Setenv("LOGOS_RUNTIME_KEY", "sekrit") + + got := Resolve() + if len(got) != 1 { + t.Fatalf("Resolve = %d runtimes, want only the configured one", len(got)) + } + if got[0].Provider.Name != "configured" || strings.Join(got[0].Models, ",") != "qwen3.6,nomic-embed-text" { + t.Errorf("Resolve = %s with %v", got[0].Provider.Name, got[0].Models) + } +} + +func TestAnErrorStatusNamesTheRuntimeAndWhatItSaid(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `model "nomic-embed-text" not found, try pulling it first`, http.StatusNotFound) + })) + defer srv.Close() + _, err := New("LM Studio", srv.URL+"/v1", "").Embed("nomic-embed-text", []string{"x"}) + if err == nil { + t.Fatal("a 404 returned embeddings") + } + for _, want := range []string{"LM Studio", "404", "try pulling it first"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not say %q", err, want) + } + } +} + +// A runtime that drops an input would shift every vector after it onto the +// wrong note. +func TestEmbedRefusesAnAnswerWithTheWrongNumberOfVectors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":[{"embedding":[0.1,0.2]}]}`) + })) + defer srv.Close() + if _, err := New("Ollama", srv.URL+"/v1", "").Embed("m", []string{"a", "b"}); err == nil { + t.Error("two inputs and one vector came back as success") + } +} + +func TestEmbeddingNothingAsksTheRuntimeNothing(t *testing.T) { + got, err := New("Ollama", "http://127.0.0.1:1/v1", "").Embed("m", nil) + if err != nil || got != nil { + t.Errorf("Embed(nil) = %v, %v; want nothing and no request", got, err) + } +} + +// Small local models are unreliable at tool calls and near-perfect when the +// sampler enforces JSON, so every extraction relies on the schema arriving. +func TestChatSendsTheSchemaAsConstrainedDecoding(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(raw, &body); err != nil { + t.Errorf("request body: %v", err) + } + fmt.Fprint(w, `{"choices":[{"message":{"content":"{\"ok\":true}"}}]}`) + })) + defer srv.Close() + + schema := map[string]any{"type": "object"} + got, err := New("Ollama", srv.URL+"/v1", "").Chat("qwen3.6", "sys", "user", schema) + if err != nil || got != `{"ok":true}` { + t.Fatalf("Chat = %q, %v", got, err) + } + rf, _ := body["response_format"].(map[string]any) + js, _ := rf["json_schema"].(map[string]any) + if rf["type"] != "json_schema" || js["strict"] != true || js["schema"] == nil { + t.Errorf("response_format = %v, want a strict json_schema", body["response_format"]) + } +} + +func TestChatWithNoChoicesIsAnError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"choices":[]}`) + })) + defer srv.Close() + if _, err := New("Ollama", srv.URL+"/v1", "").Chat("m", "s", "u", nil); err == nil { + t.Error("an answer with no choices came back as an empty success") + } +} + +func TestChatStreamAssemblesServerSentEventsAndStopsAtDone(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" { + t.Errorf("a runtime that is not Ollama was asked at %s", r.URL.Path) + } + for _, line := range []string{ + `data: {"choices":[{"delta":{"content":"Extruded"}}]}`, + `: keep-alive`, + `data: {"choices":[{"delta":{"content":" frames."}}]}`, + `data: [DONE]`, + `data: {"choices":[{"delta":{"content":" after done"}}]}`, + } { + fmt.Fprintln(w, line) + } + })) + defer srv.Close() + + var tokens []string + got, err := New("LM Studio", srv.URL+"/v1", "").ChatStream("m", []Msg{{Role: "user", Content: "q"}}, + func(tok string) { tokens = append(tokens, tok) }) + if err != nil || got != "Extruded frames." { + t.Fatalf("ChatStream = %q, %v", got, err) + } + if len(tokens) != 2 { + t.Errorf("onToken got %q, want each token as it arrived", tokens) + } +} + +// A reasoning model on /v1 spends its whole budget thinking and answers +// nothing, so Ollama goes through /api/chat with think bounded — and falls +// back to /v1 when the native endpoint refuses, for a model that rejects +// `think`. +func TestOllamaStreamsThroughTheNativeEndpointWithThinkingBounded(t *testing.T) { + var think any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/chat" { + t.Errorf("asked at %s, want /api/chat", r.URL.Path) + return + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + think = body["think"] + fmt.Fprintln(w, `{"message":{"content":"Extruded"},"done":false}`) + fmt.Fprintln(w, `{"message":{"content":" frames."},"done":true}`) + })) + defer srv.Close() + + got, err := New("Ollama", srv.URL+"/v1", "").ChatStream("qwen3.6", []Msg{{Role: "user", Content: "q"}}, func(string) {}) + if err != nil || got != "Extruded frames." { + t.Fatalf("ChatStream = %q, %v", got, err) + } + if think != "low" { + t.Errorf("think = %v, want the default low", think) + } +} + +func TestOllamaFallsBackToV1WhenTheNativeEndpointRefuses(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/chat" { + http.Error(w, `"qwen2" does not support thinking`, http.StatusBadRequest) + return + } + fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"answer"}}]}`) + fmt.Fprintln(w, `data: [DONE]`) + })) + defer srv.Close() + + got, err := New("Ollama", srv.URL+"/v1", "").ChatStream("qwen2", []Msg{{Role: "user", Content: "q"}}, func(string) {}) + if err != nil || got != "answer" { + t.Errorf("ChatStream = %q, %v; want the /v1 answer", got, err) + } +} + +func TestThinkDefaultOptsOllamaBackOutOfTheNativeEndpoint(t *testing.T) { + for think, native := range map[string]bool{"": true, "off": true, "high": true, "default": false, "bogus": false} { + p := New("Ollama", "http://127.0.0.1:1/v1", "") + p.Think = think + if _, ok := p.thinkValue(); ok != native { + t.Errorf("Think %q: native = %v, want %v", think, ok, native) + } + } + if _, ok := New("LM Studio", "http://127.0.0.1:1/v1", "").thinkValue(); ok { + t.Error("a runtime that is not Ollama was sent to /api/chat") + } +} From fec1227bc8be3141c8fc6ca45b18d33cf1d2163b Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 1 Oct 2026 17:33:11 -0700 Subject: [PATCH 12/16] The retrieval path has tests against a loopback embedder: batched embedding, vector ranking, fusion with a lexical-only hit, and graph expansion --- internal/index/retrieval_test.go | 198 +++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 internal/index/retrieval_test.go diff --git a/internal/index/retrieval_test.go b/internal/index/retrieval_test.go new file mode 100644 index 0000000..ad34214 --- /dev/null +++ b/internal/index/retrieval_test.go @@ -0,0 +1,198 @@ +package index + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/Coder8124/logos/internal/provider" +) + +// embedAxes are the dimensions of the fake embedder: a text's vector has a 1 +// on each axis whose word it contains. That makes cosine order predictable, so +// these tests can say which note should win and why, without a model. +var embedAxes = []string{"waveguide", "procurement", "lead", "frame"} + +// fakeEmbedder serves /v1/embeddings on loopback and counts the requests. +func fakeEmbedder(t *testing.T) (*provider.Provider, *atomic.Int32) { + t.Helper() + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + var req struct{ Input []string } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + var data []map[string][]float32 + for _, in := range req.Input { + vec := make([]float32, len(embedAxes)) + for i, axis := range embedAxes { + if strings.Contains(strings.ToLower(in), axis) { + vec[i] = 1 + } + } + data = append(data, map[string][]float32{"embedding": vec}) + } + json.NewEncoder(w).Encode(map[string]any{"data": data}) + })) + t.Cleanup(srv.Close) + return provider.New("Ollama", srv.URL+"/v1", ""), &calls +} + +func slugs(hits []Hit) string { + var out []string + for _, h := range hits { + out = append(out, h.Slug) + } + return strings.Join(out, ",") +} + +// A round trip per note made a 2000-note vault take minutes, so notes go in +// batches; and a note that already has a vector is not paid for again. +func TestEmbedPendingBatchesAndSkipsNotesThatAlreadyHaveAVector(t *testing.T) { + ix := newTestIndex(t) + for i := range 5 { + seed(t, ix, fmt.Sprintf("n%d", i), "Note", "waveguide") + } + p, calls := fakeEmbedder(t) + + n, err := ix.EmbedPending(p, "m", 2) + if err != nil || n != 5 { + t.Fatalf("EmbedPending = %d, %v; want all 5", n, err) + } + if c := calls.Load(); c != 3 { + t.Errorf("5 notes in batches of 2 took %d requests, want 3", c) + } + if n, err := ix.EmbedPending(p, "m", 2); err != nil || n != 0 || calls.Load() != 3 { + t.Errorf("a second pass embedded %d notes in %d more requests; every note already had a vector", n, calls.Load()-3) + } +} + +// The count is what `logos index` reports, so a runtime that fails partway +// must leave it saying how far it got, not zero and not all. +func TestEmbedPendingReportsHowFarItGotWhenTheRuntimeFails(t *testing.T) { + ix := newTestIndex(t) + for i := range 4 { + seed(t, ix, fmt.Sprintf("n%d", i), "Note", "waveguide") + } + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if calls.Add(1) > 1 { + http.Error(w, "out of memory", http.StatusInternalServerError) + return + } + fmt.Fprint(w, `{"data":[{"embedding":[1,0]},{"embedding":[0,1]}]}`) + })) + defer srv.Close() + + n, err := ix.EmbedPending(provider.New("Ollama", srv.URL+"/v1", ""), "m", 2) + if err == nil { + t.Fatal("a runtime that failed the second batch was reported as success") + } + if n != 2 { + t.Errorf("EmbedPending = %d, want the 2 that were stored before the failure", n) + } +} + +func TestSearchRanksByMeaningAndKeepsTheTopK(t *testing.T) { + ix := newTestIndex(t) + seed(t, ix, "quote", "Quote", "the waveguide quote for procurement") + seed(t, ix, "frame", "Frame", "the extruded frame") + seed(t, ix, "bom", "BOM", "waveguide line items") + p, _ := fakeEmbedder(t) + if _, err := ix.EmbedPending(p, "m", 8); err != nil { + t.Fatal(err) + } + + hits, err := ix.Search(p, "m", "procurement waveguide", 2) + if err != nil { + t.Fatal(err) + } + if got := slugs(hits); got != "quote,bom" { + t.Errorf("Search = %s, want quote (both words) then bom (one), and frame cut by k", got) + } +} + +// RRF is only worth having if a note one arm misses still comes back from the +// other, with its title, so it can be cited. +func TestHybridSearchReturnsANoteOnlyTheLexicalArmFound(t *testing.T) { + ix := newTestIndex(t) + seed(t, ix, "quote", "Quote", "the waveguide quote") + p, _ := fakeEmbedder(t) + if _, err := ix.EmbedPending(p, "m", 8); err != nil { + t.Fatal(err) + } + // Seeded after embedding, so it has no vector: only FTS can find it. + seed(t, ix, "sku", "SKU list", "part 4471 is the waveguide") + + hits, err := ix.HybridSearch(p, "m", "4471", 5) + if err != nil { + t.Fatal(err) + } + var found *Hit + for i := range hits { + if hits[i].Slug == "sku" { + found = &hits[i] + } + } + if found == nil { + t.Fatalf("HybridSearch = %s; the exact part number matched no vector and was lost", slugs(hits)) + } + if found.Title != "SKU list" { + t.Errorf("the lexical-only hit came back with title %q, so it cannot be cited", found.Title) + } +} + +// Asking about a project should surface the people on it even when their +// notes share no words with the question — but each once, and only along +// links the extractor was confident in. +func TestExpandFollowsConfidentLinksOnceAndSaysWhy(t *testing.T) { + ix := newTestIndex(t) + seed(t, ix, "kestrel", "Kestrel", "the project") + seed(t, ix, "people/ana", "Ana", "buyer") + seed(t, ix, "people/raj", "Raj", "engineer") + for _, e := range []struct { + obj string + conf float64 + }{{"ana", 0.9}, {"people/ana", 0.9}, {"raj", 0.3}} { + if _, err := ix.DB.Exec("INSERT INTO edges (src_slug, pred, obj, conf, src) VALUES ('kestrel', 'owned_by', ?, ?, 'test')", + e.obj, e.conf); err != nil { + t.Fatal(err) + } + } + + out, err := ix.Expand([]Hit{{Slug: "kestrel", Title: "Kestrel", Score: 1}}, 0.6, 5) + if err != nil { + t.Fatal(err) + } + if got := slugs(out); got != "people/ana" { + t.Fatalf("Expand = %s, want people/ana once (two edges reach her) and not raj (conf 0.3)", got) + } + if out[0].Via != "Kestrel —owned_by→" || out[0].Score != 0.5 { + t.Errorf("neighbour = via %q score %.2f, want it to say which hit pulled it in, at half that hit's score", out[0].Via, out[0].Score) + } + + if out, _ := ix.Expand([]Hit{{Slug: "kestrel"}, {Slug: "people/ana"}}, 0.6, 5); len(out) != 0 { + t.Errorf("Expand returned %s, a note that was already a hit", slugs(out)) + } +} + +func TestNoteAndEdgeCountsCountRows(t *testing.T) { + ix := newTestIndex(t) + seed(t, ix, "a", "A", "x") + seed(t, ix, "b", "B", "y") + if _, err := ix.DB.Exec("INSERT INTO edges (src_slug, pred, obj, conf, src) VALUES ('a', 'links', 'b', 1, 'test')"); err != nil { + t.Fatal(err) + } + if n, err := ix.NoteCount(); err != nil || n != 2 { + t.Errorf("NoteCount = %d, %v", n, err) + } + if n, err := ix.EdgeCount(); err != nil || n != 1 { + t.Errorf("EdgeCount = %d, %v", n, err) + } +} From 4d2fcd17ec9a69dccd89865988684cdb4e0e7856 Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 1 Oct 2026 17:37:34 -0700 Subject: [PATCH 13/16] A loop deleted from loops.md by hand stays deleted when the next loop is added or closed, without waiting for an index --- internal/secretary/commitment.go | 61 +++++++++++-------- internal/secretary/loopstore.go | 89 ++++++++++++++++++++++++---- internal/secretary/loopstore_test.go | 68 +++++++++++++++++++++ internal/vault/stamp.go | 81 +++++++++++++++++++++++++ 4 files changed, 262 insertions(+), 37 deletions(-) create mode 100644 internal/vault/stamp.go diff --git a/internal/secretary/commitment.go b/internal/secretary/commitment.go index 9f8a2ca..d0da7eb 100644 --- a/internal/secretary/commitment.go +++ b/internal/secretary/commitment.go @@ -87,27 +87,29 @@ func Add(db *sql.DB, c *Commitment) (bool, error) { if c.Status == "" { c.Status = Open } - // INSERT OR IGNORE on the fingerprint makes re-running extraction safe and - // idempotent, which matters because the daily rollup will call it often. - res, err := db.Exec( - `INSERT OR IGNORE INTO commitments (text, who, created, due_hint, status, source_ref, fingerprint) - VALUES (?,?,?,?,?,?,?)`, - c.Text, c.Who, c.Created, c.DueHint, string(c.Status), c.SourceRef, fingerprint(c.Text, c.Who)) - if err != nil { - return false, err - } - n, _ := res.RowsAffected() - if n == 0 { - return false, nil // a fingerprint-equal loop is already on the record - } - c.ID, _ = res.LastInsertId() - // The vault copy, immediately. A loop that reached only the cache is one - // the next `logos index` throws away without saying so, and the caller has - // to hear that the write is half-done rather than be told "tracked". - if err := flush(db); err != nil { - return true, err - } - return true, nil + added := false + err := withLoops(db, func(dir string) error { + // INSERT OR IGNORE on the fingerprint makes re-running extraction safe and + // idempotent, which matters because the daily rollup will call it often. + res, err := db.Exec( + `INSERT OR IGNORE INTO commitments (text, who, created, due_hint, status, source_ref, fingerprint) + VALUES (?,?,?,?,?,?,?)`, + c.Text, c.Who, c.Created, c.DueHint, string(c.Status), c.SourceRef, fingerprint(c.Text, c.Who)) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return nil // a fingerprint-equal loop is already on the record + } + added = true + c.ID, _ = res.LastInsertId() + // The vault copy, immediately. A loop that reached only the cache is one + // the next `logos index` throws away without saying so, and the caller has + // to hear that the write is half-done rather than be told "tracked". + return flushLocked(db, dir) + }) + return added, err } func Open_(db *sql.DB) ([]Commitment, error) { return list(db, Open) } @@ -142,10 +144,19 @@ func SetStatus(db *sql.DB, id int64, s Status) error { if s != Open { resolved = time.Now().Unix() } - if _, err := db.Exec("UPDATE commitments SET status = ?, resolved_at = ? WHERE id = ?", string(s), resolved, id); err != nil { - return err - } - return flush(db) + return withLoops(db, func(dir string) error { + res, err := db.Exec("UPDATE commitments SET status = ?, resolved_at = ? WHERE id = ?", string(s), resolved, id) + if err != nil { + return err + } + // The caller checked the id before the lock; withLoops may since have + // adopted a hand edit that deleted it. Saying nothing would let `logos + // loop done` report closing a loop that is no longer on the record. + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("loop %d is not in %s — it was deleted there by hand", id, LoopsFile) + } + return flushLocked(db, dir) + }) } func OpenCount(db *sql.DB) (int, error) { diff --git a/internal/secretary/loopstore.go b/internal/secretary/loopstore.go index 64d7c11..51aa566 100644 --- a/internal/secretary/loopstore.go +++ b/internal/secretary/loopstore.go @@ -57,6 +57,7 @@ var ( func SetVault(db *sql.DB, dir string) { vaultMu.Lock() defer vaultMu.Unlock() + stamps.Forget(db) if dir == "" { delete(vaults, db) return @@ -70,30 +71,77 @@ func vaultFor(db *sql.DB) string { return vaults[db] } -// flush rewrites the whole file from the database, under the lock every other -// writer takes. Whole-file because a loop list is current state rather than a -// log: one successful write heals whatever a failed one left behind. +// stamps holds loops.md as each store last wrote it. See vault.Stamps. +var stamps vault.Stamps + +// withLoops runs a mutation holding the loop list's lock, after adopting +// whatever the user changed in the file by hand, and passes fn the vault dir +// ("" for a cache-only store) so it can flushLocked when it is done. +// +// One lock across reconcile, mutation and write, for two reasons. Unserialised, +// two loops added at the same moment both read the list and both write the +// file, and the later write wins with a snapshot taken before the other loop +// existed; Import would then take the missing row for a deletion and remove a +// commitment the user never dismissed. And the reconcile has to come before the +// mutation: once a new row is in the cache, nothing can tell it apart from a +// line the user deleted. // -// Unserialised, two loops added at the same moment both read the list and both -// write the file, and the later write wins with a snapshot taken before the -// other loop existed. Import would then see a row it cannot find in the file, -// take that for a deletion, and remove a commitment the user never dismissed. -func flush(db *sql.DB) error { +// Without the reconcile, the file's own promise — delete a line and the loop is +// forgotten — only held if `logos index` ran before the next `logos loop add`, +// which regenerated the file from the cache and put the line back. +func withLoops(db *sql.DB, fn func(dir string) error) error { dir := vaultFor(db) if dir == "" { - return nil // a cache-only store — tests, and handles index.Close unbound + return fn("") // a cache-only store — tests, and handles index.Close unbound } g, err := vault.Lock(dir, "loops") if err != nil { return err } defer g.Unlock() - return flushLocked(db, dir) + // Returned rather than written past: rewriting the file over an edit we + // could not adopt is the thing this exists to prevent. + if err := reconcileLocked(db, dir); err != nil { + return err + } + return fn(dir) +} + +// reconcileLocked adopts hand edits to loops.md, with the lock held. A file we +// wrote ourselves is skipped on its hash; an absent file says nothing about +// what is open, as Import explains. +func reconcileLocked(db *sql.DB, dir string) error { + raw, err := os.ReadFile(LoopsPath(dir)) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if stamps.Ours(db, raw) { + return nil + } + if looksTruncated(string(raw)) { + // Import refuses this file and says so when the user runs `logos + // index`. Here it is a reason not to adopt the file, not to fail an + // unrelated `loop add`: the rewrite that follows replaces the torn + // file with the cache's complete copy, which is the repair. + return nil + } + if _, err := adoptLocked(db, raw); err != nil { + return err + } + stamps.Adopted(db, raw) + return nil } -// flushLocked is flush with the lock already held — Import needs it to write -// the first copy of a vault that predates this file. +// flushLocked rewrites the whole file from the database, with the lock held. +// Whole-file because a loop list is current state rather than a log: one +// successful write heals whatever a failed one left behind. func flushLocked(db *sql.DB, dir string) error { + if dir == "" { + return nil + } all, err := allLoops(db) if err != nil { return err @@ -103,13 +151,19 @@ func flushLocked(db *sql.DB, dir string) error { // No loops is an absent file, not an empty one. A person who has never // tracked a loop should not find a page in their vault about it. if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + stamps.Forget(db) return fmt.Errorf("loops emptied in the cache but not in the vault: %w", err) } + stamps.Forget(db) return nil } if err := vault.WriteAtomic(path, []byte(render(all))); err != nil { + // Forgotten, not kept: whatever is on disk now is not what we last + // recorded writing, and a stale stamp would skip adopting it. + stamps.Forget(db) return fmt.Errorf("loop saved to the cache but not to the vault: %w", err) } + stamps.Record(db, path) return nil } @@ -288,6 +342,17 @@ func Import(db *sql.DB, dir string) (int, error) { "restore the file or delete the partial line to accept it as-is", LoopsFile) } + restored, err := adoptLocked(db, raw) + if err != nil { + return restored, err + } + stamps.Adopted(db, raw) + return restored, nil +} + +// adoptLocked makes the cache match the file: every line is upserted at its id, +// and every row with no line is deleted. Returns how many rows it had to create. +func adoptLocked(db *sql.DB, raw []byte) (int, error) { parsed := parse(string(raw)) keep := map[int64]bool{} restored := 0 diff --git a/internal/secretary/loopstore_test.go b/internal/secretary/loopstore_test.go index d905221..ddfadc3 100644 --- a/internal/secretary/loopstore_test.go +++ b/internal/secretary/loopstore_test.go @@ -261,3 +261,71 @@ func TestADuplicatedLineInTheLoopFileDoesNotFailTheImport(t *testing.T) { t.Errorf("open loops = %+v, want the duplicate collapsed into one", open) } } + +// The next write regenerates the whole file from the cache, so a line deleted +// by hand only stayed deleted if `logos index` happened to run first. Without +// that, the next `logos loop add` wrote the struck loop straight back, and the +// user's edit was undone by an unrelated command that said only "tracked". +func TestALoopDeletedByHandStaysDeletedWhenTheNextLoopIsAdded(t *testing.T) { + dir := t.TempDir() + db := vaultDB(t, dir) + Add(db, &Commitment{Text: "renew the parking permit"}) + Add(db, &Commitment{Text: "send the tooling PO"}) + + raw, err := os.ReadFile(LoopsPath(dir)) + if err != nil { + t.Fatal(err) + } + var kept []string + for _, line := range strings.Split(string(raw), "\n") { + if !strings.Contains(line, "renew the parking permit") { + kept = append(kept, line) + } + } + if err := os.WriteFile(LoopsPath(dir), []byte(strings.Join(kept, "\n")), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := Add(db, &Commitment{Text: "book the freight"}); err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(LoopsPath(dir)) + if strings.Contains(string(after), "renew the parking permit") { + t.Errorf("the next add wrote a hand-deleted loop back into %s:\n%s", LoopsFile, after) + } + if !strings.Contains(string(after), "book the freight") || !strings.Contains(string(after), "send the tooling PO") { + t.Errorf("%s lost a loop that was never deleted:\n%s", LoopsFile, after) + } + open, _ := Open_(db) + for _, c := range open { + if c.Text == "renew the parking permit" { + t.Error("the hand-deleted loop is still open in the cache") + } + } +} + +// Adopting the file before the write means a loop deleted by hand is gone by the +// time the update runs. Closing it then touched no row and returned nil, so +// `logos loop done 4` printed "done [4]" for a loop the file no longer had. +func TestClosingALoopDeletedByHandSaysItIsGone(t *testing.T) { + dir := t.TempDir() + db := vaultDB(t, dir) + c := Commitment{Text: "renew the parking permit"} + Add(db, &c) + Add(db, &Commitment{Text: "send the tooling PO"}) + raw, _ := os.ReadFile(LoopsPath(dir)) + var kept []string + for _, line := range strings.Split(string(raw), "\n") { + if !strings.Contains(line, "renew the parking permit") { + kept = append(kept, line) + } + } + if err := os.WriteFile(LoopsPath(dir), []byte(strings.Join(kept, "\n")), 0o600); err != nil { + t.Fatal(err) + } + + err := SetStatus(db, c.ID, Done) + if err == nil || !strings.Contains(err.Error(), LoopsFile) { + t.Errorf("closing a loop deleted from %s by hand: err = %v, want one that says the file no longer has it", LoopsFile, err) + } +} diff --git a/internal/vault/stamp.go b/internal/vault/stamp.go new file mode 100644 index 0000000..99874fb --- /dev/null +++ b/internal/vault/stamp.go @@ -0,0 +1,81 @@ +package vault + +import ( + "crypto/sha256" + "os" + "sync" +) + +// Stamps remembers the hash of a whole-file record as this process last wrote +// it, so a store about to rewrite that file can tell "nobody has touched this +// since we wrote it" from "the user edited it by hand". +// +// The stores that rewrite a whole file from the cache — loops.md, the dream +// insight queue, the memory review queue — each told the user that deleting a +// line discards the record "on the next `logos index`". Any write before that +// index regenerated the file from the cache and put the deleted line straight +// back, so the edit only held if the user happened to reindex first. Adopting +// the file before every rewrite fixes that, and this is what makes adopting +// cheap: the common case, where the only writer was us, is one file read and a +// hash compare instead of a full pass. +// +// A hash rather than size and mtime, for the reason internal/memory gives: the +// failure a cheaper check admits is silently losing the user's edit, which is +// the bug this exists to prevent. +// +// Process-local. A stamp we do not have means a full pass, so another process +// writing the file costs one reconcile, never a missed edit. +type Stamps struct { + mu sync.Mutex + sums map[any][sha256.Size]byte +} + +// Record stamps the file at path as ours. It reads the file back rather than +// hashing what the caller meant to write, so the stamp describes the disk. +// +// A read that fails clears the stamp instead of leaving the old one. The usual +// cause is not an error at all — a store whose last record went away removes +// its file — and a stale stamp is a claim that bytes on disk are ours: restore +// that file from a backup, the claim matches, the reconcile is skipped, and the +// next write overwrites the restore. +func (s *Stamps) Record(key any, path string) { + raw, err := os.ReadFile(path) + s.mu.Lock() + defer s.mu.Unlock() + if err != nil { + delete(s.sums, key) + return + } + if s.sums == nil { + s.sums = map[any][sha256.Size]byte{} + } + s.sums[key] = sha256.Sum256(raw) +} + +// Adopted stamps bytes the caller has just read and adopted, for a reconcile +// that is not followed by a write. +func (s *Stamps) Adopted(key any, raw []byte) { + s.mu.Lock() + defer s.mu.Unlock() + if s.sums == nil { + s.sums = map[any][sha256.Size]byte{} + } + s.sums[key] = sha256.Sum256(raw) +} + +// Ours reports whether raw is exactly what this process last recorded for key. +func (s *Stamps) Ours(key any, raw []byte) bool { + s.mu.Lock() + defer s.mu.Unlock() + got, ok := s.sums[key] + return ok && got == sha256.Sum256(raw) +} + +// Forget drops the stamp for key, so the next reconcile does a full pass. A +// store calls it when it is rebound to another vault: the stamp described a +// file in the old one. +func (s *Stamps) Forget(key any) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sums, key) +} From 817217a14c235ea2094ece64c505ee3b362b0498 Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 1 Oct 2026 17:38:20 -0700 Subject: [PATCH 14/16] An insight deleted from dream-insights.md by hand stays deleted when the next one is queued or reviewed, without waiting for an index --- internal/dream/insight.go | 56 +++++++++++-------- internal/dream/insightstore.go | 87 ++++++++++++++++++++++++----- internal/dream/insightstore_test.go | 50 +++++++++++++++++ 3 files changed, 155 insertions(+), 38 deletions(-) diff --git a/internal/dream/insight.go b/internal/dream/insight.go index 99658ab..d347a9f 100644 --- a/internal/dream/insight.go +++ b/internal/dream/insight.go @@ -110,21 +110,20 @@ func Enqueue(db *sql.DB, in *Insight) error { if in.Status == "" { in.Status = Pending } - res, err := db.Exec( - `INSERT INTO dream_insights (kind, text, endpoint_a, endpoint_b, conf, model, created, status) - VALUES (?,?,?,?,?,?,?,?)`, - string(in.Kind), in.Text, in.EndpointA, in.EndpointB, in.Conf, in.Model, in.Created, string(in.Status)) - if err != nil { - return err - } - in.ID, _ = res.LastInsertId() - // The vault, second and reported. A row that reached the cache and not the - // file is the exact state that made this queue losable, so the caller hears - // about it rather than getting a success-shaped result (invariant 4). - if err := flush(db); err != nil { - return err - } - return nil + return withQueue(db, func(dir string) error { + res, err := db.Exec( + `INSERT INTO dream_insights (kind, text, endpoint_a, endpoint_b, conf, model, created, status) + VALUES (?,?,?,?,?,?,?,?)`, + string(in.Kind), in.Text, in.EndpointA, in.EndpointB, in.Conf, in.Model, in.Created, string(in.Status)) + if err != nil { + return err + } + in.ID, _ = res.LastInsertId() + // The vault, second and reported. A row that reached the cache and not the + // file is the exact state that made this queue losable, so the caller hears + // about it rather than getting a success-shaped result (invariant 4). + return flushLocked(db, dir) + }) } func scan(rows *sql.Rows) ([]Insight, error) { @@ -174,13 +173,22 @@ func Get(db *sql.DB, id int64) (Insight, error) { // SetStatus records the decision. Rejections are kept, not deleted: "you dreamed // this and I said no" is the signal for tuning what the pass proposes later. func SetStatus(db *sql.DB, id int64, s Status) error { - if _, err := db.Exec("UPDATE dream_insights SET status = ? WHERE id = ?", string(s), id); err != nil { - return err - } - // A verdict that only the cache knows is the same bug as a proposal that - // only the cache knows: the next rebuild hands the user back an insight - // they already refused. - return flush(db) + return withQueue(db, func(dir string) error { + res, err := db.Exec("UPDATE dream_insights SET status = ? WHERE id = ?", string(s), id) + if err != nil { + return err + } + // withQueue may have just adopted a hand edit that deleted this one, and + // a verdict on nothing reported as success is how `logos dream reject` + // would claim to discard an insight that was already gone. + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("insight %d is not in %s — it was deleted there by hand", id, InsightsFile) + } + // A verdict that only the cache knows is the same bug as a proposal that + // only the cache knows: the next rebuild hands the user back an insight + // they already refused. + return flushLocked(db, dir) + }) } func PendingCount(db *sql.DB) (int, error) { @@ -209,7 +217,9 @@ func Accept(db *sql.DB, p *provider.Provider, embedModel string, in Insight) (bo return false, err } if err := SetStatus(db, in.ID, Accepted); err != nil { - return r.Created(), err + // The memory is stored either way, so say that rather than leave the + // error reading as though the accept did nothing. + return r.Created(), fmt.Errorf("remembered as memory #%d, but recording the verdict failed: %w", r.ID, err) } return r.Created(), nil } diff --git a/internal/dream/insightstore.go b/internal/dream/insightstore.go index 3f99e12..8914c82 100644 --- a/internal/dream/insightstore.go +++ b/internal/dream/insightstore.go @@ -65,6 +65,7 @@ var ( func SetVault(db *sql.DB, dir string) { vaultMu.Lock() defer vaultMu.Unlock() + stamps.Forget(db) if dir == "" { delete(vaults, db) return @@ -78,47 +79,91 @@ func vaultFor(db *sql.DB) string { return vaults[db] } -// flush rewrites the whole file from the database, under the lock every other -// writer takes. Whole-file because the queue is current state rather than a -// log: one successful write heals whatever a failed one left behind. +// stamps holds the insight queue as each store last wrote it. See vault.Stamps. +var stamps vault.Stamps + +// withQueue runs a mutation holding the queue's lock, after adopting whatever +// the user changed in the file by hand, and passes fn the vault dir ("" for a +// cache-only store) so it can flushLocked when it is done. // -// Unserialised, two insights queued at the same moment both read the table and -// both write the file, and the later write wins with a snapshot taken before -// the other insight existed. Import would then see a row it cannot find in the -// file, take that for a deletion, and discard a proposal the user never saw. -func flush(db *sql.DB) error { +// One lock across reconcile, mutation and write. Unserialised, two insights +// queued at the same moment both read the table and both write the file, and +// the later write wins with a snapshot taken before the other insight existed; +// Import would then take the missing row for a deletion and discard a proposal +// the user never saw. The reconcile comes first because once a new row is in +// the cache nothing can tell it apart from a line the user deleted — and +// without it, the file's promise that deleting a line discards the insight +// only held if `logos index` ran before the next dream queued another. +func withQueue(db *sql.DB, fn func(dir string) error) error { dir := vaultFor(db) if dir == "" { - return nil // a cache-only store — tests, and handles index.Close unbound + return fn("") // a cache-only store — tests, and handles index.Close unbound } g, err := vault.Lock(dir, lockName) if err != nil { return err } defer g.Unlock() - return flushLocked(db, dir) + if err := reconcileLocked(db, dir); err != nil { + return err + } + return fn(dir) } -// flushLocked is flush with the lock already held — Import needs it to write -// the first copy of a vault that predates this file. +// reconcileLocked adopts hand edits to the queue file, with the lock held. A +// file we wrote ourselves is skipped on its hash; an absent file says nothing +// about what is queued, as Import explains. +func reconcileLocked(db *sql.DB, dir string) error { + raw, err := os.ReadFile(InsightsPath(dir)) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if stamps.Ours(db, raw) { + return nil + } + if looksTruncated(string(raw)) { + // Import refuses this file and says so on `logos index`. Here it is a + // reason not to adopt it, not to fail the write: the rewrite that + // follows replaces the torn file with the cache's complete copy. + return nil + } + if _, err := adoptLocked(db, raw); err != nil { + return err + } + stamps.Adopted(db, raw) + return nil +} + +// flushLocked rewrites the whole file from the database, with the lock held. +// Whole-file because the queue is current state rather than a log: one +// successful write heals whatever a failed one left behind. func flushLocked(db *sql.DB, dir string) error { + if dir == "" { + return nil + } all, err := allInsights(db) if err != nil { return err } path := InsightsPath(dir) if len(all) == 0 { - // No insights is an absent file, not an empty one. Someone who has - // never run a dream pass should not find a page in their vault about - // a queue they do not have. + // An empty queue is an absent file, so a vault that never dreamed has + // no page about it. + stamps.Forget(db) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("dreamed insights emptied in the cache but not in the vault: %w", err) } return nil } if err := vault.WriteAtomic(path, []byte(renderInsights(all))); err != nil { + // Whatever is on disk now is not what we last recorded writing. + stamps.Forget(db) return fmt.Errorf("insight saved to the cache but not to the vault: %w", err) } + stamps.Record(db, path) return nil } @@ -349,6 +394,18 @@ func Import(db *sql.DB, dir string) (int, error) { "restore the file or delete the partial line to accept it as-is", InsightsFile) } + restored, err := adoptLocked(db, raw) + if err != nil { + return restored, err + } + stamps.Adopted(db, raw) + return restored, nil +} + +// adoptLocked makes the cache match the file: every valid line is upserted at +// its id, and every row with no line is deleted. Returns how many rows it had +// to create. +func adoptLocked(db *sql.DB, raw []byte) (int, error) { parsed := parseInsights(string(raw)) keep := map[int64]bool{} restored := 0 diff --git a/internal/dream/insightstore_test.go b/internal/dream/insightstore_test.go index 2e0504b..51b2405 100644 --- a/internal/dream/insightstore_test.go +++ b/internal/dream/insightstore_test.go @@ -246,3 +246,53 @@ func TestTheQueueHoldsItsOwnLockName(t *testing.T) { } } } + +// Same failure as loops.md: the next enqueue regenerated the file from the +// cache and put a hand-deleted insight straight back in front of the user. +func TestAnInsightDeletedByHandStaysDeletedWhenTheNextOneArrives(t *testing.T) { + db := testDB(t) + dir := t.TempDir() + SetVault(db, dir) + t.Cleanup(func() { SetVault(db, "") }) + + first := Insight{Kind: Connection, Text: "an insight the user deletes by hand", EndpointA: 1, EndpointB: 2, Conf: 0.5} + if err := Enqueue(db, &first); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(InsightsPath(dir), []byte("---\ntype: dream-insights\npending: 0\n---\n\n"), 0o644); err != nil { + t.Fatal(err) + } + + second := Insight{Kind: Connection, Text: "a later insight", EndpointA: 3, EndpointB: 4, Conf: 0.5} + if err := Enqueue(db, &second); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(InsightsPath(dir)) + if strings.Contains(string(raw), "deletes by hand") { + t.Errorf("the next enqueue wrote a hand-deleted insight back:\n%s", raw) + } + if !strings.Contains(string(raw), "a later insight") { + t.Errorf("the new insight is not in the file:\n%s", raw) + } +} + +// Adopting the file first means a hand-deleted insight is gone by the time the +// verdict runs, and a verdict on no row returning nil let `logos dream reject` +// print "discarded insight 4" for one that was already gone. +func TestAVerdictOnAnInsightDeletedByHandSaysItIsGone(t *testing.T) { + db := testDB(t) + dir := t.TempDir() + SetVault(db, dir) + t.Cleanup(func() { SetVault(db, "") }) + in := Insight{Kind: Connection, Text: "an insight the user deletes by hand", EndpointA: 1, EndpointB: 2, Conf: 0.5} + if err := Enqueue(db, &in); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(InsightsPath(dir), []byte("---\ntype: dream-insights\npending: 0\n---\n\n"), 0o644); err != nil { + t.Fatal(err) + } + err := SetStatus(db, in.ID, Rejected) + if err == nil || !strings.Contains(err.Error(), InsightsFile) { + t.Errorf("a verdict on an insight deleted from %s: err = %v, want one that says the file no longer has it", InsightsFile, err) + } +} From fd9dad77d05dbd6072bc728ee5e4e3126f8d8e45 Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 1 Oct 2026 17:42:17 -0700 Subject: [PATCH 15/16] A proposal deleted from pending.md by hand stays rejected when the next one arrives or is reviewed, without waiting for an index --- internal/memory/logstore_test.go | 2 +- internal/memory/memory.go | 40 +++++++++-- internal/memory/pendingstore.go | 76 ++++++++++++++++----- internal/memory/pendingstore_test.go | 99 +++++++++++++++++++++++++++- internal/memory/quarantine.go | 52 +++++++++++---- internal/memory/vaultstore.go | 1 + 6 files changed, 232 insertions(+), 38 deletions(-) diff --git a/internal/memory/logstore_test.go b/internal/memory/logstore_test.go index 91cc6f7..4fb3543 100644 --- a/internal/memory/logstore_test.go +++ b/internal/memory/logstore_test.go @@ -127,7 +127,7 @@ func TestARestoredProposalKeepsTheDateItWasProposed(t *testing.T) { if _, err := db.Exec("UPDATE memories SET created = ? WHERE id = ?", proposed, m.ID); err != nil { t.Fatal(err) } - if err := flushPending(db); err != nil { + if err := withPending(db, func(dir string) error { return flushPendingLocked(db, dir) }); err != nil { t.Fatal(err) } // A vault from before log.md existed, which is every vault this repair is diff --git a/internal/memory/memory.go b/internal/memory/memory.go index a8ea79f..a52567d 100644 --- a/internal/memory/memory.go +++ b/internal/memory/memory.go @@ -352,6 +352,38 @@ func storeLocked(db *sql.DB, p *provider.Provider, embedModel string, m *Memory) } } + if m.Quarantined { + // A proposal is written down too. Not into memories/.md — that + // would defeat quarantine — but into the queue file, so a review the + // user has not got to yet survives deleting the cache. See + // internal/memory/pendingstore.go. That file is rewritten whole, so it + // gets what the kind file got above: hand edits adopted before the + // insert, and the queue lock held from there through the write. + var rec Receipt + err := withPending(db, func(dir string) error { + if err := reconcilePendingLocked(db, dir); err != nil { + return err + } + var err error + if rec, err = insertLocked(db, m, vec); err != nil || rec.Outcome != EvQuarantined { + return err + } + rec.Contested, rec.ContestedText = contested, contestedText + return flushPendingLocked(db, dir) + }) + return rec, err + } + rec, err := insertLocked(db, m, vec) + if err != nil || rec.Outcome != EvCreated { + return rec, err + } + return rec, flushLocked(db, m.Kind) +} + +// insertLocked is the row half of storeLocked: the insert, and the event that +// says what it was. The caller holds whichever lock covers the file the row +// belongs in, and writes that file. +func insertLocked(db *sql.DB, m *Memory, vec []byte) (Receipt, error) { dup, err := insertMemory(db, m, vec) if err != nil { return Receipt{}, err @@ -373,14 +405,10 @@ func storeLocked(db *sql.DB, p *provider.Provider, embedModel string, m *Memory) // distinct moments, the same way it already distinguishes created // from reinforced. See Accept in quarantine.go for the other half. logEvent(db, m.ID, EvQuarantined, m.Text, 0) - // A proposal is written down too. Not into memories/.md — - // that would defeat quarantine — but into the queue file, so a - // review the user has not got to yet survives deleting the cache. - // See internal/memory/pendingstore.go. - return Receipt{Outcome: EvQuarantined, ID: m.ID, Contested: contested, ContestedText: contestedText}, flushPending(db) + return Receipt{Outcome: EvQuarantined, ID: m.ID}, nil } logEvent(db, m.ID, EvCreated, m.Text, 0) - return Receipt{Outcome: EvCreated, ID: m.ID}, flushLocked(db, m.Kind) + return Receipt{Outcome: EvCreated, ID: m.ID}, nil } // idAttempts bounds the retry below. Eight writers colliding on one number is diff --git a/internal/memory/pendingstore.go b/internal/memory/pendingstore.go index 2b4b3e6..529c797 100644 --- a/internal/memory/pendingstore.go +++ b/internal/memory/pendingstore.go @@ -42,17 +42,6 @@ func pendingPath(dir string) string { return filepath.Join(dir, Dir, PendingFile) } -// flushPending rewrites the queue file from the database. Called after every -// change to what is pending — a proposal arriving, being accepted, or being -// rejected — so the file is never a stale view of a queue the user is working. -// -// Whole-file, like flush: a queue is current state, not a log, and rebuilding -// it each time means one successful write heals whatever the last failed one -// left behind. -func flushPending(db *sql.DB) error { - return withPending(db, func(dir string) error { return flushPendingLocked(db, dir) }) -} - // withPending runs fn holding the review queue's lock, against every process on // the machine. Taken *inside* the kind lock wherever both are held — Store's // quarantined arrival, Accept — and that order is fixed, because two agents @@ -74,6 +63,45 @@ func withPending(db *sql.DB, fn func(dir string) error) error { return fn(dir) } +// pendingStamps holds pending.md as each store last wrote it. See vault.Stamps. +var pendingStamps vault.Stamps + +// reconcilePendingLocked adopts hand edits to the queue file, with the queue +// lock held, and runs before anything that changes the queue and rewrites it. +// +// The file tells the user that deleting a line rejects the proposal. Without +// this, that only held if `logos index` ran first: the next proposal to arrive +// regenerated the file from the cache, put the deleted line back, and the +// proposal the user had rejected was waiting for review again. It has to run +// before the change, not after — once a new proposal is in the cache, nothing +// can tell it apart from a line the user deleted. +func reconcilePendingLocked(db *sql.DB, dir string) error { + if dir == "" { + return nil + } + raw, err := os.ReadFile(pendingPath(dir)) + if os.IsNotExist(err) { + return nil // says nothing about what is pending; see ImportPending + } + if err != nil { + return err + } + if pendingStamps.Ours(db, raw) { + return nil + } + if looksTruncated(string(raw)) { + // ImportPending refuses this file and says so on `logos index`. Here it + // is a reason not to adopt it, not to fail the write: the rewrite that + // follows replaces the torn file with the cache's complete queue. + return nil + } + if _, err := adoptPendingLocked(db, raw); err != nil { + return err + } + pendingStamps.Adopted(db, raw) + return nil +} + // flushPendingLocked reads the queue and writes the file with the lock held, and // the two have to be under the same lock for the same reason the memory files // do. Unserialised, two proposals arriving at once read the queue, both write @@ -96,14 +124,18 @@ func flushPendingLocked(db *sql.DB, dir string) error { if len(pend) == 0 { // An empty queue is an absent file. A reviewer who cleared their backlog // should not find a file telling them they have one. + pendingStamps.Forget(db) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("review queue emptied in the cache but not in the vault: %w", err) } return nil } if err := vault.WriteAtomic(path, []byte(renderPending(pend))); err != nil { + // Whatever is on disk now is not what we last recorded writing. + pendingStamps.Forget(db) return fmt.Errorf("proposal saved to the cache but not to the vault: %w", err) } + pendingStamps.Record(db, path) return nil } @@ -189,13 +221,25 @@ func ImportPending(db *sql.DB, dir string) (int, int, error) { "restore the file or delete the partial line to accept it as-is", PendingFile) } + restored, err := adoptPendingLocked(db, raw) + if err != nil { + return restored, 0, err + } + pendingStamps.Adopted(db, raw) + return restored, 0, nil +} + +// adoptPendingLocked makes the queue in the cache match the file: every line is +// restored, and every queued row with no line is rejected. Returns how many +// proposals it had to put back. +func adoptPendingLocked(db *sql.DB, raw []byte) (int, error) { parsed := parseKind(Fact, string(raw)) // kind= in each record overrides this default keep := map[int64]bool{} restored := 0 for _, m := range parsed { id, created, err := upsertPending(db, m) if err != nil { - return restored, 0, err + return restored, err } keep[id] = true if created { @@ -208,7 +252,7 @@ func ImportPending(db *sql.DB, dir string) (int, int, error) { // never be reaped by it. rows, err := db.Query("SELECT id, text FROM memories WHERE quarantined = 1 AND superseded = 0") if err != nil { - return restored, 0, err + return restored, err } type doomed struct { id int64 @@ -219,7 +263,7 @@ func ImportPending(db *sql.DB, dir string) (int, int, error) { var d doomed if err := rows.Scan(&d.id, &d.text); err != nil { rows.Close() - return restored, 0, err + return restored, err } if !keep[d.id] { gone = append(gone, d) @@ -231,10 +275,10 @@ func ImportPending(db *sql.DB, dir string) (int, int, error) { // flush would take, and the file it would rewrite is the one being read // as the source of truth right here. See rejectRow. if err := rejectRow(db, d.id, d.text); err != nil { - return restored, 0, err + return restored, err } } - return restored, 0, nil + return restored, nil } // rescuePendingLocked writes a queue that exists only in the cache out to the diff --git a/internal/memory/pendingstore_test.go b/internal/memory/pendingstore_test.go index 268f1ba..5847d42 100644 --- a/internal/memory/pendingstore_test.go +++ b/internal/memory/pendingstore_test.go @@ -1,6 +1,7 @@ package memory import ( + "database/sql" "os" "path/filepath" "strings" @@ -284,7 +285,7 @@ func TestAnUnboundDatabaseStillTakesProposals(t *testing.T) { } // A vault that predates pending.md has proposals in the cache and no file, and -// nothing repairs that: flushPending only runs when the queue changes, so a +// nothing repairs that: flushPendingLocked only runs when the queue changes, so a // queue nobody is touching stays in the one place that gets deleted. The real // vault this was found in had exactly that shape — two proposals from before // the file existed, and `logos index` after a wipe reported "the review queue is @@ -328,3 +329,99 @@ func TestAReviewQueueThatWasNeverWrittenDownIsWrittenDownBeforeItIsLost(t *testi t.Fatalf("restored %d proposals, want 1", n) } } + +// The next proposal regenerated the queue file from the cache, so a proposal +// the user had rejected by deleting its line came back for review — and the +// next `logos index` then had no deletion left to honour. +func TestAProposalDeletedByHandStaysDeletedWhenTheNextOneArrives(t *testing.T) { + db, dir := vaultDB(t) + first := Memory{Text: "Sam prefers async reviews", Kind: Person, Source: "mcp", Quarantined: true} + if _, err := Store(db, nil, "", &first); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, Dir, PendingFile) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var kept []string + for _, line := range strings.Split(string(raw), "\n") { + if !strings.Contains(line, "Sam prefers async reviews") { + kept = append(kept, line) + } + } + if err := os.WriteFile(path, []byte(strings.Join(kept, "\n")), 0o600); err != nil { + t.Fatal(err) + } + + second := Memory{Text: "the release train is weekly", Kind: Fact, Source: "mcp", Quarantined: true} + if _, err := Store(db, nil, "", &second); err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(path) + if strings.Contains(string(after), "Sam prefers async reviews") { + t.Errorf("the next proposal wrote a hand-rejected one back into the queue:\n%s", after) + } + if !strings.Contains(string(after), "the release train is weekly") { + t.Errorf("the new proposal is not in the queue:\n%s", after) + } +} + +// Adopting the file first means a proposal whose line the user deleted is +// already rejected when `logos review` acts on it from a list printed earlier. +// Accepting it then would overrule the rejection, and rejecting it again would +// report a second decision nobody made. +func TestReviewingAProposalDeletedByHandSaysItWasAlreadyRejected(t *testing.T) { + for _, verdict := range []struct { + name string + do func(*sql.DB, int64) error + }{{"accept", Accept}, {"reject", Reject}} { + t.Run(verdict.name, func(t *testing.T) { + db, dir := vaultDB(t) + m := Memory{Text: "Sam prefers async reviews", Kind: Person, Source: "mcp", Quarantined: true} + if _, err := Store(db, nil, "", &m); err != nil { + t.Fatal(err) + } + if _, err := Store(db, nil, "", &Memory{Text: "the release train is weekly", Kind: Fact, Source: "mcp", Quarantined: true}); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, Dir, PendingFile) + raw, _ := os.ReadFile(path) + var kept []string + for _, line := range strings.Split(string(raw), "\n") { + if !strings.Contains(line, "Sam prefers async reviews") { + kept = append(kept, line) + } + } + if err := os.WriteFile(path, []byte(strings.Join(kept, "\n")), 0o600); err != nil { + t.Fatal(err) + } + + err := verdict.do(db, m.ID) + if err == nil || !strings.Contains(err.Error(), PendingFile) { + t.Errorf("%s on a proposal deleted from %s: err = %v, want one that says deleting it rejected it", verdict.name, PendingFile, err) + } + for _, a := range activeTexts(t, db) { + if a == "Sam prefers async reviews" { + t.Error("a proposal the user rejected by hand was accepted into memory") + } + } + }) + } +} + +func activeTexts(t *testing.T, db *sql.DB) []string { + t.Helper() + rows, err := db.Query("SELECT text FROM memories WHERE quarantined = 0") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var s string + rows.Scan(&s) + out = append(out, s) + } + return out +} diff --git a/internal/memory/quarantine.go b/internal/memory/quarantine.go index 1cd5402..d33e00e 100644 --- a/internal/memory/quarantine.go +++ b/internal/memory/quarantine.go @@ -104,20 +104,35 @@ func Accept(db *sql.DB, id int64) error { if err := reconcileLocked(db, Kind(kind)); err != nil { return err } - if _, err := db.Exec("UPDATE memories SET quarantined = 0 WHERE id = ?", id); err != nil { - return err - } - logEvent(db, id, EvAccepted, text, 0) - // Now that it is active, it belongs in the vault the same as anything - // else — this is the moment it moves out of the review queue and into - // memory. Both files change, and the memory file is written first: a - // crash between the two leaves a proposal that is already remembered - // still listed as pending, which a second accept resolves. The other - // order would drop it from the queue with nothing holding it. - if err := flushLocked(db, Kind(kind)); err != nil { - return err - } - return flushPending(db) + // The queue file is rewritten below too, so its hand edits are adopted + // first, under its lock, for the reason reconcilePendingLocked gives. + return withPending(db, func(dir string) error { + if err := reconcilePendingLocked(db, dir); err != nil { + return err + } + res, err := db.Exec("UPDATE memories SET quarantined = 0 WHERE id = ? AND quarantined = 1", id) + if err != nil { + return err + } + // The id was checked before the locks; the user may since have + // deleted its line, which rejected it. Accepting a rejection back + // into memory would overrule them, and saying nothing would report + // an accept that did not happen. + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("memory #%d is not in %s — deleting its line there rejected it", id, PendingFile) + } + logEvent(db, id, EvAccepted, text, 0) + // Now that it is active, it belongs in the vault the same as anything + // else — this is the moment it moves out of the review queue and into + // memory. Both files change, and the memory file is written first: a + // crash between the two leaves a proposal that is already remembered + // still listed as pending, which a second accept resolves. The other + // order would drop it from the queue with nothing holding it. + if err := flushLocked(db, Kind(kind)); err != nil { + return err + } + return flushPendingLocked(db, dir) + }) }) } @@ -141,6 +156,15 @@ func Reject(db *sql.DB, id int64) error { // rewrite regenerates the file from the queue, so a second rejection landing // between them would write a file that still lists this one. return withPending(db, func(dir string) error { + if err := reconcilePendingLocked(db, dir); err != nil { + return err + } + // Already rejected by the hand edit just adopted: the outcome the + // caller asked for, but not something this call did, so it says so + // rather than logging a second rejection. + if !isQueued(db, id) { + return fmt.Errorf("memory #%d is not in %s — deleting its line there already rejected it", id, PendingFile) + } if err := rejectRow(db, id, text); err != nil { return err } diff --git a/internal/memory/vaultstore.go b/internal/memory/vaultstore.go index 1babbe5..9cff1ed 100644 --- a/internal/memory/vaultstore.go +++ b/internal/memory/vaultstore.go @@ -62,6 +62,7 @@ var ( // behind. func SetVault(db *sql.DB, dir string) { forgetLoggedHigh(db) + pendingStamps.Forget(db) vaultMu.Lock() defer vaultMu.Unlock() if dir == "" { From e995633cb3cc62a969f3124801805f924459eac4 Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 1 Oct 2026 17:44:10 -0700 Subject: [PATCH 16/16] The loops, insight and review-queue files say a deleted line is gone from the next change logos makes there, not only from the next index --- internal/dream/insightstore.go | 5 +++-- internal/memory/pendingstore.go | 3 ++- internal/secretary/loopstore.go | 5 +++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/dream/insightstore.go b/internal/dream/insightstore.go index 8914c82..876a015 100644 --- a/internal/dream/insightstore.go +++ b/internal/dream/insightstore.go @@ -197,8 +197,9 @@ func renderInsights(all []Insight) string { "memory yet: nothing here is recalled or packed into context until you\n" + "accept it.\n\n") b.WriteString("Run `logos dream review` to accept or reject them. Deleting a line here\n" + - "discards that insight on the next `logos index`; this file is the record,\n" + - "not the database.\n\n") + "discards that insight, from the next insight logos queues or reviews, or the\n" + + "next `logos index`, whichever is first; this file is the record, not the\n" + + "database.\n\n") for _, in := range all { fmt.Fprintf(&b, "- %s