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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ coverage/
docs/*
!docs/dashboard-architecture.md
.claude/
.cursor/*
# .cursor/** rather than .cursor/* so anything else added anywhere under
# .cursor stays ignored. Only the shipped memory rule is re-included:
# scripts/connect-ai-clients.* fetch it from the repo's raw URL for users.
.cursor/**
!.cursor/rules/
!.cursor/rules/second-brain-memory.mdc
.dev.vars
Expand Down
82 changes: 74 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,54 @@ All notable changes to Second Brain are documented here. Version numbers match `

**Team Edition (single shared team per brain)**

- Personal and Shared (`company`) memory layers on one Worker — no separate team deployment.
- Member management, invite tokens, capture defaults, sharing, author lock, and admin audit trail.
- Dashboard team panel: members, roster, activity, rename team, share/unshare from the UI.
- MCP and REST tools accept optional `workspace`: `personal` | `company` on reads and writes.
Second Brain can now be a team's memory without stopping being yours. Every person gets a **Personal** workspace that nobody else can read, plus a **Shared** layer visible to the team, on one Worker with no separate team deployment.

- Personal and Shared (`company`) memory layers; personal memories are private by default and only enter the Shared layer when someone deliberately shares them.
- Member management with invite tokens, last-seen timestamps, and per-member capture visibility set as an admin policy with per-member override.
- An owner can declare a brain a team before anyone is invited; real membership overrules any stored team mode.
- Sharing moves one canonical memory rather than making a copy. Its author remains visible, and only the author or an admin can edit, delete, or un-share it.
- Author lock prevents a team member from editing or deleting another member's memories.
- Team directory: a member sees the team, its people, and their own capture default.
- A member can set their own capture default; the composer's layer control is explained to a new team member with a dismissible onboarding coach mark.
- Capture-default controls are pinned to their own keys and shared through one select helper.
- Dashboard team panel: members, roster, activity, rename team, share and un-share from the UI. The roster no longer holds the memories list hostage.
- Dashboard: memories multi-select with bulk share and a shared-badge/payer layer chip; admin activity section with CSV export.
- Team-scoped insights with an optional company weekly insight pass (off by default) and a per-team toggle; the insight novelty floor is keyed to the workspace.
- Integration lines and provenance are gated on team mode, and a member is told who connected an integration and where it lands.
- MCP and REST tools accept optional `workspace` (`personal` | `company`) on reads and writes.
- MCP `list_teams` and `GET /team/workspaces` list the teams a caller belongs to (v3.0.0 returns one team per brain).

**Security and tenancy**

- Identity-scoped reads and writes; digest and config admin boundaries; MCP audit events.
- Multi-team write ambiguity resolved: optional `team` workspace id on capture, share, recall, list, graph, and digest.
- Identity is resolved at the API edge, and every read and write is scoped to the caller's workspace and membership.
- Personal memories are invisible to the team; shared memories are visible to all members; scoped recall searches only what the caller can see.
- Graph walks cannot traverse a memory the reader cannot open.
- Admin reads are scoped so an admin sees only the team data they are authorized to manage.
- Vectorize vectors are stamped with `workspace_id` and queries filter by the readable set; vectors are re-stamped on share and un-share so they stay in the correct layer.
- The app asks the brain who is holding the token instead of guessing from a Cloudflare login.
- OAuth replaces query-string tokens for MCP (v3); query-string authentication is refused.
- Imported entries and edges carry the importer's workspace.
- Explicit links stay within one layer and are filed where their author can see them.
- Multi-team write ambiguity is resolved with an optional `team` workspace id on capture, share, recall, list, graph, and digest.

**Admin and compliance**

- `GET /team/activity`: a single paged feed merging the `admin_events` and `entry_events` audit trails.
- Every team administration action (add/remove member, share/unshare, rename, capture defaults) is recorded in `admin_events` with timestamp and actor.
- Integration connects and disconnects are recorded in `admin_events`.
- Team configuration select helpers reload on change; team-insights toggle is routed through the shared config select.
- Admin activity body guard and bulk bar team gate enforced.
- Health endpoint surfaces Vectorize degradation status.
- Member last-seen timestamps visible to admins.

**Scope and isolation hardening**

- Scope checker rebuilt with an allowlist of safe clause shapes; evasive patterns (negation, wrapped SQL, dotted table names) are now rejected.
- Outer-join detection prevents scope clauses that reduce to a nulled column.
- Graph subrequest bound re-pinned to match current scope-checker output.
- Tag summaries scoped at the row level, not the title.
- Activity feed memory arm scoped to the row.
- Negation and wrapped SQL are no longer treated as safe by the scope checker.

**Recall in any language (#326)**

Expand All @@ -35,19 +73,47 @@ All notable changes to Second Brain are documented here. Version numbers match `
- Hooks now exit 1 with one stderr line on any failure; Claude Code hides stderr from exit-0 hooks.
- `install.sh` reconciles instead of appending, refuses a malformed settings.json, sets the SessionEnd `timeout` the 1.5 s hook budget requires, and keeps credentials in `~/.config/second-brain/config.json` rather than the hook command line.
- New: `install.sh --check` and `--uninstall`.
- Session capture redacts credentials from the body before sending it — your own token, `Bearer` values, `sk-`/`ghp_`/`github_pat_`/`xoxb-`/`AKIA`/`AIza` key shapes, PEM private keys and `TOKEN=`-style assignments while leaving UUIDs, commit SHAs, paths and ordinary prose intact.
- Session capture redacts credentials from the body before sending it. Your own token, `Bearer` values, `sk-`/`ghp_`/`github_pat_`/`xoxb-`/`AKIA`/`AIza` key shapes, PEM private keys and `TOKEN=`-style assignments are removed, while UUIDs, commit SHAs, paths and ordinary prose are left intact.
- SessionStart caches the block it printed and re-emits it on compaction, so compaction costs no recall at all; it falls back to a live recall when there is no cache or it is over 24 h old.
- New: `install.ps1`, a PowerShell installer for Windows machines where Claude Code runs hooks under PowerShell rather than Git Bash.
- Worker: a Claude Code transcript is never merged into, never replaces, and never deprecates a memory written by any other source; it is stored as a duplicate-candidate or a draft instead. Transcripts are excluded from insight synthesis.
- Worker: capturing a near-duplicate of a protected memory (importance ≥ 4 or canonical) now stores the newcomer as a duplicate-candidate. It used to report success with an id that did not exist.

**Knowledge graph quality**

- Capture-time inference now draws typed edges (`follows`, `caused_by`, `decided`) in addition to generic `relates_to`, improving traversal and recall relevance.
- Junk-link suppression prevents near-duplicate confusion from creating misleading graph connections.
- Update and merge paths now re-infer edges so the graph stays current when content changes.
- The nightly backfill can emit `follows` when the entry's kind is already classified.
- `GET /stats/graph` endpoint for graph health observability (admin only).
- MCP `link` tool description now explains each edge type and direction.
- The dangling sweep runs weekly instead of nightly (~7× cheaper amortized).

**Desktop and installer**

- Installer offers team-mode onboarding: existing-brain users choose team mode once during connect; the choice is a true one-time decision.
- Installer provisions the company insight schedule from the manifest.
- `install.ps1` (PowerShell) mirrors `install.sh` for Windows environments.
- Desktop typecheck and Rust tests now run on every PR.
- Routine "update your brain" keeps a migrated brain on its migrated search index instead of re-creating it.
- Cost-picker notices are no longer contradictory for migration-level changes.

**Bug fixes**

- Admin lockout guards are atomic; email uniqueness and tombstone guards hardened.
- Integration purge no longer deletes a colleague's memories.
- Re-embed repairs no longer detach vectors from their workspace.
- One member's tags no longer reach another.
- Toast text is readable in light mode; team panel contrast, overlap, truncation, and tap targets fixed.
- Bulk selection no longer outlives the list it is over.

**Upgrade**

- Existing v2 memories become the owner's personal workspace. Nothing is exposed to the team automatically.

### Internal plumbing (not user-facing in v3.0.0)

The codebase supports multiple company workspaces per member (many-to-many memberships, `team` query/body parameter, scoped recall). **v3.0.0 does not expose multi-team in the dashboard, admin UI, or provisioning flows** each brain still has one shared team. Backlog: [GitHub issues labeled `multi-team`](https://github.com/rahilp/second-brain-cloudflare/issues?q=label%3Amulti-team).
The codebase supports multiple company workspaces per member (many-to-many memberships, `team` query/body parameter, scoped recall). **v3.0.0 does not expose multi-team in the dashboard, admin UI, or provisioning flows**; each brain still has one shared team. Backlog: [GitHub issues labeled `multi-team`](https://github.com/rahilp/second-brain-cloudflare/issues?q=label%3Amulti-team).

AI clients: on v3.0.0 team brains, `list_teams` returns one entry; omit `team` unless more than one team is returned.

Expand Down
57 changes: 46 additions & 11 deletions src/capture/classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,23 @@ export async function classifyEntry(content: string, env: Env, config: Readonly<
return parseClassification(text);
}

/** The write half of classification, shared by both schedulers below. */
async function applyClassification(
entryId: string,
env: Env,
{ importance, canonical, kind }: { importance: number; canonical: boolean; kind: MemoryKind | null },
): Promise<void> {
await env.DB.prepare(`UPDATE entries SET importance_score = ? WHERE id = ?`).bind(importance, entryId).run();
if (!kind && !canonical) return;
// scope-exempt: by-id: the entry this background pass was queued for
const row = await env.DB.prepare(`SELECT tags FROM entries WHERE id = ?`).bind(entryId).first() as Record<string, any> | null;
if (!row) return;
let tags: string[] = JSON.parse(row.tags ?? "[]");
if (kind) tags = withKind(tags, kind);
if (canonical && getStatus(tags) === null) tags = withStatus(tags, "canonical");
await env.DB.prepare(`UPDATE entries SET tags = ? WHERE id = ?`).bind(JSON.stringify(tags), entryId).run();
}

