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/... 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/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/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/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 +} 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..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,203 +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 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 @@ -758,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 { @@ -1138,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 { 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/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 +} 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/advice/advice.go b/internal/advice/advice.go new file mode 100644 index 0000000..7b5e685 --- /dev/null +++ b/internal/advice/advice.go @@ -0,0 +1,77 @@ +// 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, Verified string + Decision, RuledOut [2]string +} + +// CLI is `logos checkpoint`'s spelling. +var CLI = Fields{ + 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`", Verified: "`verified`", + 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.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)) + } + 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..5906dd8 --- /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) != 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", "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{"--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{"`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) + } + } +} + +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/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..876a015 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 } @@ -152,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