mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-28 01:46:23 +02:00
parent
9df1bb6765
commit
d7dc27a77e
12 changed files with 860 additions and 40 deletions
|
|
@ -91,4 +91,9 @@ function ensureWelcomeFile() {
|
|||
|
||||
ensureDirs();
|
||||
ensureDefaultConfigs();
|
||||
ensureWelcomeFile();
|
||||
ensureWelcomeFile();
|
||||
|
||||
// Initialize version history repo (async, fire-and-forget on startup)
|
||||
import('../knowledge/version_history.js').then(m => m.initRepo()).catch(err => {
|
||||
console.error('[VersionHistory] Failed to init repo:', err);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,4 +5,7 @@ export * as workspace from './workspace/workspace.js';
|
|||
export * as watcher from './workspace/watcher.js';
|
||||
|
||||
// Config initialization
|
||||
export { initConfigs } from './config/initConfigs.js';
|
||||
export { initConfigs } from './config/initConfigs.js';
|
||||
|
||||
// Knowledge version history
|
||||
export * as versionHistory from './knowledge/version_history.js';
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from './graph_state.js';
|
||||
import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js';
|
||||
import { limitEventItems } from './limit_event_items.js';
|
||||
import { commitAll } from './version_history.js';
|
||||
|
||||
/**
|
||||
* Build obsidian-style knowledge graph by running topic extraction
|
||||
|
|
@ -320,6 +321,13 @@ async function buildGraphWithFiles(
|
|||
// Save state after each successful batch
|
||||
// This ensures partial progress is saved even if later batches fail
|
||||
saveState(state);
|
||||
|
||||
// Commit knowledge changes to version history
|
||||
try {
|
||||
await commitAll('Knowledge update', 'Rowboat');
|
||||
} catch (err) {
|
||||
console.error(`[GraphBuilder] Failed to commit version history:`, err);
|
||||
}
|
||||
} catch (error) {
|
||||
hadError = true;
|
||||
console.error(`Error processing batch ${batchNumber}:`, error);
|
||||
|
|
@ -467,6 +475,13 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
|
|||
|
||||
// Save state after each batch
|
||||
saveState(state);
|
||||
|
||||
// Commit knowledge changes to version history
|
||||
try {
|
||||
await commitAll('Knowledge update', 'Rowboat');
|
||||
} catch (err) {
|
||||
console.error(`[GraphBuilder] Failed to commit version history:`, err);
|
||||
}
|
||||
} catch (error) {
|
||||
hadError = true;
|
||||
console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error);
|
||||
|
|
|
|||
243
apps/x/packages/core/src/knowledge/version_history.ts
Normal file
243
apps/x/packages/core/src/knowledge/version_history.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import git from 'isomorphic-git';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
|
||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||
|
||||
// Simple promise-based mutex to serialize commits
|
||||
let commitLock: Promise<void> = Promise.resolve();
|
||||
|
||||
// Commit listeners for notifying other layers (e.g. renderer refresh)
|
||||
type CommitListener = () => void;
|
||||
const commitListeners: CommitListener[] = [];
|
||||
|
||||
export function onCommit(listener: CommitListener): () => void {
|
||||
commitListeners.push(listener);
|
||||
return () => {
|
||||
const idx = commitListeners.indexOf(listener);
|
||||
if (idx >= 0) commitListeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a git repo in the knowledge directory if one doesn't exist.
|
||||
* Stages all existing .md files and makes an initial commit.
|
||||
*/
|
||||
export async function initRepo(): Promise<void> {
|
||||
const gitDir = path.join(KNOWLEDGE_DIR, '.git');
|
||||
if (fs.existsSync(gitDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure knowledge dir exists
|
||||
if (!fs.existsSync(KNOWLEDGE_DIR)) {
|
||||
fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
await git.init({ fs, dir: KNOWLEDGE_DIR });
|
||||
|
||||
// Stage all existing .md files
|
||||
const files = getAllMdFiles(KNOWLEDGE_DIR, '');
|
||||
for (const file of files) {
|
||||
await git.add({ fs, dir: KNOWLEDGE_DIR, filepath: file });
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
await git.commit({
|
||||
fs,
|
||||
dir: KNOWLEDGE_DIR,
|
||||
message: 'Initial snapshot',
|
||||
author: { name: 'Rowboat', email: 'local' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find all .md files relative to the knowledge dir.
|
||||
*/
|
||||
function getAllMdFiles(baseDir: string, relDir: string): string[] {
|
||||
const results: string[] = [];
|
||||
const absDir = relDir ? path.join(baseDir, relDir) : baseDir;
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(absDir);
|
||||
} catch {
|
||||
return results;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry === '.git' || entry.startsWith('.')) continue;
|
||||
const fullPath = path.join(absDir, entry);
|
||||
const relPath = relDir ? `${relDir}/${entry}` : entry;
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
results.push(...getAllMdFiles(baseDir, relPath));
|
||||
} else if (entry.endsWith('.md')) {
|
||||
results.push(relPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage all changes to .md files and commit. No-op if nothing changed.
|
||||
* Serialized via a promise lock to prevent concurrent git index corruption.
|
||||
*/
|
||||
export async function commitAll(message: string, authorName: string): Promise<void> {
|
||||
const prev = commitLock;
|
||||
let resolve: () => void;
|
||||
commitLock = new Promise(r => { resolve = r; });
|
||||
|
||||
await prev;
|
||||
try {
|
||||
await commitAllInner(message, authorName);
|
||||
} finally {
|
||||
resolve!();
|
||||
}
|
||||
}
|
||||
|
||||
async function commitAllInner(message: string, authorName: string): Promise<void> {
|
||||
const matrix = await git.statusMatrix({ fs, dir: KNOWLEDGE_DIR });
|
||||
|
||||
let hasChanges = false;
|
||||
for (const [filepath, head, workdir, stage] of matrix) {
|
||||
// Skip non-md files
|
||||
if (!filepath.endsWith('.md')) continue;
|
||||
|
||||
// [filepath, HEAD, WORKDIR, STAGE]
|
||||
// Unchanged: [f, 1, 1, 1]
|
||||
if (head === 1 && workdir === 1 && stage === 1) continue;
|
||||
|
||||
hasChanges = true;
|
||||
|
||||
if (workdir === 0) {
|
||||
// File deleted from workdir
|
||||
await git.remove({ fs, dir: KNOWLEDGE_DIR, filepath });
|
||||
} else {
|
||||
// File added or modified
|
||||
await git.add({ fs, dir: KNOWLEDGE_DIR, filepath });
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasChanges) return;
|
||||
|
||||
await git.commit({
|
||||
fs,
|
||||
dir: KNOWLEDGE_DIR,
|
||||
message,
|
||||
author: { name: authorName, email: 'local' },
|
||||
});
|
||||
|
||||
for (const listener of commitListeners) {
|
||||
try { listener(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommitInfo {
|
||||
oid: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
author: string;
|
||||
}
|
||||
|
||||
const MAX_FILE_HISTORY = 50;
|
||||
|
||||
/**
|
||||
* Get commit history for a specific file.
|
||||
* Returns commits where the file content changed, most recent first.
|
||||
* Capped at MAX_FILE_HISTORY entries.
|
||||
*/
|
||||
export async function getFileHistory(knowledgeRelPath: string): Promise<CommitInfo[]> {
|
||||
// Normalize path separators for git (always forward slashes)
|
||||
const filepath = knowledgeRelPath.replace(/\\/g, '/');
|
||||
|
||||
let commits: Awaited<ReturnType<typeof git.log>>;
|
||||
try {
|
||||
commits = await git.log({ fs, dir: KNOWLEDGE_DIR });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (commits.length === 0) return [];
|
||||
|
||||
const result: CommitInfo[] = [];
|
||||
|
||||
// Walk through commits and check if file changed between consecutive commits
|
||||
for (let i = 0; i < commits.length; i++) {
|
||||
if (result.length >= MAX_FILE_HISTORY) break;
|
||||
|
||||
const commit = commits[i]!;
|
||||
const parentCommit = commits[i + 1]; // undefined for the first (oldest) commit
|
||||
|
||||
const currentOid = await getBlobOidAtCommit(commit.oid, filepath);
|
||||
const parentOid = parentCommit
|
||||
? await getBlobOidAtCommit(parentCommit.oid, filepath)
|
||||
: null;
|
||||
|
||||
// Include this commit if:
|
||||
// - The file existed and changed from parent
|
||||
// - The file was added (parentOid is null but currentOid exists)
|
||||
// - The file was deleted (currentOid is null but parentOid exists)
|
||||
if (currentOid !== parentOid) {
|
||||
result.push({
|
||||
oid: commit.oid,
|
||||
message: commit.commit.message.trim(),
|
||||
timestamp: commit.commit.author.timestamp,
|
||||
author: commit.commit.author.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the blob OID for a file at a specific commit, or null if not found.
|
||||
*/
|
||||
async function getBlobOidAtCommit(commitOid: string, filepath: string): Promise<string | null> {
|
||||
try {
|
||||
const result = await git.readBlob({
|
||||
fs,
|
||||
dir: KNOWLEDGE_DIR,
|
||||
oid: commitOid,
|
||||
filepath,
|
||||
});
|
||||
// Compute a content hash from the blob to compare
|
||||
return result.oid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content at a specific commit.
|
||||
*/
|
||||
export async function getFileAtCommit(knowledgeRelPath: string, oid: string): Promise<string> {
|
||||
const filepath = knowledgeRelPath.replace(/\\/g, '/');
|
||||
const result = await git.readBlob({
|
||||
fs,
|
||||
dir: KNOWLEDGE_DIR,
|
||||
oid,
|
||||
filepath,
|
||||
});
|
||||
return Buffer.from(result.blob).toString('utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a file to its content at a given commit, then commit the restoration.
|
||||
*/
|
||||
export async function restoreFile(knowledgeRelPath: string, oid: string): Promise<void> {
|
||||
const content = await getFileAtCommit(knowledgeRelPath, oid);
|
||||
const absPath = path.join(KNOWLEDGE_DIR, knowledgeRelPath);
|
||||
|
||||
// Ensure parent directory exists
|
||||
const dir = path.dirname(absPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(absPath, content, 'utf-8');
|
||||
|
||||
const filename = path.basename(knowledgeRelPath);
|
||||
await commitAll(`Restored ${filename}`, 'You');
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { z } from 'zod';
|
|||
import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js';
|
||||
import { commitAll } from '../knowledge/version_history.js';
|
||||
|
||||
// ============================================================================
|
||||
// Path Utilities
|
||||
|
|
@ -218,6 +219,21 @@ export async function readFile(
|
|||
};
|
||||
}
|
||||
|
||||
// Debounced commit for knowledge file edits
|
||||
let knowledgeCommitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function scheduleKnowledgeCommit(filename: string): void {
|
||||
if (knowledgeCommitTimer) {
|
||||
clearTimeout(knowledgeCommitTimer);
|
||||
}
|
||||
knowledgeCommitTimer = setTimeout(() => {
|
||||
knowledgeCommitTimer = null;
|
||||
commitAll(`Edit ${filename}`, 'You').catch(err => {
|
||||
console.error('[VersionHistory] Failed to commit after edit:', err);
|
||||
});
|
||||
}, 3 * 60 * 1000);
|
||||
}
|
||||
|
||||
export async function writeFile(
|
||||
relPath: string,
|
||||
data: string,
|
||||
|
|
@ -266,6 +282,11 @@ export async function writeFile(
|
|||
const stat = statToSchema(stats, 'file');
|
||||
const etag = computeEtag(stats.size, stats.mtimeMs);
|
||||
|
||||
// Schedule a debounced version history commit for knowledge files
|
||||
if (relPath.startsWith('knowledge/') && relPath.endsWith('.md')) {
|
||||
scheduleKnowledgeCommit(path.basename(relPath));
|
||||
}
|
||||
|
||||
return {
|
||||
path: relPath,
|
||||
stat,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue