Skip to content
Open
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
67 changes: 65 additions & 2 deletions packages/core/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ export function repoNameFromRemote(remote: string | null): string | null {
return name.length > 0 ? name : null;
}

const SCHEMA_VERSION = 16;

const MIGRATIONS: string[] = [
`
-- Version 1: Initial schema
Expand Down Expand Up @@ -675,6 +673,55 @@ const MIGRATIONS: string[] = [
CREATE INDEX IF NOT EXISTS idx_entity_relations_a ON entity_relations(entity_a);
CREATE INDEX IF NOT EXISTS idx_entity_relations_b ON entity_relations(entity_b);
`,
`
-- Version 29: Multi-user attribution, promotion workflow, and team sync scaffolding.
-- All columns nullable/defaulted for backward compat with local-only users.
-- Security note: these columns are sync metadata and product UX hints,
-- NOT access control. Isolation is enforced at the DB level (database-per-user/team).

-- User attribution
ALTER TABLE knowledge ADD COLUMN created_by TEXT;
ALTER TABLE knowledge ADD COLUMN updated_by TEXT;

-- Sensitivity classification (product hint — guides auto-promotion decisions)
ALTER TABLE knowledge ADD COLUMN sensitivity TEXT NOT NULL DEFAULT 'normal';

-- Promotion workflow (used in personal DB to track personal -> team flow)
ALTER TABLE knowledge ADD COLUMN promotion_status TEXT;
ALTER TABLE knowledge ADD COLUMN promoted_at INTEGER;

-- Approval workflow (used in team DB for admin approval)
ALTER TABLE knowledge ADD COLUMN approval_status TEXT NOT NULL DEFAULT 'auto';
ALTER TABLE knowledge ADD COLUMN approved_by TEXT;
ALTER TABLE knowledge ADD COLUMN approved_at INTEGER;

-- Origin tracking (used in team DB to trace back to source user)
ALTER TABLE knowledge ADD COLUMN source_user_id TEXT;
ALTER TABLE knowledge ADD COLUMN source_entry_id TEXT;

-- Access tracking
ALTER TABLE knowledge ADD COLUMN last_accessed_at INTEGER;

-- Team knowledge cache (local read-only copy of approved team entries)
CREATE TABLE IF NOT EXISTS team_knowledge (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_by TEXT,
confidence REAL DEFAULT 1.0,
sensitivity TEXT NOT NULL DEFAULT 'normal',
source_user_id TEXT,
synced_at INTEGER NOT NULL,
metadata TEXT
);

-- Team configuration (credentials, sync state)
CREATE TABLE IF NOT EXISTS team_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`,
];

/** Return the resolved path of the SQLite database file. */
Expand Down Expand Up @@ -872,6 +919,22 @@ function recoverMissingObjects(database: Database) {
updated_at INTEGER NOT NULL,
UNIQUE(entity_a, entity_b, relation)
);
CREATE TABLE IF NOT EXISTS team_knowledge (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_by TEXT,
confidence REAL DEFAULT 1.0,
sensitivity TEXT NOT NULL DEFAULT 'normal',
source_user_id TEXT,
synced_at INTEGER NOT NULL,
metadata TEXT
);
CREATE TABLE IF NOT EXISTS team_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);

// Recover missing columns from partial migration runs.
Expand Down
54 changes: 49 additions & 5 deletions packages/core/src/ltm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ function estimateTokens(text: string): number {
return Math.ceil(text.length / 3);
}

/** Sensitivity classification — product hint guiding auto-promotion decisions. */
export type Sensitivity = "normal" | "sensitive" | "restricted";
/** Promotion intent — tracks the personal \u2192 team DB promotion flow. */
export type PromotionStatus = "nominated" | "suggested" | "promoted";
/** Approval state — used in team DB for admin approval workflow. */
export type ApprovalStatus = "auto" | "pending" | "approved" | "rejected";

