Skip to content
Draft
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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ Config lives at `~/.config/engineering-notebook/config.json`:
"port": 3000,
"day_start_hour": 5,
"summary_instructions": "",
"summary_provider": "claude",
"summary_base_url": "",
"summary_model": "",
"summary_extras": {},
"remote_sources": [],
"auto_sync_interval": 60
}
Expand All @@ -112,9 +116,49 @@ Config lives at `~/.config/engineering-notebook/config.json`:
| `port` | Web server port | `3000` |
| `day_start_hour` | Hour (0-23) when a "day" starts (for grouping late-night sessions with the previous day) | `5` |
| `summary_instructions` | Custom instructions appended to the LLM summarization prompt | `""` |
| `summary_provider` | Summary backend: `"claude"` or `"openai-compat"` | `"claude"` |
| `summary_base_url` | Base URL when using `openai-compat` (path `/v1/chat/completions` is appended) | `""` |
| `summary_model` | Model name when using `openai-compat` | `""` |
| `summary_extras` | JSON object merged into the request body (server-specific options) | `{}` |
| `remote_sources` | SSH remote sources to sync before ingesting | `[]` |
| `auto_sync_interval` | Seconds between auto-syncs when serving | `60` |

### OpenAI-compatible summarization

To use a self-hosted or third-party model that speaks the OpenAI API protocol
(Ollama, vLLM, llama.cpp `llama-server`, LM Studio, OpenAI itself, etc.), set:

```json
{
"summary_provider": "openai-compat",
"summary_base_url": "http://localhost:11434",
"summary_model": "qwen3.6:latest",
"summary_extras": { "reasoning_effort": "none" }
}
```

The `summary_extras` object is merged verbatim into the request body, so any
field your server understands can be set there. A few useful examples:

| Server | Suggested `summary_extras` |
| --------------------------------------- | ----------------------------------------------------------------------- |
| Ollama (Qwen3, DeepSeek-R1, etc.) | `{ "reasoning_effort": "none" }` — suppresses chain-of-thought |
| OpenAI proper | `{ "reasoning_effort": "low" }` for o-series; omit for chat models |
| vLLM / SGLang / llama.cpp with reasoning | `{ "chat_template_kwargs": { "enable_thinking": false } }` |
| Any server | `{ "temperature": 0.2 }`, `{ "max_tokens": 4096 }`, etc. |

Server diagnostics:

```sh
# List available models
curl http://localhost:11434/v1/models

# Smoke-test the endpoint
curl -X POST http://localhost:11434/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"qwen3.6:latest","messages":[{"role":"user","content":"PONG"}]}'
```

### Remote Sources

Sync session files from remote machines over SSH:
Expand Down
27 changes: 27 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,29 @@ describe("config", () => {
expect(config.exclude).toContain("-private-tmp*");
expect(config.port).toBe(3000);
expect(config.db_path).toContain("notebook.db");
expect(config.summary_provider).toBe("claude");
expect(config.summary_base_url).toBe("");
expect(config.summary_model).toBe("");
expect(config.summary_extras).toEqual({});
});

test("loadConfig rejects invalid summary_provider with a clear error", () => {
const configPath = join(tempDir, "bad-provider.json");
writeFileSync(configPath, JSON.stringify({ summary_provider: "ollama" }));
expect(() => loadConfig(configPath)).toThrow(/Invalid summary_provider/);
});

test("loadConfig accepts openai-compat provider with extras", () => {
const configPath = join(tempDir, "openai-compat.json");
writeFileSync(configPath, JSON.stringify({
summary_provider: "openai-compat",
summary_base_url: "http://localhost:11434",
summary_model: "qwen3.6:latest",
summary_extras: { reasoning_effort: "none", temperature: 0.2 },
}));
const loaded = loadConfig(configPath);
expect(loaded.summary_provider).toBe("openai-compat");
expect(loaded.summary_extras).toEqual({ reasoning_effort: "none", temperature: 0.2 });
});

