* notes history
This commit is contained in:
arkml 2026-02-27 20:22:54 +05:30 committed by GitHub
parent 9df1bb6765
commit d7dc27a77e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 860 additions and 40 deletions

View file

@ -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);
});

View file

@ -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';

View file

@ -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);

View 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');
}

View file

@ -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,