export type KnowledgeEntry = {
id: string;
project_id: string | null;
Expand All @@ -23,16 +30,28 @@ export type KnowledgeEntry = {
created_at: number;
updated_at: number;
metadata: string | null;
// Multi-user attribution & sync (v29)
created_by: string | null;
updated_by: string | null;
sensitivity: Sensitivity;
promotion_status: PromotionStatus | null;
promoted_at: number | null;
approval_status: ApprovalStatus;
approved_by: string | null;
approved_at: number | null;
source_user_id: string | null;
source_entry_id: string | null;
last_accessed_at: number | null;
};

/** Columns to select for KnowledgeEntry — excludes the embedding BLOB
* (4KB per entry) which is only needed by vectorSearch() in embedding.ts. */
const KNOWLEDGE_COLS =
"id, project_id, category, title, content, source_session, cross_project, confidence, created_at, updated_at, metadata";
"id, project_id, category, title, content, source_session, cross_project, confidence, created_at, updated_at, metadata, created_by, updated_by, sensitivity, promotion_status, promoted_at, approval_status, approved_by, approved_at, source_user_id, source_entry_id, last_accessed_at";

/** Same columns with table alias prefix for use in JOIN queries. */
const KNOWLEDGE_COLS_K =
"k.id, k.project_id, k.category, k.title, k.content, k.source_session, k.cross_project, k.confidence, k.created_at, k.updated_at, k.metadata";
"k.id, k.project_id, k.category, k.title, k.content, k.source_session, k.cross_project, k.confidence, k.created_at, k.updated_at, k.metadata, k.created_by, k.updated_by, k.sensitivity, k.promotion_status, k.promoted_at, k.approval_status, k.approved_by, k.approved_at, k.source_user_id, k.source_entry_id, k.last_accessed_at";

export function create(input: {
projectPath?: string;
Expand All @@ -46,6 +65,10 @@ export function create(input: {
id?: string;
/** Initial confidence (0.0–1.0). Default 1.0. Controls injection priority for preferences. */
confidence?: number;
/** User ID who created this entry. Null for system-created entries. */
createdBy?: string;
/** Sensitivity classification — guides auto-promotion decisions. Default 'normal'. */
sensitivity?: Sensitivity;
}): string {
const pid =
input.scope === "project" && input.projectPath
Expand Down Expand Up @@ -122,8 +145,8 @@ export function create(input: {
: 1.0;
db()
.query(
`INSERT INTO knowledge (id, project_id, category, title, content, source_session, cross_project, confidence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO knowledge (id, project_id, category, title, content, source_session, cross_project, confidence, created_at, updated_at, created_by, sensitivity)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
id,
Expand All @@ -136,6 +159,8 @@ export function create(input: {
confidence,
now,
now,
input.createdBy ?? null,
input.sensitivity ?? "normal",
);

// Fire-and-forget: embed for vector search (errors logged, never thrown)
Expand All @@ -148,7 +173,7 @@ export function create(input: {

export function update(
id: string,
input: { content?: string; confidence?: number },
input: { content?: string; confidence?: number; updatedBy?: string; sensitivity?: Sensitivity },
) {
const sets: string[] = [];
const params: unknown[] = [];
Expand All @@ -162,6 +187,14 @@ export function update(
sets.push("confidence = ?");
params.push(Math.max(0, Math.min(1, input.confidence)));
}
if (input.updatedBy !== undefined) {
sets.push("updated_by = ?");
params.push(input.updatedBy);
}
if (input.sensitivity !== undefined) {
sets.push("sensitivity = ?");
params.push(input.sensitivity);
}
sets.push("updated_at = ?");
params.push(Date.now());
params.push(id);
Expand Down Expand Up @@ -654,6 +687,17 @@ export async function forSession(
created_at: section.updated_at,
updated_at: section.updated_at,
metadata: null,
created_by: null,
updated_by: null,
sensitivity: "normal",
promotion_status: null,
promoted_at: null,
approval_status: "auto",
approved_by: null,
approved_at: null,
source_user_id: null,
source_entry_id: null,
last_accessed_at: null,
});
used += cost;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe("db", () => {
const row = db().query("SELECT version FROM schema_version").get() as {
version: number;
};
expect(row.version).toBe(28);
expect(row.version).toBe(29);
});

test("distillation_fts virtual table exists", () => {
Expand Down
120 changes: 120 additions & 0 deletions packages/core/test/ltm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1221,3 +1221,123 @@ describe("ltm.rerankPreferences", () => {
expect(ltm.get(ids[3])!.confidence).toBe(0.9);
});
});

// ---------------------------------------------------------------------------
// Multi-user attribution, promotion, and team sync columns (v29)
// ---------------------------------------------------------------------------
describe("ltm — multi-user columns (v29)", () => {
const PROJ = "/test/ltm/multiuser";

beforeEach(() => {
const pid = ensureProject(PROJ);
db().query("DELETE FROM knowledge WHERE project_id = ?").run(pid);
});

test("new entries have correct default values for v29 columns", () => {
const id = ltm.create({
projectPath: PROJ,
category: "decision",
title: "Default values test",
content: "Test content for defaults",
scope: "project",
});
const entry = ltm.get(id);
expect(entry).not.toBeNull();
expect(entry!.created_by).toBeNull();
expect(entry!.updated_by).toBeNull();
expect(entry!.sensitivity).toBe("normal");
expect(entry!.promotion_status).toBeNull();
expect(entry!.promoted_at).toBeNull();
expect(entry!.approval_status).toBe("auto");
expect(entry!.approved_by).toBeNull();
expect(entry!.approved_at).toBeNull();
expect(entry!.source_user_id).toBeNull();
expect(entry!.source_entry_id).toBeNull();
expect(entry!.last_accessed_at).toBeNull();
});

test("create() accepts createdBy", () => {
const id = ltm.create({
projectPath: PROJ,
category: "pattern",
title: "Created by test",
content: "Test content",
scope: "project",
createdBy: "user-abc-123",
});
const entry = ltm.get(id);
expect(entry!.created_by).toBe("user-abc-123");
});

test("create() accepts sensitivity override", () => {
const id = ltm.create({
projectPath: PROJ,
category: "architecture",
title: "Sensitivity test",
content: "Contains API keys pattern",
scope: "project",
sensitivity: "sensitive",
});
const entry = ltm.get(id);
expect(entry!.sensitivity).toBe("sensitive");
});

test("update() sets updated_by when updatedBy provided", () => {
const id = ltm.create({
projectPath: PROJ,
category: "gotcha",
title: "Updated by test",
content: "Original content",
scope: "project",
});
ltm.update(id, { content: "Modified content", updatedBy: "user-xyz-456" });
const entry = ltm.get(id);
expect(entry!.content).toBe("Modified content");
expect(entry!.updated_by).toBe("user-xyz-456");
});

test("update() can change sensitivity", () => {
const id = ltm.create({
projectPath: PROJ,
category: "architecture",
title: "Sensitivity update test",
content: "Contains credentials pattern",
scope: "project",
sensitivity: "normal",
});
ltm.update(id, { sensitivity: "restricted" });
const entry = ltm.get(id);
expect(entry!.sensitivity).toBe("restricted");
});

test("update() without updatedBy leaves updated_by unchanged", () => {
const id = ltm.create({
projectPath: PROJ,
category: "pattern",
title: "No updatedBy test",
content: "Original content",
scope: "project",
createdBy: "user-abc-123",
});
ltm.update(id, { content: "Modified content" });
const entry = ltm.get(id);
expect(entry!.updated_by).toBeNull();
expect(entry!.created_by).toBe("user-abc-123");
});

test("team_knowledge table exists", () => {
const tables = db()
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='team_knowledge'")
.all() as Array<{ name: string }>;
expect(tables).toHaveLength(1);
expect(tables[0].name).toBe("team_knowledge");
});

test("team_config table exists", () => {
const tables = db()
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='team_config'")
.all() as Array<{ name: string }>;
expect(tables).toHaveLength(1);
expect(tables[0].name).toBe("team_config");
});
});
Loading