test("loadConfig returns default when no file exists", () => {
Expand All @@ -38,6 +61,10 @@ describe("config", () => {
port: 4000,
day_start_hour: 5,
summary_instructions: "",
summary_provider: "openai-compat",
summary_base_url: "http://localhost:11434",
summary_model: "qwen3.6:latest",
summary_extras: { reasoning_effort: "none" },
remote_sources: [],
auto_sync_interval: 60,
};
Expand Down
22 changes: 22 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,20 @@ export type RemoteSource = {
enabled: boolean;
};

export const SUMMARY_PROVIDERS = ["claude", "openai-compat"] as const;
export type SummaryProvider = (typeof SUMMARY_PROVIDERS)[number];

export type Config = {
sources: string[];
exclude: string[];
db_path: string;
port: number;
day_start_hour: number;
summary_instructions: string;
summary_provider: SummaryProvider;
summary_base_url: string;
summary_model: string;
summary_extras: Record<string, unknown>;
remote_sources: RemoteSource[];
auto_sync_interval: number;
};
Expand All @@ -29,6 +36,10 @@ export function defaultConfig(): Config {
port: 3000,
day_start_hour: 5,
summary_instructions: "",
summary_provider: "claude",
summary_base_url: "",
summary_model: "",
summary_extras: {},
remote_sources: [],
auto_sync_interval: 60,
};
Expand All @@ -45,6 +56,17 @@ export function loadConfig(path?: string): Config {
}
const raw = readFileSync(configPath, "utf-8");
const parsed = JSON.parse(raw) as Partial<Config>;

if (
parsed.summary_provider !== undefined &&
!SUMMARY_PROVIDERS.includes(parsed.summary_provider)
) {
throw new Error(
`Invalid summary_provider "${parsed.summary_provider}" in ${configPath}. ` +
`Must be one of: ${SUMMARY_PROVIDERS.join(", ")}.`
);
}

const config = { ...defaultConfig(), ...parsed };

// Migrate older default source list to include Codex sessions.
Expand Down
117 changes: 117 additions & 0 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,62 @@ export function initDb(dbPath: string): Database {
UNIQUE(date, project_id)
);

CREATE TABLE IF NOT EXISTS journal_invocations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER REFERENCES journal_invocations(id) ON DELETE CASCADE,
journal_entry_id INTEGER NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
scope_start INTEGER NOT NULL,
scope_end INTEGER NOT NULL,
depth INTEGER NOT NULL,
coherence_token TEXT NOT NULL,
question TEXT NOT NULL,
actions TEXT NOT NULL DEFAULT '[]',
started_at TEXT NOT NULL,
ended_at TEXT
);

CREATE TABLE IF NOT EXISTS journal_fragments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journal_entry_id INTEGER NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
invocation_id INTEGER REFERENCES journal_invocations(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
session_id TEXT NOT NULL REFERENCES sessions(id),
char_start INTEGER NOT NULL,
char_end INTEGER NOT NULL,
span_checksum TEXT NOT NULL,
category TEXT NOT NULL,
text TEXT NOT NULL,
extracted_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS journal_rollups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
period TEXT NOT NULL,
period_start TEXT NOT NULL,
project_id TEXT NOT NULL DEFAULT '',
headline TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '',
topics TEXT NOT NULL DEFAULT '[]',
open_questions TEXT NOT NULL DEFAULT '[]',
sources_count INTEGER NOT NULL DEFAULT 0,
reaudited_dates TEXT NOT NULL DEFAULT '[]',
generated_at TEXT NOT NULL,
model_used TEXT NOT NULL,
UNIQUE(period, period_start, project_id)
);

CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id);
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);
CREATE INDEX IF NOT EXISTS idx_journal_date ON journal_entries(date);
CREATE INDEX IF NOT EXISTS idx_journal_project ON journal_entries(project_id);
CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source_path);
CREATE INDEX IF NOT EXISTS idx_rollups_period ON journal_rollups(period, period_start);
CREATE INDEX IF NOT EXISTS idx_rollups_project ON journal_rollups(project_id);
CREATE INDEX IF NOT EXISTS idx_fragments_entry ON journal_fragments(journal_entry_id);
CREATE INDEX IF NOT EXISTS idx_fragments_session ON journal_fragments(session_id);
CREATE INDEX IF NOT EXISTS idx_fragments_invocation ON journal_fragments(invocation_id);
CREATE INDEX IF NOT EXISTS idx_invocations_entry ON journal_invocations(journal_entry_id);
CREATE INDEX IF NOT EXISTS idx_invocations_parent ON journal_invocations(parent_id);
`);

// Migrations
Expand All @@ -79,6 +130,72 @@ export function initDb(dbPath: string): Database {
} catch {
// Column already exists — ignore
}
try {
db.exec(`ALTER TABLE sessions ADD COLUMN source_size INTEGER`);
} catch {
// Column already exists — ignore
}
try {
db.exec(`ALTER TABLE sessions ADD COLUMN source_mtime TEXT`);
} catch {
// Column already exists — ignore
}
// Migrate journal_fragments to add invocation_id with proper FK + cascade.
//
// SQLite forbids `ALTER TABLE ADD COLUMN ... REFERENCES`, so the
// canonical fix is the table-rebuild dance: create the new shape, copy
// data, drop the old, rename. Detect whether migration is needed by
// checking whether the existing column has the FK; if not, rebuild.
const fragmentSchema = db
.query<{ sql: string }, []>(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'journal_fragments'`
)
.get();
const fragmentColumns = db
.query<{ name: string }, []>(`PRAGMA table_info(journal_fragments)`)
.all()
.map((r) => r.name);
const hasInvocationId = fragmentColumns.includes("invocation_id");
const fkDeclared =
fragmentSchema?.sql?.includes("invocation_id INTEGER REFERENCES") ?? false;
if (!hasInvocationId || !fkDeclared) {
db.transaction(() => {
db.exec(`
CREATE TABLE journal_fragments_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journal_entry_id INTEGER NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
invocation_id INTEGER REFERENCES journal_invocations(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
session_id TEXT NOT NULL REFERENCES sessions(id),
char_start INTEGER NOT NULL,
char_end INTEGER NOT NULL,
span_checksum TEXT NOT NULL,
category TEXT NOT NULL,
text TEXT NOT NULL,
extracted_at TEXT NOT NULL
);
`);
// Copy data, defaulting invocation_id to NULL for any pre-existing rows
// (those came from the long-since-deleted chunked-summarize path and
// had no recorded invocation).
const cols = hasInvocationId
? "id, journal_entry_id, invocation_id, chunk_index, session_id, char_start, char_end, span_checksum, category, text, extracted_at"
: "id, journal_entry_id, NULL, chunk_index, session_id, char_start, char_end, span_checksum, category, text, extracted_at";
db.exec(`
INSERT INTO journal_fragments_new
(id, journal_entry_id, invocation_id, chunk_index, session_id,
char_start, char_end, span_checksum, category, text, extracted_at)
SELECT ${cols} FROM journal_fragments;

DROP TABLE journal_fragments;
ALTER TABLE journal_fragments_new RENAME TO journal_fragments;

CREATE INDEX idx_fragments_entry ON journal_fragments(journal_entry_id);
CREATE INDEX idx_fragments_session ON journal_fragments(session_id);
CREATE INDEX idx_fragments_invocation ON journal_fragments(invocation_id);
`);
})();
}

_db = db;
return db;
Expand Down
Loading