diff --git a/README.md b/README.md index 73723877..a0320075 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,10 @@ Rule of thumb: lasting always-on project instructions → `AGENTS.md`; growing w - Optional HTTP catalog for other OpenCode instances: `"skills": ["http://127.0.0.1:4747/skills/"]` (serves `/skills/index.json` and `/skills//.md` — separate from the UI path). - REST: `GET/POST /api/skills`, `GET/PUT/DELETE /api/skills/:id`. +### Health stats + +The web UI's **Stats** tab (at `/stats`) shows memory-system health at a glance: totals per scope, per-shard memory counts with stale/missing-shard warnings and a same-tag duplication proxy, the active user-profile version, and a 14-day activity series (captures, skipped captures, profile/compaction injections, and errors bucketed by message) parsed from the plugin's own structured logs in `~/.opencode-mem/`. It is read-only — `GET /api/stats` — and degrades per section if a database is unavailable, so it keeps reporting during incidents. + ### Intelligent prompt-based memory extraction That phrase in the feature list is **auto-capture**: after a conversation, a background AI request summarizes technical work and saves it as memory. No special prompt from you is required. It uses `opencodeProvider` / `opencodeModel` when set, otherwise the manual `memoryProvider` fallback. diff --git a/src/services/api-handlers.ts b/src/services/api-handlers.ts index abf8cc55..729abd5b 100644 --- a/src/services/api-handlers.ts +++ b/src/services/api-handlers.ts @@ -13,6 +13,12 @@ import type { UserProfileData } from "./user-profile/types.js"; import { sortProfileItems } from "../utils/profile.js"; import { toPublicProfileData } from "./user-profile/profile-utils.js"; import type { ShardInfo } from "./turso/types.js"; +import { + collectDailyStats, + collectProfileStats, + collectShardStats, + type CollectDailyStatsOptions, +} from "./stats-service.js"; async function getAllMemoryShards(): Promise { await ensureTursoReady(); @@ -793,37 +799,56 @@ export async function handleSearch( } } -export async function handleStats(): Promise< +export async function handleStats( + options: Pick & { probe?: boolean } = {} +): Promise< ApiResponse<{ total: number; byScope: { user: number; project: number }; byType: Record; + generatedAt: string; + shards: Awaited>["shards"]; + profile: Awaited>; + daily: ReturnType; + dailyError?: string; }> > { try { + // Ownership/liveness probes must stay cheap: auth already ran, do not open + // shards or scan logs under the 2s checkServerAvailable timeout. + if (options.probe) { + return { success: true }; + } + // Stats only counts Turso/libSQL rows; no embedding needed. // See handleListTags comment - keep embedding init out of read paths. - const shards = await getAllMemoryShards(); - let userCount = 0, - projectCount = 0; - const typeCount: Record = {}; - for (const shard of shards) { - const db = await tursoConnectionManager.getConnection(shard.dbPath); - const memories = await tursoVectorSearch.getAllMemories(db); - for (const r of memories) { - const containerTag = String(r.container_tag ?? ""); - const type = r.type ? String(r.type) : ""; - if (containerTag.includes("_user_")) userCount++; - else if (containerTag.includes("_project_")) projectCount++; - if (type) typeCount[type] = (typeCount[type] || 0) + 1; - } + // Shard listing failures stay hard errors so /api/stats consumers (explorer + // header total) never treat an empty total as a successful empty store. + const memoryShards = await getAllMemoryShards(); + const [shardStats, profile] = await Promise.all([ + collectShardStats(memoryShards), + collectProfileStats(), + ]); + + let daily: ReturnType = []; + let dailyError: string | undefined; + try { + daily = collectDailyStats(options); + } catch (error) { + dailyError = error instanceof Error ? error.message : String(error); } + return { success: true, data: { - total: userCount + projectCount, - byScope: { user: userCount, project: projectCount }, - byType: typeCount, + total: shardStats.total, + byScope: shardStats.byScope, + byType: shardStats.byType, + generatedAt: new Date().toISOString(), + shards: shardStats.shards, + profile, + daily, + ...(dailyError ? { dailyError } : {}), }, }; } catch (error) { diff --git a/src/services/stats-service.ts b/src/services/stats-service.ts new file mode 100644 index 00000000..e06b1824 --- /dev/null +++ b/src/services/stats-service.ts @@ -0,0 +1,391 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; +import { CONFIG } from "../config.js"; +import { tursoConnectionManager } from "./turso/connection-manager.js"; +import type { ShardInfo } from "./turso/types.js"; +import type { UserProfile } from "./user-profile/types.js"; + +export type DailyCounter = + "captures" | "captureSkipped" | "profileInjections" | "compactionInjections"; + +export interface ParsedLogLine { + date: string; + message: string; + payload?: string; +} + +export interface LogClassification { + counter?: DailyCounter; + errorBucket?: string; +} + +export interface DailyStats { + date: string; + captures: number; + captureSkipped: number; + profileInjections: number; + compactionInjections: number; + errors: Record; +} + +export interface ShardStats { + shardId: number | null; + scope: "user" | "project"; + scopeHash: string; + shardIndex: number; + fileName: string; + fileExists: boolean; + isActive: boolean; + registeredVectorCount: number; + memoryCount: number; + oldestCreatedAt: string | null; + newestCreatedAt: string | null; + duplicateTagCount: number; + error?: string; +} + +export interface ShardCollection { + shards: ShardStats[]; + total: number; + byScope: { user: number; project: number }; + byType: Record; +} + +interface CachedLogEvent { + date: string; + classification: LogClassification; +} + +interface CachedLogFile { + signature: string; + events: CachedLogEvent[]; +} + +const parsedLogFiles = new Map(); + +/** Parse a structured logger line, splitting the message only at the first `: {`. */ +export function parseLogLine(line: string): ParsedLogLine | null { + const normalizedLine = line.endsWith("\r") ? line.slice(0, -1) : line; + const match = normalizedLine.match( + /^\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2})\]\s(.*)$/ + ); + if (!match || !Number.isFinite(Date.parse(match[1]!))) return null; + + const content = match[2]!; + const payloadSeparator = content.indexOf(": {"); + if (payloadSeparator === -1) { + return { date: match[1]!.slice(0, 10), message: content }; + } + + return { + date: match[1]!.slice(0, 10), + message: content.slice(0, payloadSeparator), + payload: content.slice(payloadSeparator + 2), + }; +} + +/** Classify a parsed logger message as one daily counter or a named error bucket. */ +export function classifyLogMessage(message: string): LogClassification { + if (message.startsWith("Auto-capture memory persisted")) return { counter: "captures" }; + if (message.startsWith("Auto-capture skipped")) return { counter: "captureSkipped" }; + if (message.startsWith("profile inject")) return { counter: "profileInjections" }; + if (message.startsWith("Compaction memory injected")) { + return { counter: "compactionInjections" }; + } + + if (message.startsWith("Turso ready gate failed")) { + return { errorBucket: "Turso ready gate failed" }; + } + if (message.startsWith("Failed to load skills for OpenCode registration")) { + return { errorBucket: "Failed to load skills for OpenCode registration" }; + } + if (message.startsWith("Auto-capture final error")) { + return { errorBucket: "Auto-capture final error" }; + } + if (message.startsWith("Compaction handler error")) { + return { errorBucket: "Compaction handler error" }; + } + if (message.startsWith("Idle processing error")) { + return { errorBucket: "Idle processing error" }; + } + + // Match `listMemories: error`, `chat.message: ERROR`, etc. + const errorSuffix = message.match(/^(.*):\s*error$/i); + if (errorSuffix) { + return { errorBucket: errorSuffix[1]!.trim() }; + } + return {}; +} + +function formatLocalDate(date: Date): string { + const pad = (value: number) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +function zeroDay(date: string): DailyStats { + return { + date, + captures: 0, + captureSkipped: 0, + profileInjections: 0, + compactionInjections: 0, + errors: {}, + }; +} + +function listLogFiles(logDir: string): string[] { + try { + const files = readdirSync(logDir); + return files + .filter((file) => /^opencode-mem-\d{4}-\d{2}-\d{2}\.log$/.test(file)) + .map((file) => join(logDir, file)) + .concat( + ["opencode-mem.log", "opencode-mem.log.old"] + .filter((file) => files.includes(file)) + .map((file) => join(logDir, file)) + ); + } catch { + return []; + } +} + +function getCachedEvents(filePath: string): CachedLogEvent[] | null { + try { + const stats = statSync(filePath); + if (!stats.isFile()) return null; + + const signature = `${stats.size}:${stats.mtimeMs}`; + const cached = parsedLogFiles.get(filePath); + if (cached?.signature === signature) return cached.events; + + const events: CachedLogEvent[] = []; + for (const line of readFileSync(filePath, "utf8").split("\n")) { + const parsed = parseLogLine(line); + if (!parsed) continue; + const classification = classifyLogMessage(parsed.message); + if (classification.counter || classification.errorBucket) { + events.push({ date: parsed.date, classification }); + } + } + parsedLogFiles.set(filePath, { signature, events }); + return events; + } catch { + parsedLogFiles.delete(filePath); + return null; + } +} + +export interface CollectDailyStatsOptions { + logDir?: string; + days?: number; + now?: Date; +} + +/** + * Mirrors the log file resolution in services/logger.ts without importing it: + * the API-handler scenario tests mock logger.js with a minimal surface, so a + * static import of any other export from that module breaks their import graph. + */ +function defaultLogDir(): string { + const logFile = + process.env.OPENCODE_MEM_LOG_FILE || join(homedir(), ".opencode-mem", "opencode-mem.log"); + const lastSlash = Math.max(logFile.lastIndexOf("/"), logFile.lastIndexOf("\\")); + return lastSlash === -1 ? "." : logFile.slice(0, lastSlash); +} + +/** Read and aggregate structured log counters into a local-date, zero-filled series. */ +export function collectDailyStats({ + logDir = defaultLogDir(), + days = 14, + now = new Date(), +}: CollectDailyStatsOptions = {}): DailyStats[] { + const dayCount = Math.max(1, Math.floor(days)); + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const daily = new Map(); + + for (let offset = dayCount - 1; offset >= 0; offset--) { + const date = new Date(end); + date.setDate(end.getDate() - offset); + const key = formatLocalDate(date); + daily.set(key, zeroDay(key)); + } + + const files = listLogFiles(logDir); + const foundFiles = new Set(files); + const normalizedDir = join(logDir, "."); + for (const path of parsedLogFiles.keys()) { + if (path.startsWith(normalizedDir) && !foundFiles.has(path)) parsedLogFiles.delete(path); + } + + for (const filePath of files) { + const events = getCachedEvents(filePath); + if (!events) continue; + for (const { date, classification } of events) { + const day = daily.get(date); + if (!day) continue; + if (classification.counter) { + day[classification.counter]++; + } else if (classification.errorBucket) { + day.errors[classification.errorBucket] = (day.errors[classification.errorBucket] ?? 0) + 1; + } + } + } + + return [...daily.values()]; +} + +function toISOStringOrNull(value: unknown): string | null { + if (value === null || value === undefined) return null; + const timestamp = Number(value); + if (!Number.isFinite(timestamp)) return null; + try { + return new Date(timestamp).toISOString(); + } catch { + return null; + } +} + +/** Collects registered shard health while preserving the existing stats totals. */ +export async function collectShardStats(shards: ShardInfo[]): Promise { + const shardStats: ShardStats[] = []; + let userCount = 0; + let projectCount = 0; + const typeCount: Record = {}; + + for (const shard of shards) { + const stats: ShardStats = { + shardId: Number.isFinite(shard.id) ? shard.id : null, + scope: shard.scope, + scopeHash: shard.scopeHash, + shardIndex: shard.shardIndex, + fileName: basename(shard.dbPath), + fileExists: existsSync(shard.dbPath), + isActive: shard.isActive, + registeredVectorCount: shard.vectorCount, + memoryCount: 0, + oldestCreatedAt: null, + newestCreatedAt: null, + duplicateTagCount: 0, + }; + + if (!stats.fileExists) { + stats.error = `Shard DB file missing: ${stats.fileName}`; + shardStats.push(stats); + continue; + } + + try { + const db = await tursoConnectionManager.getConnection(shard.dbPath); + + // Aggregate instead of `SELECT *` (getAllMemories): stats is polled every + // 30s by the web UI and must not drag vector blobs for every memory. + // Accumulators stay local until every query for this shard succeeds so a + // mid-walk failure cannot inflate global totals while memoryCount stays 0. + let shardUserCount = 0; + let shardProjectCount = 0; + const shardTypeCount: Record = {}; + + const scopeTypeCounts = await db.all<{ + container_tag: unknown; + type: unknown; + c: number | bigint; + }>("SELECT container_tag, type, COUNT(*) c FROM memories GROUP BY container_tag, type"); + for (const row of scopeTypeCounts) { + const containerTag = String(row.container_tag ?? ""); + const type = row.type ? String(row.type) : ""; + const count = Number(row.c ?? 0); + if (containerTag.includes("_user_")) shardUserCount += count; + else if (containerTag.includes("_project_")) shardProjectCount += count; + if (type) shardTypeCount[type] = (shardTypeCount[type] || 0) + count; + } + + const aggregate = await db.get<{ c: number | bigint; mn: unknown; mx: unknown }>( + "SELECT COUNT(*) c, MIN(created_at) mn, MAX(created_at) mx FROM memories" + ); + stats.memoryCount = Number(aggregate?.c ?? 0); + stats.oldestCreatedAt = toISOStringOrNull(aggregate?.mn); + stats.newestCreatedAt = toISOStringOrNull(aggregate?.mx); + + const duplicateTags = await db.all<{ c: number | bigint }>( + "SELECT tags, COUNT(*) c FROM memories WHERE tags IS NOT NULL AND tags != '' GROUP BY tags HAVING COUNT(*) > 1" + ); + stats.duplicateTagCount = duplicateTags.reduce( + (total, row) => total + Math.max(0, Number(row.c) - 1), + 0 + ); + + userCount += shardUserCount; + projectCount += shardProjectCount; + for (const [type, count] of Object.entries(shardTypeCount)) { + typeCount[type] = (typeCount[type] || 0) + count; + } + } catch (error) { + stats.memoryCount = 0; + stats.oldestCreatedAt = null; + stats.newestCreatedAt = null; + stats.duplicateTagCount = 0; + stats.error = error instanceof Error ? error.message : String(error); + } + + shardStats.push(stats); + } + + return { + shards: shardStats, + total: userCount + projectCount, + byScope: { user: userCount, project: projectCount }, + byType: typeCount, + }; +} + +export interface ProfileStats { + exists: boolean; + version?: number; + counts?: { preferences: number; patterns: number; workflows: number }; + lastAnalyzedAt?: string; + totalPromptsAnalyzed?: number; + error?: string; +} + +/** Read the active user's compact profile summary without returning profile contents. */ +export async function collectProfileStats(): Promise { + const profileDbPath = join(CONFIG.storagePath || "", "user-profiles.db"); + if (!existsSync(profileDbPath)) return { exists: false }; + + let profile: UserProfile | null; + try { + const { userProfileManager } = await import("./user-profile/user-profile-manager.js"); + const { getTags } = await import("./tags.js"); + const targetUserId = getTags(process.cwd()).user.userEmail || "unknown"; + profile = await userProfileManager.getActiveProfile(targetUserId); + } catch (error) { + return { + exists: false, + error: error instanceof Error ? error.message : String(error), + }; + } + if (!profile) return { exists: false }; + + try { + const data = JSON.parse(profile.profileData) as Record; + const arrayCount = (value: unknown) => (Array.isArray(value) ? value.length : 0); + const lastAnalyzedAt = toISOStringOrNull(profile.lastAnalyzedAt); + return { + exists: true, + version: Number(profile.version), + counts: { + preferences: arrayCount(data.preferences), + patterns: arrayCount(data.patterns), + workflows: arrayCount(data.workflows), + }, + ...(lastAnalyzedAt ? { lastAnalyzedAt } : {}), + totalPromptsAnalyzed: Number(profile.totalPromptsAnalyzed), + }; + } catch (error) { + return { + exists: true, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/services/web-server.ts b/src/services/web-server.ts index 02b145a7..7e175aa2 100644 --- a/src/services/web-server.ts +++ b/src/services/web-server.ts @@ -423,7 +423,10 @@ export class WebServer { const headers = this.config.apiToken ? { Authorization: `Bearer ${this.config.apiToken}` } : undefined; - const endpoint = this.config.apiToken ? "/api/stats" : "/api/health"; + // With an API token, probe authenticated /api/stats?probe=1 (cheap) so we + // prove ownership without walking shards or scanning logs. Health stays + // unauthenticated and is only safe when no token is configured. + const endpoint = this.config.apiToken ? "/api/stats?probe=1" : "/api/health"; const response = await fetch(`${this.getUrl()}${endpoint}`, { method: "GET", headers, @@ -571,7 +574,8 @@ export class WebServer { } if (path === "/api/stats" && method === "GET") { - const result = await handleStats(); + const probe = url.searchParams.get("probe") === "1"; + const result = await handleStats({ probe }); return this.jsonResponse(result); } diff --git a/tests/stats-log-parsing.test.ts b/tests/stats-log-parsing.test.ts new file mode 100644 index 00000000..3a3023b5 --- /dev/null +++ b/tests/stats-log-parsing.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test"; +import { classifyLogMessage, parseLogLine } from "../src/services/stats-service.js"; + +describe("stats log parsing", () => { + it("parses counter signatures", () => { + const fixtures = [ + ['Auto-capture memory persisted: {"id":"memory-1"}', "captures"], + ['Auto-capture skipped: {"reason":"short"}', "captureSkipped"], + ['profile inject: {"prefs":[]}', "profileInjections"], + ['Compaction memory injected: {"count":1}', "compactionInjections"], + ] as const; + + for (const [content, counter] of fixtures) { + const parsed = parseLogLine(`[2026-10-16T19:18:33.096+11:00] ${content}`); + expect(parsed).not.toBeNull(); + expect(classifyLogMessage(parsed!.message)).toEqual({ counter }); + } + }); + + it("classifies the known structured error signatures", () => { + const fixtures = [ + [ + `[2026-10-16T19:18:33.096+11:00] Turso ready gate failed: {"error":"Error: failed to open database /home/x/.opencode-mem/data/user-profiles.db: I/O error: short read on WAL frame at offset 32: expected 4096 bytes, got 0"}`, + "Turso ready gate failed", + ], + [ + `[2026-10-13T00:32:31.331+11:00] Failed to load skills for OpenCode registration: {"error":"TypeError: The database connection is not open"}`, + "Failed to load skills for OpenCode registration", + ], + [ + `[2026-10-16T19:18:46.118+11:00] listMemories: error: {"error":"failed to open database ... short read on WAL frame"}`, + "listMemories", + ], + [ + `[2026-10-16T19:20:00.000+11:00] Auto-capture final error after 3 attempts: {"error":"timeout"}`, + "Auto-capture final error", + ], + [ + `[2026-10-16T19:21:00.000+11:00] Compaction handler error: {"error":"boom"}`, + "Compaction handler error", + ], + [ + `[2026-10-16T19:22:00.000+11:00] Idle processing error: {"error":"boom"}`, + "Idle processing error", + ], + [`[2026-10-16T19:23:00.000+11:00] chat.message: ERROR: {"error":"boom"}`, "chat.message"], + ] as const; + + for (const [line, errorBucket] of fixtures) { + const parsed = parseLogLine(line); + expect(parsed).not.toBeNull(); + expect(classifyLogMessage(parsed!.message)).toEqual({ errorBucket }); + } + }); + + it("ignores separators and malformed lines and splits payload at the structured delimiter", () => { + expect(parseLogLine("\n--- Session started: 2026-10-18T12:00:00.000+00:00 ---")).toBeNull(); + expect(parseLogLine("not a timestamp: Auto-capture memory persisted")).toBeNull(); + expect(parseLogLine("[2026-99-99T19:18:33.096+11:00] Auto-capture skipped")).toBeNull(); + + const parsed = parseLogLine( + `[2026-10-16T19:18:33.096+11:00] request: error: {"detail":"url: https://example.test/a"}` + ); + expect(parsed).toEqual({ + date: "2026-10-16", + message: "request: error", + payload: `{"detail":"url: https://example.test/a"}`, + }); + expect(classifyLogMessage(parsed!.message)).toEqual({ errorBucket: "request" }); + }); +}); diff --git a/tests/stats-service.test.ts b/tests/stats-service.test.ts new file mode 100644 index 00000000..fac317ab --- /dev/null +++ b/tests/stats-service.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cleanupTursoTestDirectory } from "./turso-test-utils.js"; + +describe("handleStats health data", () => { + let baseDir: string | undefined; + let logDir: string | undefined; + + afterEach(async () => { + await cleanupTursoTestDirectory(baseDir); + if (logDir) rmSync(logDir, { recursive: true, force: true }); + baseDir = undefined; + logDir = undefined; + }); + + it("aggregates shards and returns zero-filled daily log counters", async () => { + baseDir = mkdtempSync(join(tmpdir(), "turso-stats-db-")); + logDir = mkdtempSync(join(tmpdir(), "turso-stats-logs-")); + + const { CONFIG } = await import("../src/config.js"); + CONFIG.storagePath = baseDir; + CONFIG.embeddingDimensions = 768; + + const { closeTursoAndInvalidateCaches } = await import("../src/services/turso/lifecycle.js"); + await closeTursoAndInvalidateCaches(); + + const { tursoShardManager } = await import("../src/services/turso/shard-manager.js"); + const { tursoConnectionManager } = await import("../src/services/turso/connection-manager.js"); + const { tursoVectorSearch } = await import("../src/services/turso/vector-search.js"); + + const vector = new Float32Array(768); + vector[0] = 1; + const userHash = "aaaaaaaaaaaaaaaa"; + const projectHash = "bbbbbbbbbbbbbbbb"; + const userShard = await tursoShardManager.createShard("user", userHash, 0); + const projectShard = await tursoShardManager.createShard("project", projectHash, 0); + const userDb = await tursoConnectionManager.getConnection(userShard.dbPath); + const projectDb = await tursoConnectionManager.getConnection(projectShard.dbPath); + const firstCreatedAt = Date.UTC(2026, 9, 12); + const newestCreatedAt = Date.UTC(2026, 9, 16); + + const memory = (id: string, createdAt: number, containerTag: string, tags: string) => ({ + id, + content: `memory ${id}`, + vector, + containerTag, + tags, + type: "context", + createdAt, + updatedAt: createdAt, + }); + await tursoVectorSearch.insertVector( + userDb, + memory("user-1", firstCreatedAt, `opencode_user_${userHash}`, "shared-tag") + ); + await tursoVectorSearch.insertVector( + userDb, + memory("user-2", newestCreatedAt, `opencode_user_${userHash}`, "shared-tag") + ); + await tursoVectorSearch.insertVector( + projectDb, + memory("project-1", newestCreatedAt, `opencode_project_${projectHash}`, "project-tag") + ); + + const now = new Date(2026, 9, 18, 12, 0, 0); + const day = (offset: number) => { + const date = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + date.setDate(date.getDate() - offset); + const pad = (value: number) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + }; + const timestamp = (date: string, hour: string) => `[${date}T${hour}+00:00]`; + writeFileSync( + join(logDir, "opencode-mem-2026-10-01.log"), + [ + `${timestamp(day(1), "10:00:00.000")} Auto-capture memory persisted: {"id":"1"}`, + `${timestamp(day(1), "10:01:00.000")} Auto-capture skipped: {"reason":"short"}`, + `${timestamp(day(1), "10:02:00.000")} listMemories: error: {"error":"fixture"}`, + ].join("\n") + ); + writeFileSync( + join(logDir, "opencode-mem.log.old"), + `${timestamp(day(3), "11:00:00.000")} Compaction memory injected: {"count":1}\n` + ); + writeFileSync( + join(logDir, "opencode-mem.log"), + `${timestamp(day(4), "09:00:00.000")} profile inject: {"prefs":[]}\n` + ); + + const { handleStats } = await import("../src/services/api-handlers.js"); + const result = await handleStats({ logDir, now }); + expect(result.success).toBe(true); + expect(result.data).toBeDefined(); + expect(result.data?.total).toBe(3); + expect(result.data?.byScope).toEqual({ user: 2, project: 1 }); + expect(result.data?.byType).toEqual({ context: 3 }); + expect(result.data?.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(result.data?.profile).toEqual({ exists: false }); + + const userStats = result.data?.shards.find((shard) => shard.scope === "user"); + const projectStats = result.data?.shards.find((shard) => shard.scope === "project"); + expect(result.data?.shards).toHaveLength(2); + expect(userStats).toMatchObject({ + scope: "user", + fileName: "user_aaaaaaaaaaaaaaaa_shard_0.db", + fileExists: true, + registeredVectorCount: 2, + memoryCount: 2, + oldestCreatedAt: new Date(firstCreatedAt).toISOString(), + newestCreatedAt: new Date(newestCreatedAt).toISOString(), + duplicateTagCount: 1, + }); + expect(projectStats).toMatchObject({ + scope: "project", + fileExists: true, + registeredVectorCount: 1, + memoryCount: 1, + oldestCreatedAt: new Date(newestCreatedAt).toISOString(), + newestCreatedAt: new Date(newestCreatedAt).toISOString(), + duplicateTagCount: 0, + }); + + const daily = result.data?.daily; + expect(daily).toHaveLength(14); + expect(daily?.map((item) => item.date)).toEqual( + Array.from({ length: 14 }, (_, i) => day(13 - i)) + ); + expect(daily?.[0]).toMatchObject({ + captures: 0, + captureSkipped: 0, + profileInjections: 0, + compactionInjections: 0, + errors: {}, + }); + expect(daily?.find((item) => item.date === day(1))).toMatchObject({ + captures: 1, + captureSkipped: 1, + errors: { listMemories: 1 }, + }); + expect(daily?.find((item) => item.date === day(3))?.compactionInjections).toBe(1); + expect(daily?.find((item) => item.date === day(4))?.profileInjections).toBe(1); + expect(result.data?.dailyError).toBeUndefined(); + // Windows CI needs >5s for two shard creations, three vector inserts, and + // the full stats walk; the default 5s per-test timeout races afterEach + // cleanup against the in-flight walk. Same budget as plugin-bundle-boundary. + }, 30_000); + + it("probe mode returns success without opening shards or scanning logs", async () => { + const { handleStats } = await import("../src/services/api-handlers.js"); + const result = await handleStats({ probe: true }); + expect(result).toEqual({ success: true }); + expect(result.data).toBeUndefined(); + }); + + it("excludes a shard from totals when a later query fails mid-walk", async () => { + const shardDir = mkdtempSync(join(tmpdir(), "turso-stats-partial-")); + const dbPath = join(shardDir, "user_aaaaaaaaaaaaaaaa_shard_0.db"); + writeFileSync(dbPath, ""); + + const { collectShardStats } = await import("../src/services/stats-service.js"); + const { tursoConnectionManager } = await import("../src/services/turso/connection-manager.js"); + const original = tursoConnectionManager.getConnection.bind(tursoConnectionManager); + + tursoConnectionManager.getConnection = (async () => ({ + all: async (sql: string) => { + if (sql.includes("GROUP BY container_tag")) { + return [{ container_tag: "opencode_user_aaaaaaaaaaaaaaaa", type: "context", c: 4 }]; + } + throw new Error("duplicate query failed"); + }, + get: async () => ({ c: 4, mn: Date.UTC(2026, 9, 1), mx: Date.UTC(2026, 9, 10) }), + })) as typeof tursoConnectionManager.getConnection; + + try { + const result = await collectShardStats([ + { + id: 1, + scope: "user", + scopeHash: "aaaaaaaaaaaaaaaa", + shardIndex: 0, + dbPath, + vectorCount: 4, + isActive: true, + } as never, + ]); + + expect(result.total).toBe(0); + expect(result.byScope).toEqual({ user: 0, project: 0 }); + expect(result.byType).toEqual({}); + expect(result.shards).toHaveLength(1); + expect(result.shards[0]).toMatchObject({ + memoryCount: 0, + duplicateTagCount: 0, + error: "duplicate query failed", + }); + } finally { + tursoConnectionManager.getConnection = original; + rmSync(shardDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/web-server-health.test.ts b/tests/web-server-health.test.ts index f26300b4..a79d2215 100644 --- a/tests/web-server-health.test.ts +++ b/tests/web-server-health.test.ts @@ -3,10 +3,12 @@ import { createServer } from "node:http"; import { WebServer, nextFallbackPort } from "../src/services/web-server.js"; describe("web server health check", () => { - it("authenticates the stats request when an API token is configured", async () => { + it("authenticates the stats probe when an API token is configured", async () => { const originalFetch = globalThis.fetch; + let requestUrl: string | undefined; let requestHeaders: Headers | undefined; - globalThis.fetch = async (_input, init) => { + globalThis.fetch = async (input, init) => { + requestUrl = String(input); requestHeaders = new Headers(init?.headers); return new Response(JSON.stringify({ success: true }), { status: 200 }); }; @@ -20,6 +22,7 @@ describe("web server health check", () => { }); expect(await server.checkServerAvailable()).toBe(true); + expect(requestUrl).toBe("http://0.0.0.0:4747/api/stats?probe=1"); expect(requestHeaders?.get("Authorization")).toBe("Bearer health-token"); } finally { globalThis.fetch = originalFetch; diff --git a/web/sim/stats.json b/web/sim/stats.json index 7d8ec691..a31bc88d 100644 --- a/web/sim/stats.json +++ b/web/sim/stats.json @@ -1,6 +1,196 @@ { "success": true, "data": { - "total": 12 + "total": 14, + "byScope": { "user": 5, "project": 9 }, + "byType": { "context": 8, "fact": 3, "preference": 3 }, + "generatedAt": "2026-10-18T09:41:12.000Z", + "shards": [ + { + "shardId": 41, + "scope": "project", + "scopeHash": "b1c2d3e4f5a6b7c8", + "shardIndex": 0, + "fileName": "project_b1c2d3e4f5a6b7c8_shard_0.db", + "fileExists": true, + "isActive": true, + "registeredVectorCount": 8, + "memoryCount": 7, + "oldestCreatedAt": "2026-05-02T08:15:00.000Z", + "newestCreatedAt": "2026-10-18T07:02:44.000Z", + "duplicateTagCount": 2 + }, + { + "shardId": 42, + "scope": "project", + "scopeHash": "b1c2d3e4f5a6b7c8", + "shardIndex": 1, + "fileName": "project_b1c2d3e4f5a6b7c8_shard_1.db", + "fileExists": true, + "isActive": false, + "registeredVectorCount": 2, + "memoryCount": 2, + "oldestCreatedAt": "2026-06-11T19:30:00.000Z", + "newestCreatedAt": "2026-08-29T14:11:09.000Z", + "duplicateTagCount": 0 + }, + { + "shardId": 7, + "scope": "user", + "scopeHash": "a9f8e7d6c5b4a3f2", + "shardIndex": 0, + "fileName": "user_a9f8e7d6c5b4a3f2_shard_0.db", + "fileExists": true, + "isActive": true, + "registeredVectorCount": 5, + "memoryCount": 5, + "oldestCreatedAt": "2026-04-18T11:00:00.000Z", + "newestCreatedAt": "2026-10-18T05:47:21.000Z", + "duplicateTagCount": 1 + }, + { + "shardId": 55, + "scope": "project", + "scopeHash": "0d1e2f3a4b5c6d7e", + "shardIndex": 0, + "fileName": "project_0d1e2f3a4b5c6d7e_shard_0.db", + "fileExists": false, + "isActive": true, + "registeredVectorCount": 3, + "memoryCount": 0, + "oldestCreatedAt": null, + "newestCreatedAt": null, + "duplicateTagCount": 0, + "error": "Shard DB file missing: project_0d1e2f3a4b5c6d7e_shard_0.db" + } + ], + "profile": { + "exists": true, + "version": 23, + "counts": { "preferences": 9, "patterns": 14, "workflows": 3 }, + "lastAnalyzedAt": "2026-10-17T22:10:05.000Z", + "totalPromptsAnalyzed": 1290 + }, + "daily": [ + { + "date": "2026-10-05", + "captures": 9, + "captureSkipped": 2, + "profileInjections": 12, + "compactionInjections": 1, + "errors": {} + }, + { + "date": "2026-10-06", + "captures": 14, + "captureSkipped": 3, + "profileInjections": 18, + "compactionInjections": 0, + "errors": {} + }, + { + "date": "2026-10-07", + "captures": 11, + "captureSkipped": 1, + "profileInjections": 15, + "compactionInjections": 2, + "errors": {} + }, + { + "date": "2026-10-08", + "captures": 16, + "captureSkipped": 4, + "profileInjections": 21, + "compactionInjections": 0, + "errors": { "listMemories": 2 } + }, + { + "date": "2026-10-09", + "captures": 8, + "captureSkipped": 2, + "profileInjections": 10, + "compactionInjections": 1, + "errors": {} + }, + { + "date": "2026-10-10", + "captures": 12, + "captureSkipped": 0, + "profileInjections": 16, + "compactionInjections": 0, + "errors": {} + }, + { + "date": "2026-10-11", + "captures": 10, + "captureSkipped": 5, + "profileInjections": 13, + "compactionInjections": 3, + "errors": {} + }, + { + "date": "2026-10-12", + "captures": 15, + "captureSkipped": 2, + "profileInjections": 19, + "compactionInjections": 1, + "errors": {} + }, + { + "date": "2026-10-13", + "captures": 6, + "captureSkipped": 9, + "profileInjections": 8, + "compactionInjections": 0, + "errors": { + "Failed to load skills for OpenCode registration": 61, + "Turso ready gate failed": 2 + } + }, + { + "date": "2026-10-14", + "captures": 4, + "captureSkipped": 11, + "profileInjections": 7, + "compactionInjections": 0, + "errors": { "Failed to load skills for OpenCode registration": 74 } + }, + { + "date": "2026-10-15", + "captures": 7, + "captureSkipped": 8, + "profileInjections": 9, + "compactionInjections": 1, + "errors": { "Failed to load skills for OpenCode registration": 58 } + }, + { + "date": "2026-10-16", + "captures": 5, + "captureSkipped": 10, + "profileInjections": 6, + "compactionInjections": 0, + "errors": { + "Failed to load skills for OpenCode registration": 49, + "Turso ready gate failed": 12, + "listMemories": 9 + } + }, + { + "date": "2026-10-17", + "captures": 13, + "captureSkipped": 3, + "profileInjections": 17, + "compactionInjections": 2, + "errors": { "listMemories": 1 } + }, + { + "date": "2026-10-18", + "captures": 10, + "captureSkipped": 1, + "profileInjections": 11, + "compactionInjections": 0, + "errors": {} + } + ] } } diff --git a/web/src/App.svelte b/web/src/App.svelte index 65d02770..93b85703 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -15,6 +15,7 @@ import ProfileView from "$lib/components/explorer/ProfileView.svelte"; import SkillDialog from "$lib/components/explorer/SkillDialog.svelte"; import SkillsView from "$lib/components/explorer/SkillsView.svelte"; + import StatsView from "$lib/components/explorer/StatsView.svelte"; import TagMigrationDialog from "$lib/components/explorer/TagMigrationDialog.svelte"; import TagFilter from "$lib/components/explorer/TagFilter.svelte"; import Alert from "$lib/components/ui/alert.svelte"; @@ -31,6 +32,7 @@ import { createRouter } from "$lib/router.svelte"; import { createMemoriesExplorer } from "$lib/stores/memories-explorer.svelte"; import { createSkillsStore } from "$lib/stores/skills.svelte"; + import { createStatsStore } from "$lib/stores/stats.svelte"; import { createUserProfile } from "$lib/stores/user-profile.svelte"; import { GAP, @@ -60,6 +62,7 @@ const explorer = createMemoriesExplorer(); const profile = createUserProfile(); const skills = createSkillsStore(); + const stats = createStatsStore(); let sidebarOpen = $state(false); let sidebarCollapsed = $state(readCollapsed()); @@ -71,7 +74,9 @@ ? i18n.t("tab-project") : router.view === "skills" ? i18n.t("tab-skills") - : i18n.t("tab-profile") + : router.view === "stats" + ? i18n.t("tab-stats") + : i18n.t("tab-profile") ); $effect(() => { @@ -119,6 +124,9 @@ if (router.view === "skills") { void skills.loadSkills(); } + if (router.view === "stats") { + void stats.loadStats(); + } }); function onLangToggle() { @@ -149,6 +157,7 @@ projectLabel={i18n.t("tab-project")} profileLabel={i18n.t("tab-profile")} skillsLabel={i18n.t("tab-skills")} + statsLabel={i18n.t("tab-stats")} {langLabel} languageLabel={i18n.t("nav-language")} themeLabel={i18n.t("nav-theme")} @@ -386,6 +395,13 @@ onEdit={skills.openEdit} onDelete={skills.deleteSkill} /> + {:else if router.view === "stats"} + {:else} + import Activity from "@lucide/svelte/icons/activity"; import Brain from "@lucide/svelte/icons/brain"; import Folder from "@lucide/svelte/icons/folder"; import Languages from "@lucide/svelte/icons/languages"; @@ -32,6 +33,7 @@ projectLabel: string; profileLabel: string; skillsLabel: string; + statsLabel: string; langLabel: string; languageLabel: string; themeLabel: string; @@ -48,6 +50,7 @@ projectLabel, profileLabel, skillsLabel, + statsLabel, langLabel, languageLabel, themeLabel, @@ -179,6 +182,19 @@ {skillsLabel} {/if} + onNavClick(e, ROUTES.stats)} + > +
diff --git a/web/src/lib/components/explorer/StatsView.svelte b/web/src/lib/components/explorer/StatsView.svelte new file mode 100644 index 00000000..6dd044bd --- /dev/null +++ b/web/src/lib/components/explorer/StatsView.svelte @@ -0,0 +1,328 @@ + + +
+
+

{i18n.t("stats-description")}

+ +
+ + {#if error !== null} + + + {/if} + + {#if loading && !data} + {i18n.t("stats-loading")} + {:else if data} +
+
+

{i18n.t("stats-total-memories")}

+

{data.total}

+

+ {i18n.t("stats-scope-user")}: {data.byScope?.user ?? "—"} · {i18n.t( + "stats-scope-project" + )}: {data.byScope?.project ?? "—"} +

+
+
+

{i18n.t("stats-active-shards")}

+

+ {data.shards ? shards.filter((shard) => shard.isActive).length : "—"} +

+

+ {data.shards + ? i18n.t("text-total", { count: shards.length }) + : i18n.t("stats-unavailable")} +

+
+
+

{i18n.t("tab-profile")}

+ {#if data.profile?.error || !data.profile} +

{i18n.t("stats-unavailable")}

+ {#if data.profile?.error}

+ {data.profile.error} +

{/if} + {:else if data.profile.exists} +

+ {i18n.t("profile-version")} + {data.profile.version ?? "—"} +

+

+ {i18n.t("stats-last-analyzed")}: {date(data.profile.lastAnalyzedAt)} +

+ {#if data.profile.counts} +
+
{i18n.t("profile-preferences")}
+
{data.profile.counts.preferences}
+
{i18n.t("profile-patterns")}
+
{data.profile.counts.patterns}
+
{i18n.t("profile-workflows")}
+
{data.profile.counts.workflows}
+
+ {/if} + {#if data.profile.totalPromptsAnalyzed !== undefined} +

+ {i18n.t("profile-prompts")}: {data.profile.totalPromptsAnalyzed} +

+ {/if} + {:else} +

{i18n.t("stats-no-profile")}

+ {/if} +
+
+

{i18n.t("stats-generated")}

+

+ +

+

{i18n.t("stats-snapshot-note")}

+
+
+ +
+

+ {i18n.t("stats-daily")} +

+ {#if data.dailyError} + + + {:else if !data.daily} + {i18n.t("stats-unavailable")} + {:else if daily.length === 0} + {i18n.t("stats-no-activity")} + {:else} +
+ + + + + {#each metrics as metric}{/each} + + + + + {#each daily as day (day.date)} + {@const buckets = errorBuckets(day)} + + + {#each metrics as metric} + + {/each} + + + {/each} + +
{i18n.t("stats-date")}{i18n.t(metric.label)}{i18n.t("stats-errors")}
+
+ {day[metric.field]} + +
+
+ {#if buckets.length} +
+ {buckets.reduce((sum, [, count]) => sum + count, 0)} +
    + {#each buckets as [bucket, count]}
  • + {bucket} ×{count} +
  • {/each} +
+
+ {:else} + + {/if} +
+
+ {/if} +
+ +
+

+ {i18n.t("stats-shards")} +

+ {#if shards.length} +
+ + + + + + + + + + + + {#each shards as shard} + + + + + + + + + + {/each} + +
{i18n.t("stats-scope")}{i18n.t("stats-file")}{i18n.t("stats-index")}{i18n.t("stats-status")}{i18n.t("stats-memory-vectors")}{i18n.t("stats-memory-dates")}{i18n.t("stats-duplicate-tags")}
+

+ {i18n.t(shard.scope === "user" ? "stats-scope-user" : "stats-scope-project")} +

+ {shard.scopeHash.slice(0, 8) || "—"} +
+ {shard.fileName} +

+ {i18n.t(shard.fileExists ? "stats-file-present" : "stats-file-missing")} +

+ {#if shard.error}

{shard.error}

{/if} +
{shard.shardIndex}{i18n.t(shard.isActive ? "stats-active" : "stats-inactive")} +

+ {shard.memoryCount} / {shard.registeredVectorCount} +

+ {#if shard.memoryCount !== shard.registeredVectorCount} +

+ {i18n.t("stats-count-mismatch")} +

+ {/if} +
+

{i18n.t("stats-oldest")}: {date(shard.oldestCreatedAt)}

+

{i18n.t("stats-newest")}: {date(shard.newestCreatedAt)}

+
0 + ? "font-medium text-warning" + : "text-muted-foreground"}>{shard.duplicateTagCount}
+
+ {:else} + {i18n.t(data.shards ? "stats-no-shards" : "stats-unavailable")} + {/if} +
+ {/if} +
+ + diff --git a/web/src/lib/i18n/translations.ts b/web/src/lib/i18n/translations.ts index c248df65..e7bbbb11 100644 --- a/web/src/lib/i18n/translations.ts +++ b/web/src/lib/i18n/translations.ts @@ -1,5 +1,45 @@ export const translations = { en: { + "tab-stats": "Stats", + "stats-description": "Memory health and recent activity.", + "stats-overview": "Overview", + "stats-total-memories": "Total memories", + "stats-active-shards": "Active shards", + "stats-scope-user": "User", + "stats-scope-project": "Project", + "stats-last-analyzed": "Last analyzed", + "stats-no-profile": "No profile yet", + "stats-generated": "Last generated", + "stats-snapshot-note": "Refresh to get the latest snapshot.", + "stats-loading": "Loading stats…", + "stats-load-failed": "Could not load stats. Try refreshing.", + "stats-stale": "Showing the last successful snapshot.", + "stats-unavailable": "Data unavailable", + "stats-daily": "Daily activity · last 14 days", + "stats-no-activity": "No daily activity recorded yet.", + "stats-date": "Date", + "stats-captures": "Captures", + "stats-skipped": "Skipped captures", + "stats-profile-injections": "Profile injections", + "stats-compaction-injections": "Compaction injections", + "stats-errors": "Errors", + "stats-no-errors": "No errors", + "stats-shards": "Storage shards", + "stats-no-shards": "No storage shards yet.", + "stats-scope": "Scope / hash", + "stats-file": "File", + "stats-index": "Index", + "stats-status": "Status", + "stats-memory-vectors": "Memories / registered vectors", + "stats-memory-dates": "Memory dates", + "stats-duplicate-tags": "Duplicate tags", + "stats-file-present": "File present", + "stats-file-missing": "File missing", + "stats-active": "Active", + "stats-inactive": "Inactive", + "stats-count-mismatch": "Counts differ", + "stats-oldest": "Oldest", + "stats-newest": "Newest", title: "OpenCode Memory Explorer", brand: "opencode-mem", "tab-project": "Project Memories", @@ -195,6 +235,46 @@ export const translations = { "toast-skill-load-failed": "Failed to load skills", }, de: { + "tab-stats": "Statistiken", + "stats-description": "Zustand des Speichers und letzte Aktivitäten.", + "stats-overview": "Übersicht", + "stats-total-memories": "Erinnerungen insgesamt", + "stats-active-shards": "Aktive Shards", + "stats-scope-user": "Benutzer", + "stats-scope-project": "Projekt", + "stats-last-analyzed": "Zuletzt analysiert", + "stats-no-profile": "Noch kein Profil", + "stats-generated": "Zuletzt erstellt", + "stats-snapshot-note": "Aktualisieren, um den neuesten Stand abzurufen.", + "stats-loading": "Statistiken werden geladen…", + "stats-load-failed": "Statistiken konnten nicht geladen werden. Bitte aktualisieren.", + "stats-stale": "Der letzte erfolgreich geladene Stand wird angezeigt.", + "stats-unavailable": "Daten nicht verfügbar", + "stats-daily": "Tägliche Aktivität · letzte 14 Tage", + "stats-no-activity": "Noch keine tägliche Aktivität erfasst.", + "stats-date": "Datum", + "stats-captures": "Erfassungen", + "stats-skipped": "Übersprungene Erfassungen", + "stats-profile-injections": "Profileinfügungen", + "stats-compaction-injections": "Einfügungen bei Komprimierung", + "stats-errors": "Fehler", + "stats-no-errors": "Keine Fehler", + "stats-shards": "Speicher-Shards", + "stats-no-shards": "Noch keine Speicher-Shards vorhanden.", + "stats-scope": "Bereich / Hash", + "stats-file": "Datei", + "stats-index": "Index", + "stats-status": "Status", + "stats-memory-vectors": "Erinnerungen / registrierte Vektoren", + "stats-memory-dates": "Erinnerungsdaten", + "stats-duplicate-tags": "Doppelte Tags", + "stats-file-present": "Datei vorhanden", + "stats-file-missing": "Datei fehlt", + "stats-active": "Aktiv", + "stats-inactive": "Inaktiv", + "stats-count-mismatch": "Anzahlen weichen ab", + "stats-oldest": "Älteste", + "stats-newest": "Neueste", title: "OpenCode Memory Explorer", brand: "opencode-mem", "tab-project": "Projekt-Memories", @@ -394,6 +474,46 @@ export const translations = { "toast-skill-load-failed": "Skills konnten nicht geladen werden", }, zh: { + "tab-stats": "统计", + "stats-description": "记忆系统健康状况与近期活动。", + "stats-overview": "概览", + "stats-total-memories": "记忆总数", + "stats-active-shards": "活跃分片", + "stats-scope-user": "用户", + "stats-scope-project": "项目", + "stats-last-analyzed": "上次分析", + "stats-no-profile": "暂无用户画像", + "stats-generated": "上次生成", + "stats-snapshot-note": "刷新以获取最新数据。", + "stats-loading": "正在加载统计数据…", + "stats-load-failed": "无法加载统计数据,请尝试刷新。", + "stats-stale": "当前显示上次成功获取的数据。", + "stats-unavailable": "数据不可用", + "stats-daily": "每日活动 · 最近 14 天", + "stats-no-activity": "暂无每日活动记录。", + "stats-date": "日期", + "stats-captures": "捕获次数", + "stats-skipped": "跳过捕获", + "stats-profile-injections": "画像注入", + "stats-compaction-injections": "上下文压缩注入", + "stats-errors": "错误", + "stats-no-errors": "无错误", + "stats-shards": "存储分片", + "stats-no-shards": "暂无存储分片。", + "stats-scope": "范围 / 哈希", + "stats-file": "文件", + "stats-index": "索引", + "stats-status": "状态", + "stats-memory-vectors": "记忆数 / 已注册向量数", + "stats-memory-dates": "记忆日期", + "stats-duplicate-tags": "重复标签", + "stats-file-present": "文件存在", + "stats-file-missing": "文件缺失", + "stats-active": "活跃", + "stats-inactive": "非活跃", + "stats-count-mismatch": "数量不一致", + "stats-oldest": "最早", + "stats-newest": "最新", title: "OpenCode Memory Explorer", brand: "opencode-mem", "tab-project": "项目记忆", @@ -587,6 +707,46 @@ export const translations = { "toast-skill-load-failed": "加载技能失败", }, ar: { + "tab-stats": "الإحصائيات", + "stats-description": "حالة نظام الذاكرة والنشاط الأخير.", + "stats-overview": "نظرة عامة", + "stats-total-memories": "إجمالي الذكريات", + "stats-active-shards": "أجزاء التخزين النشطة", + "stats-scope-user": "المستخدم", + "stats-scope-project": "المشروع", + "stats-last-analyzed": "آخر تحليل", + "stats-no-profile": "لا يوجد ملف شخصي بعد", + "stats-generated": "آخر إنشاء للإحصائيات", + "stats-snapshot-note": "حدّث للحصول على أحدث البيانات.", + "stats-loading": "جارٍ تحميل الإحصائيات…", + "stats-load-failed": "تعذر تحميل الإحصائيات. حاول التحديث.", + "stats-stale": "تُعرض آخر بيانات تم تحميلها بنجاح.", + "stats-unavailable": "البيانات غير متاحة", + "stats-daily": "النشاط اليومي · آخر 14 يومًا", + "stats-no-activity": "لم يُسجَّل أي نشاط يومي بعد.", + "stats-date": "التاريخ", + "stats-captures": "عمليات الالتقاط", + "stats-skipped": "عمليات الالتقاط المتخطاة", + "stats-profile-injections": "إدراجات الملف الشخصي", + "stats-compaction-injections": "إدراجات ضغط السياق", + "stats-errors": "الأخطاء", + "stats-no-errors": "لا توجد أخطاء", + "stats-shards": "أجزاء التخزين", + "stats-no-shards": "لا توجد أجزاء تخزين بعد.", + "stats-scope": "النطاق / البصمة", + "stats-file": "الملف", + "stats-index": "الفهرس", + "stats-status": "الحالة", + "stats-memory-vectors": "الذكريات / المتجهات المسجلة", + "stats-memory-dates": "تواريخ الذكريات", + "stats-duplicate-tags": "الوسوم المكررة", + "stats-file-present": "الملف موجود", + "stats-file-missing": "الملف مفقود", + "stats-active": "نشط", + "stats-inactive": "غير نشط", + "stats-count-mismatch": "الأعداد غير متطابقة", + "stats-oldest": "الأقدم", + "stats-newest": "الأحدث", title: "مستكشف ذاكرة OpenCode", brand: "opencode-mem", "tab-project": "ذكريات المشروع", @@ -821,6 +981,46 @@ export const translations = { "toast-skill-load-failed": "فشل تحميل المهارات", }, tr: { + "tab-stats": "İstatistikler", + "stats-description": "Bellek sisteminin durumu ve son etkinlikler.", + "stats-overview": "Genel bakış", + "stats-total-memories": "Toplam anı", + "stats-active-shards": "Etkin depolama parçaları", + "stats-scope-user": "Kullanıcı", + "stats-scope-project": "Proje", + "stats-last-analyzed": "Son analiz", + "stats-no-profile": "Henüz profil yok", + "stats-generated": "Son oluşturulma", + "stats-snapshot-note": "En güncel verileri almak için yenileyin.", + "stats-loading": "İstatistikler yükleniyor…", + "stats-load-failed": "İstatistikler yüklenemedi. Yenilemeyi deneyin.", + "stats-stale": "Son başarıyla alınan veriler gösteriliyor.", + "stats-unavailable": "Veriler kullanılamıyor", + "stats-daily": "Günlük etkinlik · son 14 gün", + "stats-no-activity": "Henüz günlük etkinlik kaydedilmedi.", + "stats-date": "Tarih", + "stats-captures": "Yakalamalar", + "stats-skipped": "Atlanan yakalamalar", + "stats-profile-injections": "Profil eklemeleri", + "stats-compaction-injections": "Bağlam sıkıştırma eklemeleri", + "stats-errors": "Hatalar", + "stats-no-errors": "Hata yok", + "stats-shards": "Depolama parçaları", + "stats-no-shards": "Henüz depolama parçası yok.", + "stats-scope": "Kapsam / karma", + "stats-file": "Dosya", + "stats-index": "Dizin", + "stats-status": "Durum", + "stats-memory-vectors": "Anılar / kayıtlı vektörler", + "stats-memory-dates": "Anı tarihleri", + "stats-duplicate-tags": "Yinelenen etiketler", + "stats-file-present": "Dosya mevcut", + "stats-file-missing": "Dosya eksik", + "stats-active": "Etkin", + "stats-inactive": "Etkin değil", + "stats-count-mismatch": "Sayılar farklı", + "stats-oldest": "En eski", + "stats-newest": "En yeni", title: "OpenCode Hafıza Gezgini", brand: "opencode-mem", "tab-project": "Proje Hafızası", @@ -1018,6 +1218,46 @@ export const translations = { "toast-skill-load-failed": "Beceriler yüklenemedi", }, nl: { + "tab-stats": "Statistieken", + "stats-description": "Status van het geheugensysteem en recente activiteit.", + "stats-overview": "Overzicht", + "stats-total-memories": "Totaal aantal herinneringen", + "stats-active-shards": "Actieve opslagdelen", + "stats-scope-user": "Gebruiker", + "stats-scope-project": "Project", + "stats-last-analyzed": "Laatst geanalyseerd", + "stats-no-profile": "Nog geen profiel", + "stats-generated": "Laatst gegenereerd", + "stats-snapshot-note": "Vernieuw om de nieuwste gegevens op te halen.", + "stats-loading": "Statistieken laden…", + "stats-load-failed": "Statistieken konden niet worden geladen. Probeer te vernieuwen.", + "stats-stale": "De laatst succesvol opgehaalde gegevens worden getoond.", + "stats-unavailable": "Gegevens niet beschikbaar", + "stats-daily": "Dagelijkse activiteit · afgelopen 14 dagen", + "stats-no-activity": "Nog geen dagelijkse activiteit vastgelegd.", + "stats-date": "Datum", + "stats-captures": "Vastleggingen", + "stats-skipped": "Overgeslagen vastleggingen", + "stats-profile-injections": "Profielinvoegingen", + "stats-compaction-injections": "Invoegingen bij contextcompressie", + "stats-errors": "Fouten", + "stats-no-errors": "Geen fouten", + "stats-shards": "Opslagdelen", + "stats-no-shards": "Nog geen opslagdelen.", + "stats-scope": "Bereik / hash", + "stats-file": "Bestand", + "stats-index": "Index", + "stats-status": "Status", + "stats-memory-vectors": "Herinneringen / geregistreerde vectoren", + "stats-memory-dates": "Datums van herinneringen", + "stats-duplicate-tags": "Dubbele tags", + "stats-file-present": "Bestand aanwezig", + "stats-file-missing": "Bestand ontbreekt", + "stats-active": "Actief", + "stats-inactive": "Inactief", + "stats-count-mismatch": "Aantallen verschillen", + "stats-oldest": "Oudste", + "stats-newest": "Nieuwste", title: "OpenCode Memory Explorer", brand: "opencode-mem", "tab-project": "PROJECTGEHEUGENS", diff --git a/web/src/lib/routes.ts b/web/src/lib/routes.ts index 18a48987..3e7a9425 100644 --- a/web/src/lib/routes.ts +++ b/web/src/lib/routes.ts @@ -4,9 +4,10 @@ export const ROUTES = { profile: "/user-profile", // Keep off `/skills` — that prefix serves the OpenCode HTTP skill catalog. skills: "/skill-library", + stats: "/stats", } as const; -export type AppView = "project" | "profile" | "skills"; +export type AppView = "project" | "profile" | "skills" | "stats"; export function normalizePath(pathname: string): string { const path = pathname.replace(/\/+$/, "") || "/"; @@ -17,12 +18,14 @@ export function viewFromPath(pathname: string): AppView { const path = normalizePath(pathname); if (path === ROUTES.profile) return "profile"; if (path === ROUTES.skills) return "skills"; + if (path === ROUTES.stats) return "stats"; return "project"; } export function pathForView(view: AppView): string { if (view === "profile") return ROUTES.profile; if (view === "skills") return ROUTES.skills; + if (view === "stats") return ROUTES.stats; return ROUTES.project; } @@ -32,7 +35,8 @@ export function isAppPath(pathname: string): boolean { path === ROUTES.home || path === ROUTES.project || path === ROUTES.profile || - path === ROUTES.skills + path === ROUTES.skills || + path === ROUTES.stats ); } @@ -40,6 +44,12 @@ export function isAppPath(pathname: string): boolean { export function resolveAppPath(pathname: string): string { const path = normalizePath(pathname); if (path === ROUTES.home) return ROUTES.project; - if (path === ROUTES.project || path === ROUTES.profile || path === ROUTES.skills) return path; + if ( + path === ROUTES.project || + path === ROUTES.profile || + path === ROUTES.skills || + path === ROUTES.stats + ) + return path; return ROUTES.project; } diff --git a/web/src/lib/stores/stats.svelte.ts b/web/src/lib/stores/stats.svelte.ts new file mode 100644 index 00000000..76724977 --- /dev/null +++ b/web/src/lib/stores/stats.svelte.ts @@ -0,0 +1,77 @@ +import { fetchAPI } from "$lib/api"; + +export type StatsShard = { + shardId: number | null; + scope: "user" | "project"; + scopeHash: string; + shardIndex: number; + fileName: string; + fileExists: boolean; + isActive: boolean; + registeredVectorCount: number; + memoryCount: number; + oldestCreatedAt: string | null; + newestCreatedAt: string | null; + duplicateTagCount: number; + error?: string; +}; + +export type StatsDaily = { + date: string; + captures: number; + captureSkipped: number; + profileInjections: number; + compactionInjections: number; + errors: Record; +}; + +export type StatsData = { + total: number; + byScope: { user: number; project: number }; + byType: Record; + generatedAt: string; + // Older or partially available responses can omit diagnostic sections. + shards?: StatsShard[]; + profile?: { + exists: boolean; + version?: number; + counts?: { preferences: number; patterns: number; workflows: number }; + lastAnalyzedAt?: string; + totalPromptsAnalyzed?: number; + error?: string; + }; + daily?: StatsDaily[]; + dailyError?: string; +}; + +export function createStatsStore() { + let data = $state(null); + let loading = $state(false); + let error = $state(null); + + async function loadStats() { + loading = true; + error = null; + const result = await fetchAPI("/api/stats"); + loading = false; + if (result.success && result.data) { + data = result.data; + } else { + // Keep the last snapshot visible; an empty error still signals failure. + error = result.error || ""; + } + } + + return { + get data() { + return data; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + loadStats, + }; +} diff --git a/web/vite-plugins/opencode-mem-sim.ts b/web/vite-plugins/opencode-mem-sim.ts index f6013a64..ceaf46b1 100644 --- a/web/vite-plugins/opencode-mem-sim.ts +++ b/web/vite-plugins/opencode-mem-sim.ts @@ -138,10 +138,6 @@ export function opencodeMemSimPlugin(simRoot: string): Plugin { return skills.find((item) => item.id === id); } - function memoryCount() { - return memories.filter((item) => item.type === "memory").length; - } - server.middlewares.use((req, res, next) => { const rawUrl = req.url || "/"; if (!rawUrl.startsWith("/api")) { @@ -165,10 +161,7 @@ export function opencodeMemSimPlugin(simRoot: string): Plugin { } if (pathname === "/api/stats" && method === "GET") { - sendJson(res, 200, { - success: true, - data: { total: memoryCount() }, - }); + sendJson(res, 200, readJson(path.join(simRoot, "stats.json"))); return; }