Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28d9f40733 | ||
|
|
3564498a1b | ||
|
|
9fd9a0b920 | ||
|
|
b340f86a45 | ||
|
|
f450a6c84f | ||
|
|
5a5d938e8e | ||
|
|
cc86a74dc5 | ||
|
|
6e449b464c | ||
|
|
868fe28c96 |
@@ -1,7 +1,6 @@
|
|||||||
# Supervisor MCP server
|
# Supervisor MCP server
|
||||||
SUPERVISOR_PORT=3200
|
SUPERVISOR_PORT=3200
|
||||||
SUPERVISOR_CONFIG_DIR=./config/supervisor
|
SUPERVISOR_CONFIG_DIR=./config/supervisor
|
||||||
SUPERVISOR_MODELS_FILE=./config/models.yaml
|
|
||||||
|
|
||||||
# LiteLLM gateway (iguana)
|
# LiteLLM gateway (iguana)
|
||||||
LITELLM_BASE_URL=http://iguana:4000
|
LITELLM_BASE_URL=http://iguana:4000
|
||||||
|
|||||||
+13
-5
@@ -81,11 +81,19 @@ jobs:
|
|||||||
git config user.email "cd-bot@d-ma.be"
|
git config user.email "cd-bot@d-ma.be"
|
||||||
git config user.name "CD Bot"
|
git config user.name "CD Bot"
|
||||||
git add "k3s/apps/supervisor/ingestion-deployment.yaml"
|
git add "k3s/apps/supervisor/ingestion-deployment.yaml"
|
||||||
git commit -m "chore(deploy): ingestion → ${IMAGE_TAG}"
|
# Idempotent: `git commit` exits 1 on an empty index, so a run that
|
||||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
# finds the bump already applied failed the whole job and rang the
|
||||||
git push
|
# CI-red alarm for a deploy that was fine (hyperguild#118). Two
|
||||||
|
# sources of a no-op run: a manual re-run, and the tag-push CI run
|
||||||
echo "Infra repo updated: ingestion → ${IMAGE_TAG}"
|
# that used to shadow the branch-push one for the same sha.
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "Infra already at ${IMAGE_TAG} — nothing to commit"
|
||||||
|
else
|
||||||
|
git commit -m "chore(deploy): ingestion → ${IMAGE_TAG}"
|
||||||
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||||
|
git push
|
||||||
|
echo "Infra repo updated: ingestion → ${IMAGE_TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Trigger Flux reconcile (immediate)
|
- name: Trigger Flux reconcile (immediate)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ name: CI
|
|||||||
|
|
||||||
"on":
|
"on":
|
||||||
push:
|
push:
|
||||||
|
# Branch only. A `v*` tag here points at a commit main already tested, so
|
||||||
|
# the tag push re-ran the whole gate and — because cd.yml keys off "CI
|
||||||
|
# succeeded" — triggered a SECOND full image build and deploy for the same
|
||||||
|
# sha. That second deploy then died on "nothing to commit" (hyperguild#118).
|
||||||
|
# Nothing consumes a tag-triggered run: there is no release workflow, and
|
||||||
|
# cd.yml reads github.sha, not the tag.
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
ingestion: cd ingestion && INGEST_BRAIN_DIR=../brain INGEST_PORT=3300 INGEST_WATCH_INTERVAL=30 go run ./cmd/server/
|
ingestion: cd ingestion && INGEST_BRAIN_DIR=../brain INGEST_PORT=3300 INGEST_WATCH_INTERVAL=30 go run ./cmd/server/
|
||||||
supervisor: SUPERVISOR_CONFIG_DIR=./config/supervisor SUPERVISOR_MODELS_FILE=./config/models.yaml SUPERVISOR_SESSIONS_DIR=./brain/sessions INGEST_BASE_URL=http://localhost:3300 INGEST_SVC_URL=http://localhost:3300 go run ./cmd/supervisor/
|
|
||||||
|
|||||||
@@ -76,6 +76,46 @@ that prompted the question is answered by naming it here instead.
|
|||||||
Full reasoning, including the reversal trigger: brain
|
Full reasoning, including the reversal trigger: brain
|
||||||
`wiki/hyperguild/decisions/brain-mcp-stays-in-ingestion-fix-discoverability-by-name`.
|
`wiki/hyperguild/decisions/brain-mcp-stays-in-ingestion-fix-discoverability-by-name`.
|
||||||
|
|
||||||
|
## Ingest revises, it does not overwrite
|
||||||
|
|
||||||
|
Re-ingesting a source **merges into the page already on disk** rather than
|
||||||
|
replacing it. Previously the write was unconditional, so a second ingest of the
|
||||||
|
same source discarded everything accumulated since — including hand-written
|
||||||
|
sections.
|
||||||
|
|
||||||
|
The merge is deliberately asymmetric in the existing page's favour:
|
||||||
|
|
||||||
|
| On the page | In the fresh extraction | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| a section | the same section | **the on-disk version wins** |
|
||||||
|
| — | a new section | added |
|
||||||
|
| a bullet list (`Sources`, `Key Claims`, …) | more bullets | unioned, no duplicates |
|
||||||
|
| an append section (`Open Questions`, `Updates`, …) | more lines | only genuinely new lines appended |
|
||||||
|
| frontmatter | frontmatter | **the on-disk frontmatter wins** |
|
||||||
|
|
||||||
|
So an extraction can add to a note but never silently rewrite what a human put
|
||||||
|
there, which is what makes it safe to run on every ingest rather than behind a
|
||||||
|
review gate.
|
||||||
|
|
||||||
|
Re-ingesting unchanged content is **byte-identical** — no diff, so brain-sync
|
||||||
|
does not commit noise that hides real edits.
|
||||||
|
|
||||||
|
Two fields are **generator-owned** and do get refreshed on a revise:
|
||||||
|
`last_updated` and `source_hash`. Everything else on disk wins. The boundary is
|
||||||
|
an explicit allowlist, so a field nobody has thought about stays human-owned.
|
||||||
|
|
||||||
|
## Re-ingesting an unchanged source is skipped
|
||||||
|
|
||||||
|
Ingest stores `source_hash` — sha256 of the ingested **body**, frontmatter
|
||||||
|
excluded — on the source page. A later ingest of the same source compares it
|
||||||
|
first and skips extraction entirely when it matches, reporting the skip rather
|
||||||
|
than doing it silently.
|
||||||
|
|
||||||
|
Body-only is deliberate: a re-crawl that stamps a new fetch date has not changed
|
||||||
|
the source, and treating that as drift would re-extract the corpus every time a
|
||||||
|
crawler ran. A missing or unreadable hash always means "extract" — a missed skip
|
||||||
|
costs one extraction, a wrong skip loses an update.
|
||||||
|
|
||||||
## Phase 1 tools (available now)
|
## Phase 1 tools (available now)
|
||||||
|
|
||||||
| Tool | What it does |
|
| Tool | What it does |
|
||||||
|
|||||||
+9
-26
@@ -21,7 +21,7 @@ tasks:
|
|||||||
# ── Development ────────────────────────────────────────────────────────────
|
# ── Development ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
start:
|
start:
|
||||||
desc: Start ingestion + supervisor (requires goreman — go install github.com/mattn/goreman@latest)
|
desc: Start ingestion (requires goreman — go install github.com/mattn/goreman@latest)
|
||||||
cmds:
|
cmds:
|
||||||
- goreman start
|
- goreman start
|
||||||
|
|
||||||
@@ -32,11 +32,6 @@ tasks:
|
|||||||
- lsof -ti:3200 | xargs kill -9 2>/dev/null || true
|
- lsof -ti:3200 | xargs kill -9 2>/dev/null || true
|
||||||
- echo "hyperguild stopped"
|
- echo "hyperguild stopped"
|
||||||
|
|
||||||
supervisor:dev:
|
|
||||||
desc: Run supervisor MCP server (development)
|
|
||||||
cmds:
|
|
||||||
- go run ./cmd/supervisor
|
|
||||||
|
|
||||||
hyperguild:dev:
|
hyperguild:dev:
|
||||||
desc: Run hyperguild CLI from source (e.g. task hyperguild:dev -- tier)
|
desc: Run hyperguild CLI from source (e.g. task hyperguild:dev -- tier)
|
||||||
cmds:
|
cmds:
|
||||||
@@ -67,14 +62,9 @@ tasks:
|
|||||||
build:
|
build:
|
||||||
desc: Build all binaries
|
desc: Build all binaries
|
||||||
cmds:
|
cmds:
|
||||||
- task: supervisor:build
|
- task: hyperguild:build
|
||||||
- task: ingestion:build
|
- task: ingestion:build
|
||||||
|
|
||||||
supervisor:build:
|
|
||||||
desc: Build supervisor binary
|
|
||||||
cmds:
|
|
||||||
- go build -trimpath -ldflags="-s -w -X main.version={{.VERSION}}" -o bin/supervisor ./cmd/supervisor
|
|
||||||
|
|
||||||
ingestion:build:
|
ingestion:build:
|
||||||
desc: Build ingestion server binary
|
desc: Build ingestion server binary
|
||||||
dir: ingestion
|
dir: ingestion
|
||||||
@@ -84,7 +74,7 @@ tasks:
|
|||||||
# ── Quality ────────────────────────────────────────────────────────────────
|
# ── Quality ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
check:
|
check:
|
||||||
desc: Run all checks (context freshness + lint + test + vet) across all modules
|
desc: Run all checks (context freshness + lint + test + vet + build) across all modules
|
||||||
cmds:
|
cmds:
|
||||||
# Context sync is skipped under CI, matching dma-cli, gitea-mcp, and the
|
# Context sync is skipped under CI, matching dma-cli, gitea-mcp, and the
|
||||||
# rest of the estate. It is not a preference: context-sync.sh walks UP
|
# rest of the estate. It is not a preference: context-sync.sh walks UP
|
||||||
@@ -120,6 +110,12 @@ tasks:
|
|||||||
- task: lint
|
- task: lint
|
||||||
- task: test
|
- task: test
|
||||||
- task: vet
|
- task: vet
|
||||||
|
# `build` is in the gate deliberately (hyperguild#120). vet and test walk
|
||||||
|
# ./... , which enumerates the packages that EXIST — so a build target
|
||||||
|
# naming a DELETED package is invisible to both. `task build` pointed at
|
||||||
|
# ./cmd/supervisor for months after that directory was removed, and CI
|
||||||
|
# stayed green the whole time because nothing in check ever built.
|
||||||
|
- task: build
|
||||||
- task: security:gitleaks
|
- task: security:gitleaks
|
||||||
|
|
||||||
# ── Security ─────────────────────────────────────────────
|
# ── Security ─────────────────────────────────────────────
|
||||||
@@ -173,19 +169,6 @@ tasks:
|
|||||||
- govulncheck ./... || true
|
- govulncheck ./... || true
|
||||||
- cd ingestion && go vet ./...
|
- cd ingestion && go vet ./...
|
||||||
|
|
||||||
supervisor:test:smoke:
|
|
||||||
desc: Smoke test supervisor via MCP (requires start running)
|
|
||||||
cmds:
|
|
||||||
- |
|
|
||||||
curl -s -X POST http://localhost:${SUPERVISOR_PORT:-3200}/mcp \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq .
|
|
||||||
|
|
||||||
smoke:routing:
|
|
||||||
desc: Boot the routing pod against live LiteLLM + brain and verify _routing logs land
|
|
||||||
cmds:
|
|
||||||
- bash scripts/smoke-routing.sh
|
|
||||||
|
|
||||||
# ── Git / Release ──────────────────────────────────────────────────────────
|
# ── Git / Release ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
tag:
|
tag:
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
# Model selection — first entry per skill is used.
|
|
||||||
# Override per-call by passing model in the MCP tool args.
|
|
||||||
# Model names come from LiteLLM /v1/models (host/name format).
|
|
||||||
|
|
||||||
default_chain:
|
|
||||||
- iguana/qwen3-coder-next
|
|
||||||
|
|
||||||
skills:
|
|
||||||
tdd:
|
|
||||||
chain:
|
|
||||||
- koala/qwen3-coder-30b
|
|
||||||
review:
|
|
||||||
chain:
|
|
||||||
- iguana/devstral
|
|
||||||
debug:
|
|
||||||
chain:
|
|
||||||
- iguana/deepseek-r1-14b
|
|
||||||
spec:
|
|
||||||
chain:
|
|
||||||
- koala/phi4-14b
|
|
||||||
retrospective:
|
|
||||||
chain:
|
|
||||||
- iguana/qwen3-coder-next
|
|
||||||
trainer:
|
|
||||||
chain:
|
|
||||||
- iguana/qwen3-coder-next
|
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContentHash returns the lowercase hex sha256 of b. It is the note's
|
// ContentHash returns the lowercase hex sha256 of b. It is the note's
|
||||||
@@ -98,14 +99,47 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
|||||||
}
|
}
|
||||||
priorHash = ContentHash(prior)
|
priorHash = ContentHash(prior)
|
||||||
|
|
||||||
|
// A caller may lead opts.Content with its own frontmatter fence to set or
|
||||||
|
// change fields on supersede (e.g. the claim envelope). Split it off
|
||||||
|
// before it reaches the body — left un-split, it would render as a
|
||||||
|
// second, inert `---`-fenced block of literal text rather than taking
|
||||||
|
// effect, and any claim-envelope fields inside it would reach disk
|
||||||
|
// unvalidated (hyperguild#111: the same rule that guards note birth in
|
||||||
|
// writeHallNote must guard a change to the same fields).
|
||||||
|
contentFM, contentBody := parseFrontmatter(opts.Content)
|
||||||
|
|
||||||
fm, _ := parseFrontmatter(string(prior))
|
fm, _ := parseFrontmatter(string(prior))
|
||||||
|
for _, l := range contentFM.lines {
|
||||||
|
if l.key != "" {
|
||||||
|
fm.set(l.key, l.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
fm.set("updated_at", time.Now().UTC().Format(time.RFC3339))
|
fm.set("updated_at", time.Now().UTC().Format(time.RFC3339))
|
||||||
fm.set("supersedes", priorHash)
|
fm.set("supersedes", priorHash)
|
||||||
if opts.Reason != "" {
|
if opts.Reason != "" {
|
||||||
fm.set("supersede_reason", opts.Reason)
|
fm.set("supersede_reason", opts.Reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := []byte(fm.render() + opts.Content)
|
// Validate the envelope AFTER the merge, against the full field set that
|
||||||
|
// is about to reach disk — not just the caller's fragment. Validating the
|
||||||
|
// fragment alone would miss a caller that supersedes claim_provenance to
|
||||||
|
// empty while half_life/applies survive untouched from the prior note:
|
||||||
|
// each side looks individually fine, but the merged result is an
|
||||||
|
// unprovenanced envelope, which is the exact laundering this check
|
||||||
|
// exists to catch. (Caught in review before merge, not filed after —
|
||||||
|
// worth being honest about in the history: this second pass was the
|
||||||
|
// actual fix; the first one validated the wrong document.)
|
||||||
|
envelope := make(map[string]string, len(fm.lines))
|
||||||
|
for _, l := range fm.lines {
|
||||||
|
if l.key != "" {
|
||||||
|
envelope[l.key] = l.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := claim.ValidateEnvelope(envelope); err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("claim envelope: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := []byte(fm.render() + contentBody)
|
||||||
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
||||||
return "", "", "", fmt.Errorf("write: %w", err)
|
return "", "", "", fmt.Errorf("write: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,114 @@ func TestUpdateNoteSupersedesAndStamps(t *testing.T) {
|
|||||||
assert.Contains(t, s, "supersede_reason: facts changed")
|
assert.Contains(t, s, "supersede_reason: facts changed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteRejectsInvalidEnvelopeAndWritesNothing(t *testing.T) {
|
||||||
|
// hyperguild#111: WriteNote validates the claim envelope before create;
|
||||||
|
// UpdateNote didn't before supersede, so the same rule must hold on
|
||||||
|
// change as on birth, fail-closed, nothing written on rejection.
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
original := "---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\n---\n# Old\n\nold body\n"
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original)
|
||||||
|
|
||||||
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "---\nhalf_life: forever\n---\n# New\n\nnew body\n",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "claim envelope")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, original, string(got), "rejected update must leave the note byte-identical")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteRejectsAppliesWithoutProvenance(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
original := "---\nwing: jepa-fx\nhall: facts\n---\nold\n"
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original)
|
||||||
|
|
||||||
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "---\napplies: universal\n---\nnew\n",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "claim envelope")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, original, string(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteAcceptsValidEnvelopeAndMergesIntoFrontmatter(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
||||||
|
"---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\n---\nold\n")
|
||||||
|
|
||||||
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "---\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\nnew body\n",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(got)
|
||||||
|
assert.Contains(t, s, "half_life: durable")
|
||||||
|
assert.Contains(t, s, "applies: universal")
|
||||||
|
assert.Contains(t, s, "claim_provenance: asserted")
|
||||||
|
assert.Contains(t, s, "wing: jepa-fx", "prior fields survive")
|
||||||
|
assert.Contains(t, s, "new body")
|
||||||
|
assert.Equal(t, 2, strings.Count(s, "---\n"),
|
||||||
|
"exactly one open+close fence pair — the caller's own fence must not leak in as a second, inert block")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteRejectsClearingProvenanceWhileEnvelopeSurvives(t *testing.T) {
|
||||||
|
// Found in review, not written speculatively: validating only the
|
||||||
|
// caller's fragment (rather than the merged result) let a caller blank
|
||||||
|
// out claim_provenance alone while half_life/applies survived from the
|
||||||
|
// prior note untouched — each side looks fine on its own, the merged
|
||||||
|
// envelope on disk is the unprovenanced one the whole check exists to
|
||||||
|
// catch.
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
original := "---\nwing: jepa-fx\nhall: facts\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\nold\n"
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original)
|
||||||
|
|
||||||
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "---\nclaim_provenance: \n---\nnew body\n",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "claim envelope")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, original, string(got), "rejected update must leave the note byte-identical")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteWithoutEnvelopeFieldsIsUnchangedFromToday(t *testing.T) {
|
||||||
|
// Regression pin: plain fence-less content (the common case) must behave
|
||||||
|
// exactly as it did before the envelope-merge fix.
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
||||||
|
"---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\ncustom: keep-me\n---\n# Old\n\nold body\n")
|
||||||
|
|
||||||
|
relPath, hash, priorHash, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "# New\n\nnew body\n",
|
||||||
|
Reason: "facts changed",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, rel, relPath)
|
||||||
|
assert.NotEmpty(t, hash)
|
||||||
|
assert.NotEqual(t, hash, priorHash)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(got)
|
||||||
|
assert.Contains(t, s, "# New")
|
||||||
|
assert.Contains(t, s, "custom: keep-me")
|
||||||
|
assert.Contains(t, s, "supersedes: "+priorHash)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateNoteResolvesByWingHallSlug(t *testing.T) {
|
func TestUpdateNoteResolvesByWingHallSlug(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package mcp
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -180,6 +181,13 @@ func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (notes.R
|
|||||||
}
|
}
|
||||||
res, err := notes.Edit(s.brainDir, a.Path, a.OldStr, a.NewStr, scope)
|
res, err := notes.Edit(s.brainDir, a.Path, a.OldStr, a.NewStr, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, notes.ErrDegenerate) && a.OldStr == "" {
|
||||||
|
// hyperguild#115: "old_str is empty" alone doesn't say whether the
|
||||||
|
// caller sent it empty or a hop between the caller and here lost
|
||||||
|
// it. Naming the raw argument byte count makes that distinguishable
|
||||||
|
// without a second investigation.
|
||||||
|
return notes.Result{}, nil, fmt.Errorf("%w (raw arguments were %d bytes)", err, len(args))
|
||||||
|
}
|
||||||
return notes.Result{}, nil, err
|
return notes.Result{}, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,29 @@ func TestBrainReadAndEdit_RoundTripThroughJSONRPC(t *testing.T) {
|
|||||||
"only the matched bytes may change")
|
"only the matched bytes may change")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBrainEdit_EmptyOldStrErrorNamesRawArgumentBytes(t *testing.T) {
|
||||||
|
// hyperguild#115: a session reported "old_str is empty" with an old_str
|
||||||
|
// that, on their end, was never empty. The server-side path was
|
||||||
|
// unreachable to reproduce (verified separately: a fresh brain_edit call
|
||||||
|
// with real content round-trips cleanly), which points at something
|
||||||
|
// between a specific client and this call losing the field. The fix this
|
||||||
|
// issue actually asked for isn't a behavior change — old_str=="" must
|
||||||
|
// still refuse — it's that the refusal should say how many raw argument
|
||||||
|
// bytes it received, so "client sent empty" is distinguishable from
|
||||||
|
// "server received empty after some hop lost it" without a second
|
||||||
|
// session's worth of guessing.
|
||||||
|
dir := newBrainRepo(t)
|
||||||
|
srv := mcp.NewServer(dir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := call(t, srv, "brain_edit", map[string]any{
|
||||||
|
"path": "wiki/homelab/facts/n.md", "old_str": "", "new_str": "BETA",
|
||||||
|
})
|
||||||
|
errMsg, ok := resp["error"].(map[string]any)["message"].(string)
|
||||||
|
require.True(t, ok, "%v", resp)
|
||||||
|
assert.Contains(t, errMsg, "old_str is empty")
|
||||||
|
assert.Contains(t, errMsg, "raw arguments were", "%v", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
func TestBrainEdit_RefusesGovernanceFilesFromTheModelFacingTool(t *testing.T) {
|
func TestBrainEdit_RefusesGovernanceFilesFromTheModelFacingTool(t *testing.T) {
|
||||||
dir := newBrainRepo(t)
|
dir := newBrainRepo(t)
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
srv := mcp.NewServer(dir, nil, nil, nil)
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ func buildFrontmatter(rp RawPage, date string) string {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, "date_ingested: %s\n", date)
|
fmt.Fprintf(&sb, "date_ingested: %s\n", date)
|
||||||
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
||||||
|
if rp.SourceHash != "" {
|
||||||
|
fmt.Fprintf(&sb, "source_hash: %s\n", rp.SourceHash)
|
||||||
|
}
|
||||||
if rp.Source != "" {
|
if rp.Source != "" {
|
||||||
fmt.Fprintf(&sb, "source: %s\n", yamlScalar(rp.Source))
|
fmt.Fprintf(&sb, "source: %s\n", yamlScalar(rp.Source))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,13 @@ type RawPage struct {
|
|||||||
// ingested content's own frontmatter (see parseContentFrontmatter) — never
|
// ingested content's own frontmatter (see parseContentFrontmatter) — never
|
||||||
// set by the LLM. json:"-" keeps them immune to same-named keys the LLM
|
// set by the LLM. json:"-" keeps them immune to same-named keys the LLM
|
||||||
// might emit. Only meaningful for Type == "source".
|
// might emit. Only meaningful for Type == "source".
|
||||||
Source string `json:"-"`
|
// SourceHash is sha256 of the ingested BODY (frontmatter excluded), stamped
|
||||||
Author string `json:"-"`
|
// so the next ingest of the same source can tell "unchanged" from "edited"
|
||||||
Published string `json:"-"`
|
// without re-running extraction (#105).
|
||||||
|
SourceHash string `json:"-"`
|
||||||
|
Source string `json:"-"`
|
||||||
|
Author string `json:"-"`
|
||||||
|
Published string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseRawPages parses LLM output as a JSON array of RawPage objects.
|
// ParseRawPages parses LLM output as a JSON array of RawPage objects.
|
||||||
@@ -112,6 +116,11 @@ type sourceMeta struct {
|
|||||||
Source string
|
Source string
|
||||||
Author string
|
Author string
|
||||||
Published string
|
Published string
|
||||||
|
// BodyHash is sha256 of the content with any leading frontmatter removed.
|
||||||
|
// Body-only on purpose: a re-crawl that stamps a new fetch date has not
|
||||||
|
// changed the source, and treating that as drift would re-extract the
|
||||||
|
// whole corpus every time a crawler ran (#105).
|
||||||
|
BodyHash string
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseContentFrontmatter extracts source/author/published from a leading
|
// parseContentFrontmatter extracts source/author/published from a leading
|
||||||
@@ -154,6 +163,7 @@ func applySourceMeta(pages []RawPage, meta sourceMeta) {
|
|||||||
if pages[i].Type != "source" {
|
if pages[i].Type != "source" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
pages[i].SourceHash = meta.BodyHash
|
||||||
pages[i].Source = meta.Source
|
pages[i].Source = meta.Source
|
||||||
pages[i].Author = meta.Author
|
pages[i].Author = meta.Author
|
||||||
pages[i].Published = meta.Published
|
pages[i].Published = meta.Published
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package pipeline
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -26,6 +28,11 @@ type Config struct {
|
|||||||
type Result struct {
|
type Result struct {
|
||||||
Pages []string // relative paths written (or would-be written in dry-run)
|
Pages []string // relative paths written (or would-be written in dry-run)
|
||||||
Warnings []string
|
Warnings []string
|
||||||
|
// Skipped reports that the source was byte-identical to the last ingest,
|
||||||
|
// so no extraction ran and nothing was written. Reported rather than
|
||||||
|
// silent: a run that does nothing must say so, or "the pipeline is fine"
|
||||||
|
// and "the pipeline stopped working" look the same (#105, #107).
|
||||||
|
Skipped bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run ingests content and writes structured wiki pages to brainDir/wiki/.
|
// Run ingests content and writes structured wiki pages to brainDir/wiki/.
|
||||||
@@ -43,13 +50,32 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
|
|
||||||
sourceSlug := wiki.Slug(source)
|
sourceSlug := wiki.Slug(source)
|
||||||
date := time.Now().UTC().Format("2006-01-02")
|
date := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
|
// Drift check BEFORE any extraction: an unchanged source costs a full LLM
|
||||||
|
// pass over every chunk to produce a result that merges to nothing (#105).
|
||||||
|
// The merge is already idempotent (#103), so this is cost and signal, not
|
||||||
|
// correctness — which is why it is safe for it to be an early return.
|
||||||
|
bodyHash := hashBody(content)
|
||||||
|
if !dryRun && bodyHash != "" && bodyHash == storedSourceHash(brainDir, sourceSlug) {
|
||||||
|
return Result{Skipped: true, Warnings: []string{
|
||||||
|
fmt.Sprintf("source %q unchanged since last ingest (hash %s) — extraction skipped", source, bodyHash[:12]),
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The source's own page path is deterministic (sourceSlug, computed above)
|
||||||
|
// even before extraction runs, unlike a concept/entity page's path, which
|
||||||
|
// only exists once the model has produced a title. So page-aware
|
||||||
|
// extraction (#106) is scoped to the source page: it's the one page every
|
||||||
|
// chunk's prompt can be given in advance.
|
||||||
|
existingPage := readSourcePage(brainDir, sourceSlug)
|
||||||
|
|
||||||
chunks := Chunk(content, cfg.ChunkSize)
|
chunks := Chunk(content, cfg.ChunkSize)
|
||||||
|
|
||||||
var allRaw []RawPage
|
var allRaw []RawPage
|
||||||
var allWarnings []string
|
var allWarnings []string
|
||||||
|
|
||||||
for _, chunk := range chunks {
|
for _, chunk := range chunks {
|
||||||
userPrompt := BuildPrompt(schema, source, chunk, inventory)
|
userPrompt := BuildPrompt(schema, source, chunk, inventory, existingPage)
|
||||||
output, err := cfg.Complete(ctx, systemPrompt, userPrompt)
|
output, err := cfg.Complete(ctx, systemPrompt, userPrompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, fmt.Errorf("LLM call: %w", err)
|
return Result{}, fmt.Errorf("LLM call: %w", err)
|
||||||
@@ -59,7 +85,9 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
allWarnings = append(allWarnings, warnings...)
|
allWarnings = append(allWarnings, warnings...)
|
||||||
}
|
}
|
||||||
|
|
||||||
applySourceMeta(allRaw, parseContentFrontmatter(content))
|
meta := parseContentFrontmatter(content)
|
||||||
|
meta.BodyHash = bodyHash
|
||||||
|
applySourceMeta(allRaw, meta)
|
||||||
|
|
||||||
return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
||||||
}
|
}
|
||||||
@@ -96,7 +124,11 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string
|
|||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
|
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(dest, []byte(page.Content), 0o644); err != nil {
|
content, err := reviseOrCreate(dest, page)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
|
||||||
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
|
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,6 +147,81 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string
|
|||||||
return Result{Pages: written, Warnings: warnings}, nil
|
return Result{Pages: written, Warnings: warnings}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hashBody returns sha256 of content with any leading frontmatter block
|
||||||
|
// removed, or "" for empty content.
|
||||||
|
//
|
||||||
|
// Body-only on purpose: a re-crawl that stamps a new fetch date has not changed
|
||||||
|
// the source, and treating that as drift would re-extract the corpus every time
|
||||||
|
// a crawler ran (#105).
|
||||||
|
func hashBody(content string) string {
|
||||||
|
body := content
|
||||||
|
if strings.HasPrefix(body, "---\n") {
|
||||||
|
if _, rest, found := strings.Cut(body[4:], "\n---"); found {
|
||||||
|
body = strings.TrimPrefix(rest, "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(body) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(body))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSourcePage returns the full on-disk content of the source page at
|
||||||
|
// wiki/sources/<sourceSlug>.md, or "" when it doesn't exist yet.
|
||||||
|
func readSourcePage(brainDir, sourceSlug string) string {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(brainDir, "wiki", "sources", sourceSlug+".md"))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// storedSourceHash reads source_hash from the source page a previous ingest
|
||||||
|
// wrote, or "" when there is no such page or no such field.
|
||||||
|
//
|
||||||
|
// Absence always means "extract". That is the fail-safe direction: a missed
|
||||||
|
// skip costs one extraction, while a wrong skip loses an update silently — and
|
||||||
|
// silence is the failure mode this repo keeps paying for.
|
||||||
|
func storedSourceHash(brainDir, sourceSlug string) string {
|
||||||
|
for _, line := range strings.Split(readSourcePage(brainDir, sourceSlug), "\n") {
|
||||||
|
if key, value, found := strings.Cut(line, ":"); found && strings.TrimSpace(key) == "source_hash" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// reviseOrCreate returns the bytes to write for one extracted page: merged into
|
||||||
|
// whatever is already on disk, or the page as-is when the path is new.
|
||||||
|
//
|
||||||
|
// This is the compile half of compile-on-ingest. Before it, the write was an
|
||||||
|
// unconditional os.WriteFile, so re-ingesting a source replaced its page
|
||||||
|
// wholesale and discarded everything accumulated since — including human edits
|
||||||
|
// (hyperguild#43: "re-ingest overwrites wholesale").
|
||||||
|
//
|
||||||
|
// The merge is deliberately asymmetric in the existing page's favour:
|
||||||
|
// wiki.Merge keeps the on-disk frontmatter and the on-disk version of any
|
||||||
|
// section both sides carry, adds sections only the extraction produced, and
|
||||||
|
// unions bullet lists. So a freshly extracted page can add to a note but never
|
||||||
|
// silently rewrite what a human put there — which is what makes it safe to run
|
||||||
|
// this on every ingest rather than behind a review gate.
|
||||||
|
//
|
||||||
|
// Known limitation: because the on-disk frontmatter wins, `last_updated` keeps
|
||||||
|
// its first-ingest date on a revised page. Left deliberately — the alternative
|
||||||
|
// is this function editing frontmatter it does not own, and no lint reads that
|
||||||
|
// field today.
|
||||||
|
func reviseOrCreate(dest string, page wiki.Page) (string, error) {
|
||||||
|
existing, err := os.ReadFile(dest)
|
||||||
|
switch {
|
||||||
|
case os.IsNotExist(err):
|
||||||
|
return page.Content, nil
|
||||||
|
case err != nil:
|
||||||
|
return "", fmt.Errorf("read existing %s: %w", page.Path, err)
|
||||||
|
}
|
||||||
|
return wiki.Merge(wiki.Page{Path: page.Path, Content: string(existing)}, page).Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
// mergeAll deduplicates pages by path, merging content from later occurrences.
|
// mergeAll deduplicates pages by path, merging content from later occurrences.
|
||||||
func mergeAll(pages []wiki.Page) []wiki.Page {
|
func mergeAll(pages []wiki.Page) []wiki.Page {
|
||||||
order := make([]string, 0, len(pages))
|
order := make([]string, 0, len(pages))
|
||||||
|
|||||||
@@ -171,3 +171,223 @@ func mustJSON(v any) string {
|
|||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- B1: compile-on-ingest — revise the existing page, never clobber it -----
|
||||||
|
|
||||||
|
func TestRunRaw_MergesIntoAnExistingPageInsteadOfOverwritingIt(t *testing.T) {
|
||||||
|
// Before this, buildAndWrite did an unconditional os.WriteFile: re-ingesting
|
||||||
|
// a source replaced its page wholesale, discarding anything accumulated
|
||||||
|
// since — including human edits. hyperguild#43 recorded the symptom as
|
||||||
|
// "re-ingest overwrites wholesale".
|
||||||
|
dir := t.TempDir()
|
||||||
|
page := filepath.Join(dir, "wiki", "sources", "shape-up.md")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(page), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(page, []byte(`---
|
||||||
|
title: 'Shape Up'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
A book about shaping product work.
|
||||||
|
|
||||||
|
## My Take
|
||||||
|
|
||||||
|
Read this before the next planning cycle.
|
||||||
|
`), 0o644))
|
||||||
|
|
||||||
|
_, err := RunRaw(dir, "shape-up", []RawPage{{
|
||||||
|
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product-strategy",
|
||||||
|
Content: "## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n",
|
||||||
|
}}, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(page)
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(got)
|
||||||
|
|
||||||
|
assert.Contains(t, s, "## My Take", "a section the extraction never saw must survive the merge")
|
||||||
|
assert.Contains(t, s, "Read this before the next planning cycle.")
|
||||||
|
assert.Contains(t, s, "Appetite beats estimation.", "newly extracted material must still land")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRaw_ReIngestingUnchangedContentIsByteIdentical(t *testing.T) {
|
||||||
|
// Compile-on-ingest runs on every ingest, so "nothing changed" must produce
|
||||||
|
// no diff at all. Otherwise every re-ingest dirties the tree, brain-sync
|
||||||
|
// commits it, and the git history fills with noise that hides real edits.
|
||||||
|
dir := t.TempDir()
|
||||||
|
raw := []RawPage{{
|
||||||
|
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product-strategy",
|
||||||
|
Content: "## Summary\n\nA book about shaping product work.\n\n## Open Questions\n\n- Does appetite scale?\n",
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err := RunRaw(dir, "shape-up", raw, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
first, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = RunRaw(dir, "shape-up", raw, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
second, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, string(first), string(second),
|
||||||
|
"re-ingesting unchanged content must leave the page byte-identical")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- #105: source drift — an unchanged re-ingest must not re-extract --------
|
||||||
|
|
||||||
|
func TestRun_SkipsExtractionWhenTheSourceIsUnchanged(t *testing.T) {
|
||||||
|
// #103 made a re-ingest harmless (byte-identical merge). It is still not
|
||||||
|
// free: the full LLM extraction runs over every chunk to produce a result
|
||||||
|
// that merges to nothing. This is the cost half.
|
||||||
|
dir := t.TempDir()
|
||||||
|
var calls int
|
||||||
|
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
calls++
|
||||||
|
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
content := "---\nsource: https://example.com/shape-up\n---\n\nThe body of the article.\n"
|
||||||
|
|
||||||
|
first, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, calls, "first ingest must extract")
|
||||||
|
assert.False(t, first.Skipped)
|
||||||
|
|
||||||
|
second, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, calls, "unchanged content must not be extracted again")
|
||||||
|
assert.True(t, second.Skipped, "the skip must be reported, not silent")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_ReExtractsWhenTheSourceBodyChanged(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
var calls int
|
||||||
|
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
calls++
|
||||||
|
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), cfg, dir, "---\nsource: x\n---\n\nversion one\n", "shape-up", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = Run(context.Background(), cfg, dir, "---\nsource: x\n---\n\nversion two — the source was edited\n", "shape-up", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 2, calls, "changed content must be re-extracted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- #106: page-aware extraction — the model sees the page it is revising --
|
||||||
|
|
||||||
|
func TestRun_ExistingSourcePageIsIncludedInTheExtractionPrompt(t *testing.T) {
|
||||||
|
// #103 made the merge asymmetric (on-disk wins); #106 is the other half —
|
||||||
|
// the extraction itself gets to see what's already there instead of
|
||||||
|
// producing a page blind and letting the merge reconcile it afterwards.
|
||||||
|
// This only proves the existing page reaches the prompt; the byte-identical
|
||||||
|
// guarantee for an echoed-back revision is a separate test below.
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, sub := range []string{"wiki/concepts", "wiki/entities", "wiki/sources"} {
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, sub), 0o755))
|
||||||
|
}
|
||||||
|
|
||||||
|
seeded := "---\ntitle: 'Shape Up'\n---\n\n## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n"
|
||||||
|
page := filepath.Join(dir, "wiki", "sources", "shape-up.md")
|
||||||
|
require.NoError(t, os.WriteFile(page, []byte(seeded), 0o644))
|
||||||
|
|
||||||
|
var gotPrompt string
|
||||||
|
cfg := Config{Complete: func(_ context.Context, _, user string) (string, error) {
|
||||||
|
gotPrompt = user
|
||||||
|
return mustJSON([]RawPage{{
|
||||||
|
Title: "Shape Up", Type: "source", Subtype: "book",
|
||||||
|
Content: "## Summary\n\nA book about shaping product work.\n",
|
||||||
|
}}), nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Deliberately does not mention "Appetite beats estimation." — that phrase
|
||||||
|
// can only reach the prompt via the existing on-disk page, not the source
|
||||||
|
// content, so its presence proves the page was threaded through.
|
||||||
|
content := "A short editorial update to the book's listing. No new claims here."
|
||||||
|
_, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Contains(t, gotPrompt, "Appetite beats estimation.",
|
||||||
|
"the existing page's content must be visible to the extraction prompt")
|
||||||
|
assert.Contains(t, gotPrompt, "revis", "the prompt must ask for a revision, not a fresh page")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_NewSourceHasNoExistingPageInThePrompt(t *testing.T) {
|
||||||
|
// The other side of the same behavior: a source with no page on disk yet
|
||||||
|
// must not get an "existing page" section injected into its prompt at all.
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, sub := range []string{"wiki/concepts", "wiki/entities", "wiki/sources"} {
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, sub), 0o755))
|
||||||
|
}
|
||||||
|
|
||||||
|
var gotPrompt string
|
||||||
|
cfg := Config{Complete: func(_ context.Context, _, user string) (string, error) {
|
||||||
|
gotPrompt = user
|
||||||
|
return mustJSON([]RawPage{{
|
||||||
|
Title: "New Thing", Type: "source", Subtype: "article",
|
||||||
|
Content: "## Summary\n\nBrand new.\n",
|
||||||
|
}}), nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), cfg, dir, "Brand new content.", "new-thing", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NotContains(t, gotPrompt, "Current page",
|
||||||
|
"a source with no page on disk yet must not get a revision section in its prompt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_RevisionEchoingExistingPageStaysByteIdentical(t *testing.T) {
|
||||||
|
// The issue's explicit risk: a model given the current page will sometimes
|
||||||
|
// echo it back verbatim. That must not duplicate content or otherwise
|
||||||
|
// perturb the page — the deterministic merge is still the backstop.
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, sub := range []string{"wiki/concepts", "wiki/entities", "wiki/sources"} {
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, sub), 0o755))
|
||||||
|
}
|
||||||
|
|
||||||
|
seeded := "---\ntitle: 'Shape Up'\n---\n\n## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n"
|
||||||
|
page := filepath.Join(dir, "wiki", "sources", "shape-up.md")
|
||||||
|
require.NoError(t, os.WriteFile(page, []byte(seeded), 0o644))
|
||||||
|
|
||||||
|
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
return mustJSON([]RawPage{{
|
||||||
|
Title: "Shape Up", Type: "source", Subtype: "book",
|
||||||
|
Content: "## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n",
|
||||||
|
}}), nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
content := "A short editorial update to the book's listing. No new claims here."
|
||||||
|
result, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, result.Skipped)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(page)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
want := "---\ntitle: 'Shape Up'\nlast_updated: " + time.Now().UTC().Format("2006-01-02") +
|
||||||
|
"\nsource_hash: " + hashBody(content) + "\n---\n\n" +
|
||||||
|
"## Summary\n\nA book about shaping product work.\n\n" +
|
||||||
|
"## Key Claims\n\n- Appetite beats estimation.\n"
|
||||||
|
assert.Equal(t, want, string(got),
|
||||||
|
"a revision pass that echoes the existing page back must not duplicate or alter its content")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_FrontmatterOnlyChangeIsNotDrift(t *testing.T) {
|
||||||
|
// The hash covers the body, so re-fetching a page whose metadata moved
|
||||||
|
// (a re-crawl stamping a new date) is not treated as the source changing.
|
||||||
|
dir := t.TempDir()
|
||||||
|
var calls int
|
||||||
|
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
calls++
|
||||||
|
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), cfg, dir, "---\nsource: x\nfetched: monday\n---\n\nsame body\n", "shape-up", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = Run(context.Background(), cfg, dir, "---\nsource: x\nfetched: tuesday\n---\n\nsame body\n", "shape-up", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, calls, "only the body counts as the source")
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,8 +22,18 @@ Each element must have exactly these fields:
|
|||||||
Wikilinks in content: [[Display Name]] — just the display name, no slug, no pipe separator.
|
Wikilinks in content: [[Display Name]] — just the display name, no slug, no pipe separator.
|
||||||
Only link to pages listed in the inventory or pages you are creating in this response.`
|
Only link to pages listed in the inventory or pages you are creating in this response.`
|
||||||
|
|
||||||
// BuildPrompt constructs the user prompt for a single chunk.
|
// BuildPrompt constructs the user prompt for a single chunk. existingPage is
|
||||||
func BuildPrompt(schema, source, content string, inventory map[wiki.PageType][]wiki.Entry) string {
|
// the full current content (frontmatter + body) of the source's own wiki page
|
||||||
|
// when one already exists on disk, or "" for a source being ingested for the
|
||||||
|
// first time. When non-empty, the model is asked to revise that page rather
|
||||||
|
// than write a fresh one — see hyperguild#106. The deterministic merge in
|
||||||
|
// wiki.Merge remains the backstop regardless of what the model returns: an
|
||||||
|
// on-disk section the model doesn't touch survives untouched, and a section
|
||||||
|
// it does touch only takes effect where Merge's strategy allows the fresh
|
||||||
|
// side to change anything (bullet union, append) — never for an ordinary
|
||||||
|
// section, so the model proposing a revision is never how a page gets
|
||||||
|
// overwritten.
|
||||||
|
func BuildPrompt(schema, source, content string, inventory map[wiki.PageType][]wiki.Entry, existingPage string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
fmt.Fprintf(&sb, "Today's date is %s.\n\n", time.Now().UTC().Format("2006-01-02"))
|
fmt.Fprintf(&sb, "Today's date is %s.\n\n", time.Now().UTC().Format("2006-01-02"))
|
||||||
@@ -49,6 +59,18 @@ func BuildPrompt(schema, source, content string, inventory map[wiki.PageType][]w
|
|||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if existingPage != "" {
|
||||||
|
sb.WriteString("## Current page for this source — revise it, don't restart\n\n")
|
||||||
|
sb.WriteString("This source has already been ingested and has the wiki page shown below.\n")
|
||||||
|
sb.WriteString("You are revising that page, not writing a fresh one:\n")
|
||||||
|
sb.WriteString(" - Add sections or claims the source below supports that this page doesn't have yet.\n")
|
||||||
|
sb.WriteString(" - Strengthen a claim where the source gives more detail, evidence, or nuance than what's already there.\n")
|
||||||
|
sb.WriteString(" - Leave unchanged whatever the source doesn't affect — reuse the existing wording verbatim rather than paraphrasing it, so an unchanged claim is recognized as unchanged.\n")
|
||||||
|
sb.WriteString(" - A merge step keeps this page's on-disk content for any section you don't return, so it's fine to omit a section you aren't revising.\n\n")
|
||||||
|
sb.WriteString(existingPage)
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
sb.WriteString("## Non-negotiable rules\n\n")
|
sb.WriteString("## Non-negotiable rules\n\n")
|
||||||
sb.WriteString("1. Output ONLY a valid JSON array — no prose, no fences.\n")
|
sb.WriteString("1. Output ONLY a valid JSON array — no prose, no fences.\n")
|
||||||
sb.WriteString("2. Fields: title, type, subtype (if applicable), domain (if applicable), content.\n")
|
sb.WriteString("2. Fields: title, type, subtype (if applicable), domain (if applicable), content.\n")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package wiki
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,38 +30,235 @@ type section struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Merge combines two Page values with the same path.
|
// Merge combines two Page values with the same path.
|
||||||
// Frontmatter is taken from a. Sections are merged by strategy:
|
// Frontmatter is taken from a. The preamble (content before the first ##
|
||||||
// bullet sections union unique lines, append sections concatenate,
|
// heading) is taken from a — same "on-disk wins" default every unlisted
|
||||||
// all others keep a's version. Sections in b not present in a are appended.
|
// named section gets, kept as a separate field rather than a section with
|
||||||
|
// an empty heading so it can never collide with one (see parseSections).
|
||||||
|
// Sections are merged by strategy: bullet sections union unique lines,
|
||||||
|
// append sections concatenate, all others keep a's version. Sections in b
|
||||||
|
// not present in a are appended.
|
||||||
func Merge(a, b Page) Page {
|
func Merge(a, b Page) Page {
|
||||||
fmA, secsA := parseSections(a.Content)
|
fmA, preambleA, secsA := parseSections(a.Content)
|
||||||
_, secsB := parseSections(b.Content)
|
fmB, _, secsB := parseSections(b.Content)
|
||||||
|
|
||||||
|
// First occurrence wins on a collision — a page can already carry two
|
||||||
|
// on-disk sections that only differ by case or whitespace (the exact
|
||||||
|
// corpus drift this normalization exists to catch going forward, found
|
||||||
|
// pre-existing in review of PR #130): a plain last-write assignment
|
||||||
|
// here would silently and deterministically route every future update
|
||||||
|
// to whichever duplicate happens to sort last in the file, freezing
|
||||||
|
// the other one forever with nothing to say so.
|
||||||
idx := make(map[string]int, len(secsA))
|
idx := make(map[string]int, len(secsA))
|
||||||
for i, s := range secsA {
|
for i, s := range secsA {
|
||||||
idx[s.heading] = i
|
key := normalizeHeading(s.heading)
|
||||||
|
if _, exists := idx[key]; !exists {
|
||||||
|
idx[key] = i
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, sB := range secsB {
|
for _, sB := range secsB {
|
||||||
i, exists := idx[sB.heading]
|
key := normalizeHeading(sB.heading)
|
||||||
|
i, exists := idx[key]
|
||||||
if !exists {
|
if !exists {
|
||||||
idx[sB.heading] = len(secsA)
|
idx[key] = len(secsA)
|
||||||
secsA = append(secsA, sB)
|
secsA = append(secsA, sB)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sA := secsA[i]
|
sA := secsA[i]
|
||||||
switch {
|
switch {
|
||||||
case bulletSections[sB.heading]:
|
case bulletSectionsNorm[key]:
|
||||||
secsA[i].content = mergeBullets(sA.content, sB.content)
|
secsA[i].content = mergeBullets(sA.content, sB.content)
|
||||||
case appendSections[sB.heading]:
|
case appendSectionsNorm[key]:
|
||||||
secsA[i].content = strings.TrimRight(sA.content, "\n") + "\n\n" + strings.TrimLeft(sB.content, "\n")
|
secsA[i].content = appendNew(sA.content, sB.content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Page{Path: a.Path, Content: rebuildContent(fmA, secsA)}
|
return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), preambleA, secsA)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSections(markdown string) (frontmatter string, sections []section) {
|
// normalizeHeading returns the key Merge uses to decide "is this the same
|
||||||
|
// section" — trimmed and case-folded, so "Key Claims" and "Key claims" match
|
||||||
|
// (hyperguild#106: heading casing drift must not fork a section). It is used
|
||||||
|
// only for that comparison; the on-disk section's original heading text
|
||||||
|
// (secsA[i].heading, untouched here) is what actually gets written out, so a
|
||||||
|
// human's heading casing is never silently rewritten by a merge.
|
||||||
|
func normalizeHeading(h string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(h))
|
||||||
|
}
|
||||||
|
|
||||||
|
// bulletSectionsNorm and appendSectionsNorm are bulletSections/appendSections
|
||||||
|
// keyed by normalizeHeading instead of exact text, so a section's merge
|
||||||
|
// strategy (union bullets, append) is chosen by the same normalized identity
|
||||||
|
// Merge uses to match it — otherwise a heading that matched an on-disk
|
||||||
|
// section (case-insensitively) could still fail the exact-string strategy
|
||||||
|
// lookup and silently fall through to "keep a's version," dropping b's
|
||||||
|
// content instead of unioning or appending it.
|
||||||
|
var bulletSectionsNorm = normalizeSet(bulletSections)
|
||||||
|
var appendSectionsNorm = normalizeSet(appendSections)
|
||||||
|
|
||||||
|
func normalizeSet(names map[string]bool) map[string]bool {
|
||||||
|
out := make(map[string]bool, len(names))
|
||||||
|
for k := range names {
|
||||||
|
out[normalizeHeading(k)] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatorOwned names the frontmatter fields the ingest pipeline may refresh
|
||||||
|
// on a page it is revising. Everything else on disk wins.
|
||||||
|
//
|
||||||
|
// #103 made the on-disk frontmatter win wholesale, which is what stops a
|
||||||
|
// regenerating writer from overwriting human-set metadata — a title someone
|
||||||
|
// fixed, or the claim-property envelope (brain#21). The cost was that fields
|
||||||
|
// the generator legitimately owns froze at their first-ingest values: when it
|
||||||
|
// last ran, and what it last saw.
|
||||||
|
//
|
||||||
|
// An explicit allowlist rather than a carve-out: the ownership boundary is
|
||||||
|
// nameable, and a field nobody has thought about stays human-owned by default,
|
||||||
|
// which is the safe direction.
|
||||||
|
var generatorOwned = map[string]bool{
|
||||||
|
"last_updated": true,
|
||||||
|
"source_hash": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmLine is one line of a frontmatter block: a parsed key/value pair, or a
|
||||||
|
// raw line (blank, comment, or anything else that isn't a simple "key:
|
||||||
|
// value") kept verbatim. Same shape as api.frontmatter's fmLine — this
|
||||||
|
// package doesn't import that one (it's unexported there, and a's/b's
|
||||||
|
// frontmatter here always arrives as a string, not a parsed struct), so the
|
||||||
|
// small, already-proven set()-appends-if-absent behavior is reproduced
|
||||||
|
// locally rather than string-index surgery reinvented per call site.
|
||||||
|
type fmLine struct {
|
||||||
|
key, value, raw string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFrontmatterBlock parses a "---\n...\n---\n" fenced block (the shape
|
||||||
|
// parseSections produces) into an ordered line list. A block with no
|
||||||
|
// opening/closing fence — including the empty string, a page with no
|
||||||
|
// frontmatter at all — returns nil, ok=false: there is nothing to update in
|
||||||
|
// place, only something to potentially create.
|
||||||
|
func parseFrontmatterBlock(block string) (lines []fmLine, ok bool) {
|
||||||
|
if !strings.HasPrefix(block, "---\n") {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
rest := block[len("---\n"):]
|
||||||
|
end := strings.Index(rest, "\n---\n")
|
||||||
|
if end < 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(rest[:end], "\n") {
|
||||||
|
// An indented line is a continuation (a YAML block-list item, a
|
||||||
|
// wrapped value) never a top-level key, regardless of whether it
|
||||||
|
// happens to contain a colon — " - 'Chapter 3: The Reckoning'"
|
||||||
|
// is not a key named "- 'Chapter 3". Checked before Cut so a
|
||||||
|
// colon inside one never reaches the key/value path at all.
|
||||||
|
if line != "" && (line[0] == ' ' || line[0] == '\t') {
|
||||||
|
lines = append(lines, fmLine{raw: line})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val, found := strings.Cut(line, ":")
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if !found || key == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||||
|
lines = append(lines, fmLine{raw: line})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, fmLine{key: key, value: strings.TrimSpace(val)})
|
||||||
|
}
|
||||||
|
return lines, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// setLine updates key's value in place if lines already has it, or appends
|
||||||
|
// a new entry if it doesn't — the one behavior the original refreshOwned
|
||||||
|
// got wrong (PR #126 review, round 2: it only ever updated in place).
|
||||||
|
func setLine(lines []fmLine, key, value string) []fmLine {
|
||||||
|
for i := range lines {
|
||||||
|
if lines[i].key == key {
|
||||||
|
lines[i].value = value
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(lines, fmLine{key: key, value: value})
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderFrontmatterBlock(lines []fmLine) string {
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("---\n")
|
||||||
|
for _, l := range lines {
|
||||||
|
switch {
|
||||||
|
case l.key == "":
|
||||||
|
sb.WriteString(l.raw)
|
||||||
|
case l.value == "":
|
||||||
|
// A key with nothing after the colon (the YAML block-list shape:
|
||||||
|
// "aliases:" followed by indented "- item" lines on their own
|
||||||
|
// raw lines) must stay bare — "key: " with a trailing space
|
||||||
|
// is a different, if invisible, byte sequence, and this file's
|
||||||
|
// whole purpose is byte-stability across an unrelated merge.
|
||||||
|
sb.WriteString(l.key)
|
||||||
|
sb.WriteByte(':')
|
||||||
|
default:
|
||||||
|
sb.WriteString(l.key)
|
||||||
|
sb.WriteString(": ")
|
||||||
|
sb.WriteString(l.value)
|
||||||
|
}
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
sb.WriteString("---\n")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshOwned returns a's frontmatter with the generator-owned fields taken
|
||||||
|
// from b where b supplies them. Fields absent from b keep a's value, so a
|
||||||
|
// generator that stops emitting a field cannot blank it.
|
||||||
|
//
|
||||||
|
// A field b supplies that a doesn't have yet — every page ingested before
|
||||||
|
// this mechanism existed has no source_hash line at all — is appended, not
|
||||||
|
// dropped (round 1 of review: dropping it silently meant source_hash never
|
||||||
|
// populated for the pre-existing corpus). If a has no frontmatter fence at
|
||||||
|
// all, one is created to hold the new fields rather than splicing raw
|
||||||
|
// "key: value" text into the body (round 2: that corrupted the page and the
|
||||||
|
// fields would duplicate on every subsequent merge, since unfenced text
|
||||||
|
// isn't recognized as frontmatter by parseSections on the next pass).
|
||||||
|
func refreshOwned(a, b string) string {
|
||||||
|
bLines, _ := parseFrontmatterBlock(b)
|
||||||
|
bVals := make(map[string]string)
|
||||||
|
for _, l := range bLines {
|
||||||
|
if l.key != "" && generatorOwned[l.key] {
|
||||||
|
bVals[l.key] = l.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(bVals) == 0 {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
aLines, _ := parseFrontmatterBlock(a) // ok==false (no fence) -> aLines nil, same as "start fresh"
|
||||||
|
|
||||||
|
// Sorted for a deterministic, byte-stable result when more than one
|
||||||
|
// field is appended in the same call — map iteration order isn't.
|
||||||
|
keys := make([]string, 0, len(bVals))
|
||||||
|
for k := range bVals {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
aLines = setLine(aLines, k, bVals[k])
|
||||||
|
}
|
||||||
|
return renderFrontmatterBlock(aLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSections splits markdown into its frontmatter, its preamble (content
|
||||||
|
// before the first `## ` heading), and its `## `-headed sections.
|
||||||
|
//
|
||||||
|
// The preamble is returned separately rather than as a section with an
|
||||||
|
// empty heading — a section keyed by heading=="" would collide with a
|
||||||
|
// literal degenerate `## ` (marker, no title text; strings.TrimPrefix also
|
||||||
|
// yields ""), which is a distinct, addressable section a page can genuinely
|
||||||
|
// have, not the same thing as "no heading yet." Two entries sharing one key
|
||||||
|
// in Merge's idx map means one of them silently loses lookup visibility —
|
||||||
|
// found in review as real, reproducible data loss, not a hypothetical.
|
||||||
|
func parseSections(markdown string) (frontmatter, preamble string, sections []section) {
|
||||||
lines := strings.Split(markdown, "\n")
|
lines := strings.Split(markdown, "\n")
|
||||||
i := 0
|
i := 0
|
||||||
|
|
||||||
@@ -78,6 +276,22 @@ func parseSections(markdown string) (frontmatter string, sections []section) {
|
|||||||
frontmatter = fmt.Sprintf("---\n%s\n---\n", strings.Join(fmLines, "\n"))
|
frontmatter = fmt.Sprintf("---\n%s\n---\n", strings.Join(fmLines, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Everything before the first `## ` heading — an intro paragraph, or, if
|
||||||
|
// the page has no headings at all, the entire body — is ordinary content,
|
||||||
|
// not something this parser is entitled to drop (hyperguild#103 review:
|
||||||
|
// dropping it meant Merge, one caller up, would silently replace the
|
||||||
|
// whole on-disk body with the fresh extraction whenever a page didn't
|
||||||
|
// follow the ## convention — exactly the class of bug this file exists
|
||||||
|
// to prevent).
|
||||||
|
var preambleLines []string
|
||||||
|
for ; i < len(lines); i++ {
|
||||||
|
if strings.HasPrefix(lines[i], "## ") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
preambleLines = append(preambleLines, lines[i])
|
||||||
|
}
|
||||||
|
preamble = strings.Join(preambleLines, "\n")
|
||||||
|
|
||||||
var cur *section
|
var cur *section
|
||||||
for ; i < len(lines); i++ {
|
for ; i < len(lines); i++ {
|
||||||
line := lines[i]
|
line := lines[i]
|
||||||
@@ -96,24 +310,85 @@ func parseSections(markdown string) (frontmatter string, sections []section) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func rebuildContent(frontmatter string, sections []section) string {
|
// rebuildContent serialises sections back into a page.
|
||||||
|
//
|
||||||
|
// Each section body is normalised to exactly one trailing newline and no
|
||||||
|
// leading blank line. Without that, a section keeps whatever blank lines it was
|
||||||
|
// parsed with and this function adds its own, so every merge pass grows the page
|
||||||
|
// by a blank line — invisible in a diff viewer, fatal to byte-stability. Compile-on-ingest
|
||||||
|
// merges on every run, so "merging changes nothing when nothing changed" has to
|
||||||
|
// hold at the byte level, not just the content level.
|
||||||
|
func rebuildContent(frontmatter, preamble string, sections []section) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(frontmatter)
|
sb.WriteString(frontmatter)
|
||||||
|
if body := strings.Trim(preamble, "\n"); body != "" {
|
||||||
|
fmt.Fprintf(&sb, "\n%s\n", body)
|
||||||
|
}
|
||||||
for _, sec := range sections {
|
for _, sec := range sections {
|
||||||
fmt.Fprintf(&sb, "\n## %s\n\n%s", sec.heading, sec.content)
|
body := strings.Trim(sec.content, "\n")
|
||||||
|
fmt.Fprintf(&sb, "\n## %s\n\n%s\n", sec.heading, body)
|
||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// canonicalLine collapses a line to its comparison key: internal whitespace
|
||||||
|
// runs (a re-extraction's bullet spacing, tabs vs spaces, trailing spaces)
|
||||||
|
// must not make the same logical line look new. strings.Fields already
|
||||||
|
// trims and splits on any whitespace run, so joining it back with single
|
||||||
|
// spaces is exactly "same words, same order, whitespace-insensitive."
|
||||||
|
func canonicalLine(line string) string {
|
||||||
|
return strings.Join(strings.Fields(line), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendNew concatenates the lines of b that a does not already carry.
|
||||||
|
//
|
||||||
|
// Append sections used to concatenate wholesale, which is correct exactly once.
|
||||||
|
// Compile-on-ingest merges a freshly extracted page into the one already on
|
||||||
|
// disk, so an unchanged source re-ingested would grow "Open Questions" and
|
||||||
|
// "Updates" on every run until the page is unreadable — and the drift guard
|
||||||
|
// that would otherwise catch it (hashing the source body) does not exist yet,
|
||||||
|
// so idempotency has to hold here rather than upstream.
|
||||||
|
//
|
||||||
|
// Line-level rather than block-level: the common case is a re-ingest carrying
|
||||||
|
// one genuinely new line alongside several it already has. A block-level
|
||||||
|
// "skip if identical" would duplicate the old lines every time that happened.
|
||||||
|
//
|
||||||
|
// The cost is that a line repeated verbatim on purpose collapses to one. For
|
||||||
|
// these sections — questions, updates, evolving notes — a repeat is noise
|
||||||
|
// rather than signal, and the failure is conservative: the page stops growing
|
||||||
|
// instead of growing wrongly.
|
||||||
|
func appendNew(a, b string) string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, line := range strings.Split(a, "\n") {
|
||||||
|
if c := canonicalLine(line); c != "" {
|
||||||
|
seen[c] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var add []string
|
||||||
|
for _, line := range strings.Split(b, "\n") {
|
||||||
|
c := canonicalLine(line)
|
||||||
|
if c == "" || seen[c] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[c] = true
|
||||||
|
add = append(add, line)
|
||||||
|
}
|
||||||
|
if len(add) == 0 {
|
||||||
|
return a // nothing new — leave the section byte-identical
|
||||||
|
}
|
||||||
|
return strings.TrimRight(a, "\n") + "\n\n" + strings.Join(add, "\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
func mergeBullets(a, b string) string {
|
func mergeBullets(a, b string) string {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
var lines []string
|
var lines []string
|
||||||
for _, line := range strings.Split(a+b, "\n") {
|
for _, line := range strings.Split(a+b, "\n") {
|
||||||
trimmed := strings.TrimSpace(line)
|
c := canonicalLine(line)
|
||||||
if trimmed == "" || seen[trimmed] {
|
if c == "" || seen[c] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[trimmed] = true
|
seen[c] = true
|
||||||
lines = append(lines, line)
|
lines = append(lines, line)
|
||||||
}
|
}
|
||||||
return strings.Join(lines, "\n") + "\n"
|
return strings.Join(lines, "\n") + "\n"
|
||||||
|
|||||||
@@ -53,3 +53,344 @@ func TestMerge_KeepsFrontmatterFromA(t *testing.T) {
|
|||||||
assert.Contains(t, got.Content, "title: A")
|
assert.Contains(t, got.Content, "title: A")
|
||||||
assert.NotContains(t, got.Content, "title: B")
|
assert.NotContains(t, got.Content, "title: B")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- hyperguild#103 review: a page with content outside any `## ` section ---
|
||||||
|
// must never lose that content on merge. parseSections used to only capture
|
||||||
|
// text between `## ` headings — anything before the first heading (a common,
|
||||||
|
// ordinary shape for a hand-written note: an intro paragraph before the first
|
||||||
|
// section) or, worse, an entire page with no `## ` headings at all (a legacy
|
||||||
|
// note, a stub, any hand-edit that doesn't follow the section convention)
|
||||||
|
// vanished silently. Merge would then fill the page entirely from the fresh
|
||||||
|
// extraction — the opposite of "never silently rewrite what a human put
|
||||||
|
// there," the guarantee this whole file exists to provide.
|
||||||
|
|
||||||
|
func TestMerge_PreservesOnDiskContentWithNoSectionHeadings(t *testing.T) {
|
||||||
|
a := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\nJust some free text a human wrote. No section headings at all.\n"}
|
||||||
|
b := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\n## Definition\n\nFresh extraction's take.\n"}
|
||||||
|
|
||||||
|
got := Merge(a, b)
|
||||||
|
assert.Contains(t, got.Content, "Just some free text a human wrote",
|
||||||
|
"on-disk content with no ## headings must survive the merge, not be replaced wholesale by the fresh extraction")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_DegenerateEmptyHeadingDoesNotCollideWithPreamble(t *testing.T) {
|
||||||
|
// Second review pass on #103: representing the preamble as a section with
|
||||||
|
// heading=="" collides with a literal `## ` (marker, no title text) —
|
||||||
|
// strings.TrimPrefix("## ", "## ") is also "". `a` has only a preamble
|
||||||
|
// (no degenerate heading of its own); `b` introduces a genuinely new
|
||||||
|
// degenerate `## ` section. Because both key to "", Merge's idx thinks
|
||||||
|
// b's section "already exists" in a (it's really just a's unrelated
|
||||||
|
// preamble) and never adds it — b's section vanishes from the output
|
||||||
|
// entirely, not merged, not kept, not appended.
|
||||||
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\nAn intro paragraph.\n\n## Definition\n\nA thing.\n"}
|
||||||
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## \n\nA genuinely new degenerate section's body.\n\n## Definition\n\nA thing.\n"}
|
||||||
|
|
||||||
|
got := Merge(a, b)
|
||||||
|
assert.Contains(t, got.Content, "An intro paragraph.", "a's own preamble must survive")
|
||||||
|
assert.Contains(t, got.Content, "A genuinely new degenerate section's body.",
|
||||||
|
"b's degenerate section is new content, not a collision with a's unrelated preamble — it must not be silently dropped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_PreservesPreambleBeforeFirstHeading(t *testing.T) {
|
||||||
|
a := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\nAn intro paragraph before the first heading.\n\n## Definition\n\nA thing.\n"}
|
||||||
|
b := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\n## Definition\n\nA thing.\n"}
|
||||||
|
|
||||||
|
got := Merge(a, b)
|
||||||
|
assert.Contains(t, got.Content, "An intro paragraph before the first heading",
|
||||||
|
"text before the first ## heading is ordinary page content, not something the parser is entitled to drop")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hyperguild#103 review: dedup must be whitespace-insensitive -----------
|
||||||
|
// Page content is produced by an LLM per run, so byte-identical re-extraction
|
||||||
|
// of "the same" logical line is not guaranteed — different bullet spacing or
|
||||||
|
// trailing whitespace on an otherwise-identical line must still dedup, or
|
||||||
|
// idempotency only holds when the extractor happens to reproduce whitespace
|
||||||
|
// exactly, which is not a real guarantee.
|
||||||
|
|
||||||
|
func TestMerge_AppendSectionDedupsAcrossWhitespaceVariants(t *testing.T) {
|
||||||
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions\n\n- Does this converge?\n"}
|
||||||
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions\n\n- Does this converge? \n"}
|
||||||
|
|
||||||
|
got := Merge(a, b)
|
||||||
|
assert.Equal(t, 1, strings.Count(got.Content, "Does this converge?"),
|
||||||
|
"same logical line with different whitespace must dedup, not duplicate")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_BulletSectionDedupsAcrossWhitespaceVariants(t *testing.T) {
|
||||||
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Related Concepts\n\n- [[bar|Bar]]\n"}
|
||||||
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Related Concepts\n\n- [[bar|Bar]]\n"}
|
||||||
|
|
||||||
|
got := Merge(a, b)
|
||||||
|
assert.Equal(t, 1, strings.Count(got.Content, "[[bar|Bar]]"),
|
||||||
|
"same logical bullet with different whitespace must dedup, not duplicate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- brain#21/B1: merging must be safe to run again -------------------------
|
||||||
|
|
||||||
|
func TestMerge_AppendSectionDoesNotDuplicateContentAlreadyPresent(t *testing.T) {
|
||||||
|
// Compile-on-ingest merges a freshly extracted page into the one already
|
||||||
|
// on disk. Bullet sections union, so they are naturally idempotent — but
|
||||||
|
// append sections concatenate, so re-ingesting an unchanged source would
|
||||||
|
// grow "Open Questions" on every run until the page is unreadable.
|
||||||
|
page := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: X
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Does this converge?
|
||||||
|
`}
|
||||||
|
|
||||||
|
once := Merge(page, page)
|
||||||
|
twice := Merge(once, page)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, strings.Count(once.Content, "Does this converge?"),
|
||||||
|
"merging a page with itself must not duplicate an append section")
|
||||||
|
assert.Equal(t, once.Content, twice.Content,
|
||||||
|
"merge must be idempotent — a third pass may not keep growing the page")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_AppendSectionStillAccumulatesGenuinelyNewContent(t *testing.T) {
|
||||||
|
// The dedup must not turn append sections into keep-a-only sections:
|
||||||
|
// new material is exactly what compile-on-ingest exists to add.
|
||||||
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: X
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Does this converge?
|
||||||
|
`}
|
||||||
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: X
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- What happens under load?
|
||||||
|
`}
|
||||||
|
|
||||||
|
got := Merge(existing, fresh).Content
|
||||||
|
assert.Contains(t, got, "Does this converge?", "existing question must survive")
|
||||||
|
assert.Contains(t, got, "What happens under load?", "new question must be added")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_GeneratorOwnedFrontmatterFieldsAreRefreshed(t *testing.T) {
|
||||||
|
// #103 made on-disk frontmatter win wholesale, which is what protects
|
||||||
|
// human-set fields from a regenerating writer. The cost was that fields the
|
||||||
|
// generator legitimately owns — when it last ran, what it last saw — froze
|
||||||
|
// at their first-ingest values. An explicit owned-set is the boundary:
|
||||||
|
// named fields the generator may refresh, everything else human-owned.
|
||||||
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: 'X'
|
||||||
|
last_updated: 2026-01-01
|
||||||
|
source_hash: aaaa
|
||||||
|
half_life: durable
|
||||||
|
claim_provenance: asserted
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Original.
|
||||||
|
`}
|
||||||
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: 'Renamed by the model'
|
||||||
|
last_updated: 2026-08-22
|
||||||
|
source_hash: bbbb
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Rewritten by the model.
|
||||||
|
`}
|
||||||
|
|
||||||
|
got := Merge(existing, fresh).Content
|
||||||
|
|
||||||
|
assert.Contains(t, got, "last_updated: 2026-08-22", "generator owns last_updated")
|
||||||
|
assert.Contains(t, got, "source_hash: bbbb", "generator owns source_hash")
|
||||||
|
assert.Contains(t, got, "title: 'X'", "a human-set title must survive regeneration")
|
||||||
|
assert.Contains(t, got, "half_life: durable", "the claim envelope must survive regeneration")
|
||||||
|
assert.Contains(t, got, "claim_provenance: asserted")
|
||||||
|
assert.Contains(t, got, "Original.", "an existing section still wins over the model's rewrite")
|
||||||
|
assert.NotContains(t, got, "Renamed by the model")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_GeneratorOwnedFieldsAreAppendedWhenAPageNeverHadThemBefore(t *testing.T) {
|
||||||
|
// Found in review of PR #126: refreshOwned only replaced a generator-owned
|
||||||
|
// line that already existed in a's frontmatter — it never appended one a
|
||||||
|
// didn't have. Every page ingested before this feature shipped has no
|
||||||
|
// source_hash line at all, so on its first re-ingest after deploy, the
|
||||||
|
// fresh hash was silently dropped and the skip-on-unchanged feature this
|
||||||
|
// PR exists to build would never engage for the entire pre-existing
|
||||||
|
// corpus, permanently, with nothing telling anyone.
|
||||||
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: 'X'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Original.
|
||||||
|
`}
|
||||||
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: 'Renamed by the model'
|
||||||
|
last_updated: 2026-08-22
|
||||||
|
source_hash: bbbb
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Rewritten by the model.
|
||||||
|
`}
|
||||||
|
|
||||||
|
got := Merge(existing, fresh).Content
|
||||||
|
|
||||||
|
// Exact frontmatter block, not Contains: the second review round found
|
||||||
|
// that a substring check doesn't prove the fields landed inside a valid
|
||||||
|
// fence, in the right order, or that the fence itself wasn't corrupted —
|
||||||
|
// two independent mutations (closeIdx targeting the wrong fence, reversed
|
||||||
|
// sort) both survived a Contains-only version of this test.
|
||||||
|
want := "---\ntitle: 'X'\nlast_updated: 2026-08-22\nsource_hash: bbbb\n---\n\n## Summary\n\nOriginal.\n"
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_GeneratorOwnedFieldsCreateAFrontmatterBlockWhenThePageHadNoneAtAll(t *testing.T) {
|
||||||
|
// Second review round on PR #126: when a has no frontmatter fence at all,
|
||||||
|
// the append fallback spliced raw, unfenced "key: value" lines straight
|
||||||
|
// into the body -- worse than the original bug, because the next
|
||||||
|
// parseSections call wouldn't recognize them as frontmatter (they don't
|
||||||
|
// start with `---`), so they'd be absorbed as ordinary preamble text and
|
||||||
|
// re-duplicate on every subsequent merge instead of updating in place.
|
||||||
|
existing := Page{Path: "wiki/sources/x.md", Content: "## Summary\n\nOriginal.\n"}
|
||||||
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
last_updated: 2026-08-22
|
||||||
|
source_hash: bbbb
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Rewritten by the model.
|
||||||
|
`}
|
||||||
|
|
||||||
|
got := Merge(existing, fresh).Content
|
||||||
|
|
||||||
|
want := "---\nlast_updated: 2026-08-22\nsource_hash: bbbb\n---\n\n## Summary\n\nOriginal.\n"
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_GeneratorOwnedFieldsMixedReplaceAndAppend(t *testing.T) {
|
||||||
|
// a already has last_updated (replace in place) but not source_hash
|
||||||
|
// (append) -- the two code paths in the same call, together.
|
||||||
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: 'X'
|
||||||
|
last_updated: 2026-01-01
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Original.
|
||||||
|
`}
|
||||||
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
last_updated: 2026-08-22
|
||||||
|
source_hash: bbbb
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Rewritten by the model.
|
||||||
|
`}
|
||||||
|
|
||||||
|
got := Merge(existing, fresh).Content
|
||||||
|
|
||||||
|
want := "---\ntitle: 'X'\nlast_updated: 2026-08-22\nsource_hash: bbbb\n---\n\n## Summary\n\nOriginal.\n"
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hyperguild#106: heading casing drift must not fork a section ----------
|
||||||
|
// Page-aware extraction asks the model to revise the page it's given, but
|
||||||
|
// nothing guarantees the model reproduces a heading's exact on-disk casing
|
||||||
|
// ("Key Claims" vs "Key claims"). Without normalizing the match key, that
|
||||||
|
// drift silently creates a near-duplicate section instead of updating the
|
||||||
|
// original — the failure mode #106 exists to close.
|
||||||
|
|
||||||
|
func TestMerge_HeadingCaseDriftMatchesSameSection(t *testing.T) {
|
||||||
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Key Claims\n\n- Appetite beats estimation.\n"}
|
||||||
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Key claims\n\n- New pattern holds under load.\n"}
|
||||||
|
|
||||||
|
got := Merge(a, b).Content
|
||||||
|
|
||||||
|
assert.Equal(t, 1, strings.Count(got, "## Key"),
|
||||||
|
"casing drift in the heading must not create a second section")
|
||||||
|
assert.Contains(t, got, "Appetite beats estimation.")
|
||||||
|
assert.Contains(t, got, "New pattern holds under load.",
|
||||||
|
"the differently-cased section's content must still be unioned in")
|
||||||
|
assert.Contains(t, got, "## Key Claims",
|
||||||
|
"the on-disk heading's original casing must be preserved in the output")
|
||||||
|
assert.NotContains(t, got, "## Key claims",
|
||||||
|
"the fresh extraction's heading casing must not overwrite the on-disk display text")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_HeadingWhitespaceDriftMatchesSameSection(t *testing.T) {
|
||||||
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions \n\n- Does appetite scale?\n"}
|
||||||
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions\n\n- What about remote teams?\n"}
|
||||||
|
|
||||||
|
got := Merge(a, b).Content
|
||||||
|
|
||||||
|
assert.Equal(t, 1, strings.Count(got, "## Open Questions"),
|
||||||
|
"surrounding whitespace drift in the heading must not create a second section")
|
||||||
|
assert.Contains(t, got, "Does appetite scale?")
|
||||||
|
assert.Contains(t, got, "What about remote teams?")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_PreExistingCaseDuplicateHeadingsRouteToTheFirstOccurrence(t *testing.T) {
|
||||||
|
// Found in review of PR #130: normalizeHeading's idx map is built by
|
||||||
|
// iterating secsA with idx[normalizeHeading(s.heading)] = i -- last
|
||||||
|
// write wins. Before heading normalization existed, two on-disk
|
||||||
|
// sections differing only by case (real, pre-existing corpus drift --
|
||||||
|
// the exact pattern #106 itself cites) never collided, since idx keyed
|
||||||
|
// on the exact string. After normalization they collide, and every
|
||||||
|
// fresh update silently, deterministically routes to whichever
|
||||||
|
// duplicate happens to appear LAST in the file -- the first one is
|
||||||
|
// frozen forever, never receiving updates again, with nothing to say
|
||||||
|
// so. A Contains-only check doesn't catch this: nothing is literally
|
||||||
|
// deleted, a bullet just lands under the wrong of two still-present
|
||||||
|
// headings. Assert it lands under the FIRST occurrence instead —
|
||||||
|
// deterministic and matches "earliest is canonical," same reasoning
|
||||||
|
// as every other on-disk-wins default in this file.
|
||||||
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Key Claims\n\n- claim one\n\n## KEY CLAIMS\n\n- claim two\n"}
|
||||||
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Key Claims\n\n- claim one\n- fresh claim\n"}
|
||||||
|
|
||||||
|
got := Merge(a, b).Content
|
||||||
|
|
||||||
|
assert.Contains(t, got, "## Key Claims\n\n- claim one\n- fresh claim\n",
|
||||||
|
"the fresh bullet must land in the first-occurrence section, not silently route to the last duplicate")
|
||||||
|
assert.Contains(t, got, "## KEY CLAIMS\n\n- claim two\n",
|
||||||
|
"the second duplicate is untouched by this update, not merged into or overwritten")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshOwned_CommentLinesSurviveUnmangled(t *testing.T) {
|
||||||
|
// Third review round on PR #126: parseFrontmatterBlock dropped the
|
||||||
|
// sibling api package's comment guard (strings.HasPrefix(trimmed, "#")).
|
||||||
|
// Without it, a comment line containing a colon gets cut at the colon
|
||||||
|
// and re-rendered with collapsed whitespace instead of surviving
|
||||||
|
// verbatim.
|
||||||
|
a := "---\n#see also: https://x/y\ntitle: 'X'\n---\n"
|
||||||
|
b := "---\nsource_hash: bbbb\n---\n"
|
||||||
|
|
||||||
|
got := refreshOwned(a, b)
|
||||||
|
assert.Equal(t, "---\n#see also: https://x/y\ntitle: 'X'\nsource_hash: bbbb\n---\n", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshOwned_IndentedListItemsWithColonsAreNotMisparsedAsKeys(t *testing.T) {
|
||||||
|
// Same round: an indented YAML block-list item that happens to contain a
|
||||||
|
// colon (e.g. a title with a subtitle) is a continuation line, never a
|
||||||
|
// top-level key, regardless of what strings.Cut finds in it. Misparsing
|
||||||
|
// it as a key/value pair and re-rendering strips its indentation,
|
||||||
|
// de-indenting it out of the list it belongs to -- and refreshOwned
|
||||||
|
// re-renders this block on essentially every non-skipped ingest, so this
|
||||||
|
// is the normal path, not an edge case.
|
||||||
|
a := "---\naliases:\n - 'Chapter 3: The Reckoning'\ntitle: 'X'\n---\n"
|
||||||
|
b := "---\nsource_hash: bbbb\n---\n"
|
||||||
|
|
||||||
|
got := refreshOwned(a, b)
|
||||||
|
assert.Equal(t, "---\naliases:\n - 'Chapter 3: The Reckoning'\ntitle: 'X'\nsource_hash: bbbb\n---\n", got)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ type Config struct {
|
|||||||
LiteLLMBaseURL string // LITELLM_BASE_URL, default http://iguana:4000
|
LiteLLMBaseURL string // LITELLM_BASE_URL, default http://iguana:4000
|
||||||
LiteLLMAPIKey string // LITELLM_API_KEY
|
LiteLLMAPIKey string // LITELLM_API_KEY
|
||||||
ConfigDir string // SUPERVISOR_CONFIG_DIR, default ./config/supervisor
|
ConfigDir string // SUPERVISOR_CONFIG_DIR, default ./config/supervisor
|
||||||
ModelsFile string // SUPERVISOR_MODELS_FILE, default <ConfigDir>/../models.yaml
|
|
||||||
IngestBaseURL string // INGEST_BASE_URL, default http://localhost:3300
|
IngestBaseURL string // INGEST_BASE_URL, default http://localhost:3300
|
||||||
IngestSvcURL string // INGEST_SVC_URL — base URL for brain_ingest (/ingest, /ingest-path)
|
IngestSvcURL string // INGEST_SVC_URL — base URL for brain_ingest (/ingest, /ingest-path)
|
||||||
KBRetrievalURL string // KB_RETRIEVAL_URL — base URL for brain_search
|
KBRetrievalURL string // KB_RETRIEVAL_URL — base URL for brain_search
|
||||||
@@ -23,7 +22,6 @@ func Load() (Config, error) {
|
|||||||
LiteLLMAPIKey: os.Getenv("LITELLM_API_KEY"),
|
LiteLLMAPIKey: os.Getenv("LITELLM_API_KEY"),
|
||||||
ConfigDir: envOr("SUPERVISOR_CONFIG_DIR", "./config/supervisor"),
|
ConfigDir: envOr("SUPERVISOR_CONFIG_DIR", "./config/supervisor"),
|
||||||
}
|
}
|
||||||
cfg.ModelsFile = envOr("SUPERVISOR_MODELS_FILE", cfg.ConfigDir+"/../models.yaml")
|
|
||||||
cfg.IngestBaseURL = envOr("INGEST_BASE_URL", "http://localhost:3300")
|
cfg.IngestBaseURL = envOr("INGEST_BASE_URL", "http://localhost:3300")
|
||||||
cfg.IngestSvcURL = envOr("INGEST_SVC_URL", "")
|
cfg.IngestSvcURL = envOr("INGEST_SVC_URL", "")
|
||||||
cfg.KBRetrievalURL = envOr("KB_RETRIEVAL_URL", "")
|
cfg.KBRetrievalURL = envOr("KB_RETRIEVAL_URL", "")
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
type skillChain struct {
|
|
||||||
Chain []string `yaml:"chain"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type modelsFile struct {
|
|
||||||
DefaultChain []string `yaml:"default_chain"`
|
|
||||||
Skills map[string]skillChain `yaml:"skills"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Models struct {
|
|
||||||
data modelsFile
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadModels(path string) (Models, error) {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return Models{}, fmt.Errorf("load models: %w", err)
|
|
||||||
}
|
|
||||||
var f modelsFile
|
|
||||||
if err := yaml.Unmarshal(raw, &f); err != nil {
|
|
||||||
return Models{}, fmt.Errorf("parse models: %w", err)
|
|
||||||
}
|
|
||||||
return Models{data: f}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModelFor returns the primary model to use for a skill.
|
|
||||||
// If override is non-empty, it is returned directly.
|
|
||||||
// Falls back to default_chain[0] when the skill has no explicit entry.
|
|
||||||
func (m Models) ModelFor(skill, override string) string {
|
|
||||||
if override != "" {
|
|
||||||
return override
|
|
||||||
}
|
|
||||||
if sc, ok := m.data.Skills[skill]; ok && len(sc.Chain) > 0 {
|
|
||||||
return sc.Chain[0]
|
|
||||||
}
|
|
||||||
if len(m.data.DefaultChain) > 0 {
|
|
||||||
return m.data.DefaultChain[0]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package config_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.d-ma.be/mathias/hyperguild/internal/config"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
const testYAML = `
|
|
||||||
default_chain:
|
|
||||||
- ollama/qwen3-coder-30b-tuned
|
|
||||||
- claude-sonnet-4-6
|
|
||||||
|
|
||||||
skills:
|
|
||||||
review:
|
|
||||||
chain:
|
|
||||||
- ollama/devstral-tuned
|
|
||||||
- ollama/gemma4
|
|
||||||
- claude-sonnet-4-6
|
|
||||||
spec:
|
|
||||||
chain:
|
|
||||||
- ollama/phi4
|
|
||||||
- claude-opus-4-6
|
|
||||||
`
|
|
||||||
|
|
||||||
func writeModels(t *testing.T, content string) string {
|
|
||||||
t.Helper()
|
|
||||||
f := filepath.Join(t.TempDir(), "models.yaml")
|
|
||||||
require.NoError(t, os.WriteFile(f, []byte(content), 0o644))
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestModelsModelForSkillWithEntry(t *testing.T) {
|
|
||||||
m, err := config.LoadModels(writeModels(t, testYAML))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "ollama/devstral-tuned", m.ModelFor("review", ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestModelsModelForDefaultFallback(t *testing.T) {
|
|
||||||
m, err := config.LoadModels(writeModels(t, testYAML))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "ollama/qwen3-coder-30b-tuned", m.ModelFor("trainer", ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestModelsModelForCallerOverride(t *testing.T) {
|
|
||||||
m, err := config.LoadModels(writeModels(t, testYAML))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "claude-opus-4-6", m.ModelFor("review", "claude-opus-4-6"))
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Boot the routing binary and exercise its four tools against live deps.
|
|
||||||
# Skipped when LITELLM_BASE_URL or BRAIN_URL is unreachable.
|
|
||||||
|
|
||||||
LITELLM_BASE_URL="${LITELLM_BASE_URL:-https://llm-api.d-ma.be}"
|
|
||||||
BRAIN_URL="${BRAIN_URL:-http://koala:30330}"
|
|
||||||
|
|
||||||
if ! curl -sS --max-time 2 "${LITELLM_BASE_URL}/v1/models" >/dev/null 2>&1; then
|
|
||||||
echo "SKIP: LITELLM at ${LITELLM_BASE_URL} unreachable"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if ! curl -sS --max-time 2 "${BRAIN_URL}/query" -X POST -d '{"query":"x","k":1}' -H 'Content-Type: application/json' >/dev/null 2>&1; then
|
|
||||||
echo "SKIP: BRAIN at ${BRAIN_URL} unreachable"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
PORT=33310
|
|
||||||
BIN=$(mktemp)
|
|
||||||
trap 'rm -f $BIN; pkill -P $$ -f "$BIN" 2>/dev/null || true' EXIT
|
|
||||||
|
|
||||||
go build -o "$BIN" ./cmd/routing
|
|
||||||
|
|
||||||
LITELLM_BASE_URL="$LITELLM_BASE_URL" BRAIN_URL="$BRAIN_URL" \
|
|
||||||
ROUTING_PORT="$PORT" SUPERVISOR_CONFIG_DIR="$(pwd)/config/supervisor" \
|
|
||||||
"$BIN" &
|
|
||||||
|
|
||||||
# Wait for the binary to bind.
|
|
||||||
for _ in $(seq 1 50); do
|
|
||||||
curl -sS "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1 && break
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
|
|
||||||
call_tool() {
|
|
||||||
local tool="$1"
|
|
||||||
local args="$2"
|
|
||||||
curl -sS -X POST "http://127.0.0.1:${PORT}/mcp" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"${tool}\",\"arguments\":${args}}}" \
|
|
||||||
| jq -e '.result // .error' > /dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "calling tools/list..."
|
|
||||||
curl -sS -X POST "http://127.0.0.1:${PORT}/mcp" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
|
|
||||||
| jq -r '.result.tools | map(.name) | sort | .[]'
|
|
||||||
|
|
||||||
echo "calling each tool..."
|
|
||||||
call_tool review '{"project_root":"/tmp","files":["README.md"],"session_id":"smoke-1"}'
|
|
||||||
call_tool debug '{"project_root":"/tmp","error":"smoke test","session_id":"smoke-1"}'
|
|
||||||
call_tool retrospective '{"session_id":"smoke-1"}'
|
|
||||||
call_tool trainer '{"session_id":"smoke-1"}'
|
|
||||||
|
|
||||||
echo "checking brain has _routing entries..."
|
|
||||||
sleep 2
|
|
||||||
COUNT=$(curl -sS "${BRAIN_URL}/pass-rate?skill=_routing&window=1h" | jq -r '.total // 0')
|
|
||||||
if [ "${COUNT}" -lt 4 ]; then
|
|
||||||
echo "FAIL: expected >=4 _routing entries in last 1h, got ${COUNT}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "PASS: smoke:routing"
|
|
||||||
@@ -49,12 +49,13 @@ Persist the session via a **single `capture` call** (the `brain:capture` MCP too
|
|||||||
**Assemble one payload:**
|
**Assemble one payload:**
|
||||||
|
|
||||||
- **`insights[]`** — the generalizable learnings from Phase 1 (decisions/failures worth re-reading). Each: `{text, wing, hall}`; add `supersede_slug` to revise a prior note in place instead of creating a duplicate. `hall` ∈ facts/decisions/failures/hypotheses/sources.
|
- **`insights[]`** — the generalizable learnings from Phase 1 (decisions/failures worth re-reading). Each: `{text, wing, hall}`; add `supersede_slug` to revise a prior note in place instead of creating a duplicate. `hall` ∈ facts/decisions/failures/hypotheses/sources.
|
||||||
|
Optional claim envelope, per insight: `half_life` ∈ volatile/seasonal/durable, `applies` ∈ local/homelab/universal, `claim_provenance` ∈ asserted/inferred — required together whenever either `half_life` or `applies` is set (an envelope with no provenance is rejected). A close-session insight is usually exactly the durable, generalizable, human-decided kind this describes: `half_life: durable, applies: universal, claim_provenance: asserted` is the common case, not a rare opt-in.
|
||||||
- **`tickets[]`** — the issue actions from Phase 3: `{repo, action, ...}` where action ∈ create/close/comment. Owner is always `mathias` (server-forced).
|
- **`tickets[]`** — the issue actions from Phase 3: `{repo, action, ...}` where action ∈ create/close/comment. Owner is always `mathias` (server-forced).
|
||||||
- **`context`** — `{harness: "claudeai-chat", session_ref: <chatid8-or-slug>, fidelity: "live-capture", actor: "mathias", classification: <see gate below>}`.
|
- **`context`** — `{harness: "claudeai-chat", session_ref: <chatid8-or-slug>, fidelity: "live-capture", actor: "mathias", classification: <see gate below>}`.
|
||||||
|
|
||||||
**THE CLASSIFICATION GATE (read before calling — this is where capture refuses).**
|
**THE CLASSIFICATION GATE (read before calling — this is where capture refuses).**
|
||||||
Capture computes an **effective classification = the strictest across EVERY target it touches** (each insight's `wing`, each ticket's `repo`), then refuses if that effective level is `confidential` and the origin is us-nexus (claude.ai is us-nexus). Levels come from `classification.yaml` at the brain root (source of truth, #67), with the code defaults as the floor: `hyperguild`/`homelab` → internal; `client-*` → confidential; **anything untagged → confidential (fail-safe)**.
|
Capture computes an **effective classification = the strictest across EVERY target it touches** (each insight's `wing`, each ticket's `repo`), then refuses if that effective level is `confidential` and the origin is us-nexus (claude.ai is us-nexus). Levels come from `classification.yaml` at the brain root (source of truth, #67), with the code defaults as the floor: `hyperguild`/`homelab` → internal; `client-*` → confidential; **anything untagged → confidential (fail-safe)**.
|
||||||
- **Tagged `internal` today** (safe through claude.ai): wings `hyperguild`, `homelab`; repos `brain`, `ai-sessions`, `infra`, `hyperguild`, `homelab`, `tapir`, `agentsquad`, `jepa-fx-risk`, `swedsl`. Treat `classification.yaml` as authoritative — this list is a hint, not gospel.
|
- **The rule, not a list**: untagged → confidential (fail-safe). `classification.yaml` at the brain root is the sole authority on what's tagged `internal` today — it grew from 10 to ~39 repos in one sweep (hyperguild#91) and will keep moving, so read the file rather than trust an enumeration frozen at whatever moment this skill was last edited.
|
||||||
- Declare `context.classification: "internal"` for normal homelab work.
|
- Declare `context.classification: "internal"` for normal homelab work.
|
||||||
- Insight `wing`s and ticket `repo`s are classification INPUTS, not free-form metadata — every target must resolve `internal` or the whole capture escalates to `confidential` and the gate refuses via claude.ai. Listing the central homelab repos (incl. `brain`/`ai-sessions`) is now fine; they're tagged.
|
- Insight `wing`s and ticket `repo`s are classification INPUTS, not free-form metadata — every target must resolve `internal` or the whole capture escalates to `confidential` and the gate refuses via claude.ai. Listing the central homelab repos (incl. `brain`/`ai-sessions`) is now fine; they're tagged.
|
||||||
- If a session genuinely touched **`client-*` or otherwise-untagged** material, it cannot be captured through claude.ai — note that in the verdict rather than trying to force it.
|
- If a session genuinely touched **`client-*` or otherwise-untagged** material, it cannot be captured through claude.ai — note that in the verdict rather than trying to force it.
|
||||||
|
|||||||
Reference in New Issue
Block a user