Files
shiro-neko/src/store.ts
T
Muhammad Zakir Ramadhan 5b8503fcd9 Initial commit: shiro-neko 0.1.0-beta.1
Agentic coding CLI on Bun, Ink, and the AI SDK.

Core: streamText loop with SDK-level tool approval so a denied call provably never executes; endpoint fallback for OpenAI reasoning models; retry with backoff.

Tools: read/write/edit/glob/grep/bash, path-jailed, gitignore-aware, ripgrep with a JS fallback, binary rejection, live bash streaming.

Agents: five variants crossing thinking level with tool restriction; plan and review withhold mutating tools from the model.

Extensibility: frontmatter skills with on-demand bodies, plugin host with blocking hooks, MCP stdio and HTTP, read-only subagents.

State: durable per-project memory, session task lists, session persistence, compaction that repairs provider-item dependencies.

Distribution: five-platform cross-compiled binaries with checksums, install scripts, CI on three operating systems.

404 tests, typecheck clean.
2026-09-02 17:30:18 +07:00

110 lines
3.7 KiB
TypeScript

import type { ModelMessage } from 'ai';
import { createHash } from 'node:crypto';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { NotebookState } from './notebook';
export type SessionRecord = {
id: string;
createdAt: string;
updatedAt: string;
cwd: string;
provider: string;
model: string;
title: string;
inputTokens: number;
outputTokens: number;
/** Estimated USD, absent when the model has no known rate. */
costUsd?: number;
/** Task list and notes, so a resumed session keeps its plan. */
notebook?: NotebookState;
messages: ModelMessage[];
};
/** Resolved per call so tests can point SHIRO_HOME at a temp directory. */
const root = () => join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko');
const dir = () => join(root(), 'sessions');
const file = (id: string) => join(dir(), `${id}.json`);
export function newId(): string {
return Bun.randomUUIDv7();
}
export async function save(rec: SessionRecord): Promise<void> {
await Bun.write(file(rec.id), JSON.stringify({ ...rec, updatedAt: new Date().toISOString() }, null, 2));
}
export async function load(id: string): Promise<SessionRecord | undefined> {
const f = Bun.file(file(id));
if (!(await f.exists())) return undefined;
try {
return (await f.json()) as SessionRecord;
} catch {
return undefined;
}
}
export async function list(limit = 20): Promise<SessionRecord[]> {
const found: SessionRecord[] = [];
// Bun.Glob throws ENOENT on a directory that does not exist yet, which is the
// normal state on a fresh install.
try {
for await (const name of new Bun.Glob('*.json').scan({ cwd: dir(), onlyFiles: true })) {
const rec = await load(name.replace(/\.json$/, ''));
if (rec) found.push(rec);
}
} catch {
return [];
}
return found.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit);
}
export async function latest(cwd?: string): Promise<SessionRecord | undefined> {
const all = await list(100);
return cwd ? all.find((r) => r.cwd === cwd) : all[0];
}
/** Resolves a full id or a unique prefix, so users can type the first few chars. */
export async function resolveId(prefix: string): Promise<string | undefined> {
if (await Bun.file(file(prefix)).exists()) return prefix;
const matches = (await list(100)).filter((r) => r.id.startsWith(prefix));
return matches.length === 1 ? matches[0]!.id : undefined;
}
export function titleOf(messages: ModelMessage[]): string {
const first = messages.find((m) => m.role === 'user');
const text = typeof first?.content === 'string' ? first.content : '';
return text.length > 60 ? `${text.slice(0, 60)}...` : text || 'untitled';
}
const MAX_HISTORY = 200;
/** Per-directory file, hashed because a path is not a safe filename. */
const historyFile = (cwd: string) =>
join(root(), 'history', `${createHash('sha256').update(cwd).digest('hex').slice(0, 16)}.json`);
export async function loadHistory(cwd = process.cwd()): Promise<string[]> {
const f = Bun.file(historyFile(cwd));
if (!(await f.exists())) return [];
try {
const parsed: unknown = await f.json();
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
/** Appends unless it repeats the previous entry, keeping the newest MAX_HISTORY. */
export async function appendHistory(prompt: string, cwd = process.cwd()): Promise<string[]> {
const text = prompt.trim();
if (!text) return loadHistory(cwd);
const existing = await loadHistory(cwd);
if (existing.at(-1) === text) return existing;
const next = [...existing, text].slice(-MAX_HISTORY);
await Bun.write(historyFile(cwd), JSON.stringify(next, null, 2));
return next;
}
export { dir as sessionsDir, root as shiroHome };