export function scheduleClassifyAndTag(
entryId: string,
content: string,
Expand All @@ -66,17 +83,35 @@ export function scheduleClassifyAndTag(
): void {
ctx.waitUntil(
classifyEntry(content, env, config)
.then(async ({ importance, canonical, kind }) => {
await env.DB.prepare(`UPDATE entries SET importance_score = ? WHERE id = ?`).bind(importance, entryId).run();
if (!kind && !canonical) return;
// scope-exempt: by-id: the entry this background pass was queued for
const row = await env.DB.prepare(`SELECT tags FROM entries WHERE id = ?`).bind(entryId).first() as Record<string, any> | null;
if (!row) return;
let tags: string[] = JSON.parse(row.tags ?? "[]");
if (kind) tags = withKind(tags, kind);
if (canonical && getStatus(tags) === null) tags = withStatus(tags, "canonical");
await env.DB.prepare(`UPDATE entries SET tags = ? WHERE id = ?`).bind(JSON.stringify(tags), entryId).run();
})
.then(c => applyClassification(entryId, env, c))
.catch(e => console.error("Classification failed (non-fatal):", e))
);
}

/**
* Classify, then infer edges with the kind that classification just produced.
*
* One `classifyEntry` call feeding both halves, not two: the kind an edge type
* needs to gate on is the same kind the tagger writes, and asking twice would
* add a model call per capture to learn something already known.
*
* Inference runs whatever classification did. A failed or unparseable
* classification degrades to `kind: null`, which costs the capture its typed
* edge and nothing else — the generic edge is still drawn. Losing the graph
* write because the classifier was unavailable would be a much worse trade.
*/
export function classifyThenInfer(
entryId: string,
content: string,
env: Env,
ctx: ExecutionContext,
config: Readonly<Config>,
infer: (kind: MemoryKind | null) => Promise<void>,
): void {
ctx.waitUntil(
classifyEntry(content, env, config)
.then(async c => { await applyClassification(entryId, env, c); return c.kind; })
.catch(e => { console.error("Classification failed (non-fatal):", e); return null; })
.then(kind => infer(kind).catch(e => console.error("Edge inference failed (non-fatal):", e)))
);
}
30 changes: 24 additions & 6 deletions src/capture/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { DEFAULTS, resolveConfig, type Config } from "../config";
import { createEdge, inferEdgesOnWrite } from "../graph/edges";
import { getStatus, withStatus } from "../memory/status";
import { extractHashtags } from "../text/hashtags";
import { scheduleClassifyAndTag } from "./classify";
import { classifyThenInfer, scheduleClassifyAndTag } from "./classify";
import { checkDuplicateAndContradiction } from "./duplicate";
import { deprecateEntry } from "./lifecycle";
import { deleteStaleVectors, reembedOrThrow, storeEntry } from "./store";
Expand Down Expand Up @@ -115,7 +115,7 @@ export async function captureEntry(
if (!protectedTarget) {
let newVectorIds: string[] | null = null;
try {
newVectorIds = await reembedOrThrow(env, targetId, newContent, existingTags, existingSource, cfg, writeCtx);
newVectorIds = (await reembedOrThrow(env, targetId, newContent, existingTags, existingSource, cfg, writeCtx)).vectorIds;
} catch (e) {
console.error("Merge re-embed failed — keeping both, target untouched:", e);
}
Expand All @@ -138,7 +138,18 @@ export async function captureEntry(
await deleteStaleVectors(env, oldVectorIds, newVectorIds);
} catch (e) { console.error("Old vector cleanup failed (non-fatal):", e); }

scheduleClassifyAndTag(targetId, newContent, env, ctx, cfg);
// The survivor's content just changed, so its graph position should
// too. `neighbors` is the answer duplicate detection already got from
// Vectorize for this same text, reused rather than asked again — the
// merge therefore adds no query and no embed of its own.
// inferEdgesOnWrite drops the written id from its own candidates, so
// the target needs no filtering. dup.matchId does: the model picks the
// merge target and is free to choose the SECOND-best match, leaving
// the closest near-duplicate in `neighbors` — and linking the survivor
// to that is the junk edge suppression exists to prevent, arriving by
// a different door.
classifyThenInfer(targetId, newContent, env, ctx, cfg, kind =>
inferEdgesOnWrite(targetId, neighbors, env, { suppressId: dup.matchId, newKind: kind }));

return mergeAction.action === "merge"
? { status: "merged", id: targetId }
Expand Down Expand Up @@ -169,7 +180,10 @@ export async function captureEntry(
// straight off the back of the save can miss it by one refresh.
ctx.waitUntil(rememberTags(env, finalTags, writeCtx.workspaceId));

scheduleClassifyAndTag(id, c, env, ctx, cfg);
// A flagged capture is a near-duplicate the writer chose to keep, so the
// entry it duplicates is its top neighbour by construction. Linking them
// spends an inference slot restating the duplicate-candidate tag.
const suppressId = dup.status === "flagged" ? dup.matchId : undefined;

if (contradiction.detected && contradiction.conflicting_id) {
const conflictId = contradiction.conflicting_id;
Expand Down Expand Up @@ -197,6 +211,8 @@ export async function captureEntry(
} catch (e) {
console.error("Contradiction count update failed (non-fatal):", e);
}
// This path draws no edges, so there is nothing to chain onto.
scheduleClassifyAndTag(id, c, env, ctx, cfg);
return { status: "contradiction_protected", id, canonicalId: conflictId, reason: contradiction.reason };
}

Expand All @@ -223,11 +239,13 @@ export async function captureEntry(
} catch (e) {
console.error("Supersedes edge creation failed (non-fatal):", e);
}
ctx.waitUntil(inferEdgesOnWrite(id, neighbors.filter(n => n.id !== conflictId), env).catch(e => console.error("Edge inference failed (non-fatal):", e)));
classifyThenInfer(id, c, env, ctx, cfg, kind =>
inferEdgesOnWrite(id, neighbors.filter(n => n.id !== conflictId), env, { suppressId, newKind: kind }));
return { status: "contradiction", id, resolvedConflict: conflictId, reason: contradiction.reason };
}

ctx.waitUntil(inferEdgesOnWrite(id, neighbors, env).catch(e => console.error("Edge inference failed (non-fatal):", e)));
classifyThenInfer(id, c, env, ctx, cfg, kind =>
inferEdgesOnWrite(id, neighbors, env, { suppressId, newKind: kind }));

if (dup.status === "flagged") {
return { status: "flagged", id, matchId: dup.matchId, score: dup.score };
Expand Down
Loading
Loading