mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
chore: bump version to 10.0.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "claude-mem",
|
"name": "claude-mem",
|
||||||
"version": "10.0.1",
|
"version": "10.0.2",
|
||||||
"source": "./plugin",
|
"source": "./plugin",
|
||||||
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mem",
|
"name": "claude-mem",
|
||||||
"version": "10.0.1",
|
"version": "10.0.2",
|
||||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude",
|
"claude",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mem",
|
"name": "claude-mem",
|
||||||
"version": "10.0.1",
|
"version": "10.0.2",
|
||||||
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Alex Newman"
|
"name": "Alex Newman"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mem-plugin",
|
"name": "claude-mem-plugin",
|
||||||
"version": "10.0.1",
|
"version": "10.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Runtime dependencies for claude-mem bundled hooks",
|
"description": "Runtime dependencies for claude-mem bundled hooks",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
65
src/services/transcripts/cli.ts
Normal file
65
src/services/transcripts/cli.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { DEFAULT_CONFIG_PATH, DEFAULT_STATE_PATH, expandHomePath, loadTranscriptWatchConfig, writeSampleConfig } from './config.js';
|
||||||
|
import { TranscriptWatcher } from './watcher.js';
|
||||||
|
|
||||||
|
function getArgValue(args: string[], name: string): string | null {
|
||||||
|
const index = args.indexOf(name);
|
||||||
|
if (index === -1) return null;
|
||||||
|
return args[index + 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTranscriptCommand(subcommand: string | undefined, args: string[]): Promise<number> {
|
||||||
|
switch (subcommand) {
|
||||||
|
case 'init': {
|
||||||
|
const configPath = getArgValue(args, '--config') ?? DEFAULT_CONFIG_PATH;
|
||||||
|
writeSampleConfig(configPath);
|
||||||
|
console.log(`Created sample config: ${expandHomePath(configPath)}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case 'watch': {
|
||||||
|
const configPath = getArgValue(args, '--config') ?? DEFAULT_CONFIG_PATH;
|
||||||
|
let config;
|
||||||
|
try {
|
||||||
|
config = loadTranscriptWatchConfig(configPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('not found')) {
|
||||||
|
writeSampleConfig(configPath);
|
||||||
|
console.log(`Created sample config: ${expandHomePath(configPath)}`);
|
||||||
|
config = loadTranscriptWatchConfig(configPath);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const statePath = expandHomePath(config.stateFile ?? DEFAULT_STATE_PATH);
|
||||||
|
const watcher = new TranscriptWatcher(config, statePath);
|
||||||
|
await watcher.start();
|
||||||
|
console.log('Transcript watcher running. Press Ctrl+C to stop.');
|
||||||
|
|
||||||
|
const shutdown = () => {
|
||||||
|
watcher.stop();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
return await new Promise(() => undefined);
|
||||||
|
}
|
||||||
|
case 'validate': {
|
||||||
|
const configPath = getArgValue(args, '--config') ?? DEFAULT_CONFIG_PATH;
|
||||||
|
try {
|
||||||
|
loadTranscriptWatchConfig(configPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('not found')) {
|
||||||
|
writeSampleConfig(configPath);
|
||||||
|
console.log(`Created sample config: ${expandHomePath(configPath)}`);
|
||||||
|
loadTranscriptWatchConfig(configPath);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Config OK: ${expandHomePath(configPath)}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
console.log('Usage: claude-mem transcript <init|watch|validate> [--config <path>]');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/services/transcripts/config.ts
Normal file
137
src/services/transcripts/config.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import type { TranscriptSchema, TranscriptWatchConfig } from './types.js';
|
||||||
|
|
||||||
|
export const DEFAULT_CONFIG_PATH = join(homedir(), '.claude-mem', 'transcript-watch.json');
|
||||||
|
export const DEFAULT_STATE_PATH = join(homedir(), '.claude-mem', 'transcript-watch-state.json');
|
||||||
|
|
||||||
|
const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
||||||
|
name: 'codex',
|
||||||
|
version: '0.2',
|
||||||
|
description: 'Schema for Codex session JSONL files under ~/.codex/sessions.',
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
name: 'session-meta',
|
||||||
|
match: { path: 'type', equals: 'session_meta' },
|
||||||
|
action: 'session_context',
|
||||||
|
fields: {
|
||||||
|
sessionId: 'payload.id',
|
||||||
|
cwd: 'payload.cwd'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'turn-context',
|
||||||
|
match: { path: 'type', equals: 'turn_context' },
|
||||||
|
action: 'session_context',
|
||||||
|
fields: {
|
||||||
|
cwd: 'payload.cwd'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'user-message',
|
||||||
|
match: { path: 'payload.type', equals: 'user_message' },
|
||||||
|
action: 'session_init',
|
||||||
|
fields: {
|
||||||
|
prompt: 'payload.message'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'assistant-message',
|
||||||
|
match: { path: 'payload.type', equals: 'agent_message' },
|
||||||
|
action: 'assistant_message',
|
||||||
|
fields: {
|
||||||
|
message: 'payload.message'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tool-use',
|
||||||
|
match: { path: 'payload.type', in: ['function_call', 'custom_tool_call', 'web_search_call'] },
|
||||||
|
action: 'tool_use',
|
||||||
|
fields: {
|
||||||
|
toolId: 'payload.call_id',
|
||||||
|
toolName: {
|
||||||
|
coalesce: [
|
||||||
|
'payload.name',
|
||||||
|
{ value: 'web_search' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
toolInput: {
|
||||||
|
coalesce: [
|
||||||
|
'payload.arguments',
|
||||||
|
'payload.input',
|
||||||
|
'payload.action'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tool-result',
|
||||||
|
match: { path: 'payload.type', in: ['function_call_output', 'custom_tool_call_output'] },
|
||||||
|
action: 'tool_result',
|
||||||
|
fields: {
|
||||||
|
toolId: 'payload.call_id',
|
||||||
|
toolResponse: 'payload.output'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'session-end',
|
||||||
|
match: { path: 'payload.type', equals: 'turn_aborted' },
|
||||||
|
action: 'session_end'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SAMPLE_CONFIG: TranscriptWatchConfig = {
|
||||||
|
version: 1,
|
||||||
|
schemas: {
|
||||||
|
codex: CODEX_SAMPLE_SCHEMA
|
||||||
|
},
|
||||||
|
watches: [
|
||||||
|
{
|
||||||
|
name: 'codex',
|
||||||
|
path: '~/.codex/sessions/**/*.jsonl',
|
||||||
|
schema: 'codex',
|
||||||
|
startAtEnd: true,
|
||||||
|
context: {
|
||||||
|
mode: 'agents',
|
||||||
|
path: '~/.codex/AGENTS.md',
|
||||||
|
updateOn: ['session_start', 'session_end']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
stateFile: DEFAULT_STATE_PATH
|
||||||
|
};
|
||||||
|
|
||||||
|
export function expandHomePath(inputPath: string): string {
|
||||||
|
if (!inputPath) return inputPath;
|
||||||
|
if (inputPath.startsWith('~')) {
|
||||||
|
return join(homedir(), inputPath.slice(1));
|
||||||
|
}
|
||||||
|
return inputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadTranscriptWatchConfig(path = DEFAULT_CONFIG_PATH): TranscriptWatchConfig {
|
||||||
|
const resolvedPath = expandHomePath(path);
|
||||||
|
if (!existsSync(resolvedPath)) {
|
||||||
|
throw new Error(`Transcript watch config not found: ${resolvedPath}`);
|
||||||
|
}
|
||||||
|
const raw = readFileSync(resolvedPath, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw) as TranscriptWatchConfig;
|
||||||
|
if (!parsed.version || !parsed.watches) {
|
||||||
|
throw new Error(`Invalid transcript watch config: ${resolvedPath}`);
|
||||||
|
}
|
||||||
|
if (!parsed.stateFile) {
|
||||||
|
parsed.stateFile = DEFAULT_STATE_PATH;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeSampleConfig(path = DEFAULT_CONFIG_PATH): void {
|
||||||
|
const resolvedPath = expandHomePath(path);
|
||||||
|
const dir = dirname(resolvedPath);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
writeFileSync(resolvedPath, JSON.stringify(SAMPLE_CONFIG, null, 2));
|
||||||
|
}
|
||||||
151
src/services/transcripts/field-utils.ts
Normal file
151
src/services/transcripts/field-utils.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import type { FieldSpec, MatchRule, TranscriptSchema, WatchTarget } from './types.js';
|
||||||
|
|
||||||
|
interface ResolveContext {
|
||||||
|
watch: WatchTarget;
|
||||||
|
schema: TranscriptSchema;
|
||||||
|
session?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePath(path: string): Array<string | number> {
|
||||||
|
const cleaned = path.trim().replace(/^\$\.?/, '');
|
||||||
|
if (!cleaned) return [];
|
||||||
|
|
||||||
|
const tokens: Array<string | number> = [];
|
||||||
|
const parts = cleaned.split('.');
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const regex = /([^[\]]+)|\[(\d+)\]/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = regex.exec(part)) !== null) {
|
||||||
|
if (match[1]) {
|
||||||
|
tokens.push(match[1]);
|
||||||
|
} else if (match[2]) {
|
||||||
|
tokens.push(parseInt(match[2], 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getValueByPath(input: unknown, path: string): unknown {
|
||||||
|
if (!path) return undefined;
|
||||||
|
const tokens = parsePath(path);
|
||||||
|
let current: any = input;
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (current === null || current === undefined) return undefined;
|
||||||
|
current = current[token as any];
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyValue(value: unknown): boolean {
|
||||||
|
return value === undefined || value === null || value === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFromContext(path: string, ctx: ResolveContext): unknown {
|
||||||
|
if (path.startsWith('$watch.')) {
|
||||||
|
const key = path.slice('$watch.'.length);
|
||||||
|
return (ctx.watch as any)[key];
|
||||||
|
}
|
||||||
|
if (path.startsWith('$schema.')) {
|
||||||
|
const key = path.slice('$schema.'.length);
|
||||||
|
return (ctx.schema as any)[key];
|
||||||
|
}
|
||||||
|
if (path.startsWith('$session.')) {
|
||||||
|
const key = path.slice('$session.'.length);
|
||||||
|
return ctx.session ? (ctx.session as any)[key] : undefined;
|
||||||
|
}
|
||||||
|
if (path === '$cwd') return ctx.watch.workspace;
|
||||||
|
if (path === '$project') return ctx.watch.project;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFieldSpec(
|
||||||
|
spec: FieldSpec | undefined,
|
||||||
|
entry: unknown,
|
||||||
|
ctx: ResolveContext
|
||||||
|
): unknown {
|
||||||
|
if (spec === undefined) return undefined;
|
||||||
|
|
||||||
|
if (typeof spec === 'string') {
|
||||||
|
const fromContext = resolveFromContext(spec, ctx);
|
||||||
|
if (fromContext !== undefined) return fromContext;
|
||||||
|
return getValueByPath(entry, spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.coalesce && Array.isArray(spec.coalesce)) {
|
||||||
|
for (const candidate of spec.coalesce) {
|
||||||
|
const value = resolveFieldSpec(candidate, entry, ctx);
|
||||||
|
if (!isEmptyValue(value)) return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.path) {
|
||||||
|
const fromContext = resolveFromContext(spec.path, ctx);
|
||||||
|
if (fromContext !== undefined) return fromContext;
|
||||||
|
const value = getValueByPath(entry, spec.path);
|
||||||
|
if (!isEmptyValue(value)) return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.value !== undefined) return spec.value;
|
||||||
|
|
||||||
|
if (spec.default !== undefined) return spec.default;
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFields(
|
||||||
|
fields: Record<string, FieldSpec> | undefined,
|
||||||
|
entry: unknown,
|
||||||
|
ctx: ResolveContext
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const resolved: Record<string, unknown> = {};
|
||||||
|
if (!fields) return resolved;
|
||||||
|
|
||||||
|
for (const [key, spec] of Object.entries(fields)) {
|
||||||
|
resolved[key] = resolveFieldSpec(spec, entry, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesRule(
|
||||||
|
entry: unknown,
|
||||||
|
rule: MatchRule | undefined,
|
||||||
|
schema: TranscriptSchema
|
||||||
|
): boolean {
|
||||||
|
if (!rule) return true;
|
||||||
|
|
||||||
|
const path = rule.path || schema.eventTypePath || 'type';
|
||||||
|
const value = path ? getValueByPath(entry, path) : undefined;
|
||||||
|
|
||||||
|
if (rule.exists) {
|
||||||
|
if (value === undefined || value === null || value === '') return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.equals !== undefined) {
|
||||||
|
return value === rule.equals;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.in && Array.isArray(rule.in)) {
|
||||||
|
return rule.in.includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.contains !== undefined) {
|
||||||
|
return typeof value === 'string' && value.includes(rule.contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.regex) {
|
||||||
|
try {
|
||||||
|
const regex = new RegExp(rule.regex);
|
||||||
|
return regex.test(String(value ?? ''));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
371
src/services/transcripts/processor.ts
Normal file
371
src/services/transcripts/processor.ts
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import { sessionInitHandler } from '../../cli/handlers/session-init.js';
|
||||||
|
import { observationHandler } from '../../cli/handlers/observation.js';
|
||||||
|
import { fileEditHandler } from '../../cli/handlers/file-edit.js';
|
||||||
|
import { sessionCompleteHandler } from '../../cli/handlers/session-complete.js';
|
||||||
|
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { getProjectContext, getProjectName } from '../../utils/project-name.js';
|
||||||
|
import { writeAgentsMd } from '../../utils/agents-md-utils.js';
|
||||||
|
import { resolveFieldSpec, resolveFields, matchesRule } from './field-utils.js';
|
||||||
|
import { expandHomePath } from './config.js';
|
||||||
|
import type { TranscriptSchema, WatchTarget, SchemaEvent } from './types.js';
|
||||||
|
|
||||||
|
interface SessionState {
|
||||||
|
sessionId: string;
|
||||||
|
cwd?: string;
|
||||||
|
project?: string;
|
||||||
|
lastUserMessage?: string;
|
||||||
|
lastAssistantMessage?: string;
|
||||||
|
pendingTools: Map<string, { name?: string; input?: unknown }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingTool {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
input?: unknown;
|
||||||
|
response?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TranscriptEventProcessor {
|
||||||
|
private sessions = new Map<string, SessionState>();
|
||||||
|
|
||||||
|
async processEntry(
|
||||||
|
entry: unknown,
|
||||||
|
watch: WatchTarget,
|
||||||
|
schema: TranscriptSchema,
|
||||||
|
sessionIdOverride?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
for (const event of schema.events) {
|
||||||
|
if (!matchesRule(entry, event.match, schema)) continue;
|
||||||
|
await this.handleEvent(entry, watch, schema, event, sessionIdOverride ?? undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSessionKey(watch: WatchTarget, sessionId: string): string {
|
||||||
|
return `${watch.name}:${sessionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrCreateSession(watch: WatchTarget, sessionId: string): SessionState {
|
||||||
|
const key = this.getSessionKey(watch, sessionId);
|
||||||
|
let session = this.sessions.get(key);
|
||||||
|
if (!session) {
|
||||||
|
session = {
|
||||||
|
sessionId,
|
||||||
|
pendingTools: new Map()
|
||||||
|
};
|
||||||
|
this.sessions.set(key, session);
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveSessionId(
|
||||||
|
entry: unknown,
|
||||||
|
watch: WatchTarget,
|
||||||
|
schema: TranscriptSchema,
|
||||||
|
event: SchemaEvent,
|
||||||
|
sessionIdOverride?: string
|
||||||
|
): string | null {
|
||||||
|
const ctx = { watch, schema } as any;
|
||||||
|
const fieldSpec = event.fields?.sessionId ?? (schema.sessionIdPath ? { path: schema.sessionIdPath } : undefined);
|
||||||
|
const resolved = resolveFieldSpec(fieldSpec, entry, ctx);
|
||||||
|
if (typeof resolved === 'string' && resolved.trim()) return resolved;
|
||||||
|
if (typeof resolved === 'number') return String(resolved);
|
||||||
|
if (sessionIdOverride && sessionIdOverride.trim()) return sessionIdOverride;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCwd(
|
||||||
|
entry: unknown,
|
||||||
|
watch: WatchTarget,
|
||||||
|
schema: TranscriptSchema,
|
||||||
|
event: SchemaEvent,
|
||||||
|
session: SessionState
|
||||||
|
): string | undefined {
|
||||||
|
const ctx = { watch, schema, session } as any;
|
||||||
|
const fieldSpec = event.fields?.cwd ?? (schema.cwdPath ? { path: schema.cwdPath } : undefined);
|
||||||
|
const resolved = resolveFieldSpec(fieldSpec, entry, ctx);
|
||||||
|
if (typeof resolved === 'string' && resolved.trim()) return resolved;
|
||||||
|
if (watch.workspace) return watch.workspace;
|
||||||
|
return session.cwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveProject(
|
||||||
|
entry: unknown,
|
||||||
|
watch: WatchTarget,
|
||||||
|
schema: TranscriptSchema,
|
||||||
|
event: SchemaEvent,
|
||||||
|
session: SessionState
|
||||||
|
): string | undefined {
|
||||||
|
const ctx = { watch, schema, session } as any;
|
||||||
|
const fieldSpec = event.fields?.project ?? (schema.projectPath ? { path: schema.projectPath } : undefined);
|
||||||
|
const resolved = resolveFieldSpec(fieldSpec, entry, ctx);
|
||||||
|
if (typeof resolved === 'string' && resolved.trim()) return resolved;
|
||||||
|
if (watch.project) return watch.project;
|
||||||
|
if (session.cwd) return getProjectName(session.cwd);
|
||||||
|
return session.project;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleEvent(
|
||||||
|
entry: unknown,
|
||||||
|
watch: WatchTarget,
|
||||||
|
schema: TranscriptSchema,
|
||||||
|
event: SchemaEvent,
|
||||||
|
sessionIdOverride?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const sessionId = this.resolveSessionId(entry, watch, schema, event, sessionIdOverride);
|
||||||
|
if (!sessionId) {
|
||||||
|
logger.debug('TRANSCRIPT', 'Skipping event without sessionId', { event: event.name, watch: watch.name });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.getOrCreateSession(watch, sessionId);
|
||||||
|
const cwd = this.resolveCwd(entry, watch, schema, event, session);
|
||||||
|
if (cwd) session.cwd = cwd;
|
||||||
|
const project = this.resolveProject(entry, watch, schema, event, session);
|
||||||
|
if (project) session.project = project;
|
||||||
|
|
||||||
|
const fields = resolveFields(event.fields, entry, { watch, schema, session });
|
||||||
|
|
||||||
|
switch (event.action) {
|
||||||
|
case 'session_context':
|
||||||
|
this.applySessionContext(session, fields);
|
||||||
|
break;
|
||||||
|
case 'session_init':
|
||||||
|
await this.handleSessionInit(session, fields);
|
||||||
|
if (watch.context?.updateOn?.includes('session_start')) {
|
||||||
|
await this.updateContext(session, watch);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'user_message':
|
||||||
|
if (typeof fields.message === 'string') session.lastUserMessage = fields.message;
|
||||||
|
if (typeof fields.prompt === 'string') session.lastUserMessage = fields.prompt;
|
||||||
|
break;
|
||||||
|
case 'assistant_message':
|
||||||
|
if (typeof fields.message === 'string') session.lastAssistantMessage = fields.message;
|
||||||
|
break;
|
||||||
|
case 'tool_use':
|
||||||
|
await this.handleToolUse(session, fields);
|
||||||
|
break;
|
||||||
|
case 'tool_result':
|
||||||
|
await this.handleToolResult(session, fields);
|
||||||
|
break;
|
||||||
|
case 'observation':
|
||||||
|
await this.sendObservation(session, fields);
|
||||||
|
break;
|
||||||
|
case 'file_edit':
|
||||||
|
await this.sendFileEdit(session, fields);
|
||||||
|
break;
|
||||||
|
case 'session_end':
|
||||||
|
await this.handleSessionEnd(session, watch);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applySessionContext(session: SessionState, fields: Record<string, unknown>): void {
|
||||||
|
const cwd = typeof fields.cwd === 'string' ? fields.cwd : undefined;
|
||||||
|
const project = typeof fields.project === 'string' ? fields.project : undefined;
|
||||||
|
if (cwd) session.cwd = cwd;
|
||||||
|
if (project) session.project = project;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSessionInit(session: SessionState, fields: Record<string, unknown>): Promise<void> {
|
||||||
|
const prompt = typeof fields.prompt === 'string' ? fields.prompt : '';
|
||||||
|
const cwd = session.cwd ?? process.cwd();
|
||||||
|
if (prompt) {
|
||||||
|
session.lastUserMessage = prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sessionInitHandler.execute({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
cwd,
|
||||||
|
prompt,
|
||||||
|
platform: 'transcript'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleToolUse(session: SessionState, fields: Record<string, unknown>): Promise<void> {
|
||||||
|
const toolId = typeof fields.toolId === 'string' ? fields.toolId : undefined;
|
||||||
|
const toolName = typeof fields.toolName === 'string' ? fields.toolName : undefined;
|
||||||
|
const toolInput = this.maybeParseJson(fields.toolInput);
|
||||||
|
const toolResponse = this.maybeParseJson(fields.toolResponse);
|
||||||
|
|
||||||
|
const pending: PendingTool = { id: toolId, name: toolName, input: toolInput, response: toolResponse };
|
||||||
|
|
||||||
|
if (toolId) {
|
||||||
|
session.pendingTools.set(toolId, { name: pending.name, input: pending.input });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === 'apply_patch' && typeof toolInput === 'string') {
|
||||||
|
const files = this.parseApplyPatchFiles(toolInput);
|
||||||
|
for (const filePath of files) {
|
||||||
|
await this.sendFileEdit(session, {
|
||||||
|
filePath,
|
||||||
|
edits: [{ type: 'apply_patch', patch: toolInput }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolResponse !== undefined && toolName) {
|
||||||
|
await this.sendObservation(session, {
|
||||||
|
toolName,
|
||||||
|
toolInput,
|
||||||
|
toolResponse
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleToolResult(session: SessionState, fields: Record<string, unknown>): Promise<void> {
|
||||||
|
const toolId = typeof fields.toolId === 'string' ? fields.toolId : undefined;
|
||||||
|
const toolName = typeof fields.toolName === 'string' ? fields.toolName : undefined;
|
||||||
|
const toolResponse = this.maybeParseJson(fields.toolResponse);
|
||||||
|
|
||||||
|
let toolInput: unknown = this.maybeParseJson(fields.toolInput);
|
||||||
|
let name = toolName;
|
||||||
|
|
||||||
|
if (toolId && session.pendingTools.has(toolId)) {
|
||||||
|
const pending = session.pendingTools.get(toolId)!;
|
||||||
|
toolInput = pending.input ?? toolInput;
|
||||||
|
name = name ?? pending.name;
|
||||||
|
session.pendingTools.delete(toolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
await this.sendObservation(session, {
|
||||||
|
toolName: name,
|
||||||
|
toolInput,
|
||||||
|
toolResponse
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendObservation(session: SessionState, fields: Record<string, unknown>): Promise<void> {
|
||||||
|
const toolName = typeof fields.toolName === 'string' ? fields.toolName : undefined;
|
||||||
|
if (!toolName) return;
|
||||||
|
|
||||||
|
await observationHandler.execute({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
cwd: session.cwd ?? process.cwd(),
|
||||||
|
toolName,
|
||||||
|
toolInput: this.maybeParseJson(fields.toolInput),
|
||||||
|
toolResponse: this.maybeParseJson(fields.toolResponse),
|
||||||
|
platform: 'transcript'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendFileEdit(session: SessionState, fields: Record<string, unknown>): Promise<void> {
|
||||||
|
const filePath = typeof fields.filePath === 'string' ? fields.filePath : undefined;
|
||||||
|
if (!filePath) return;
|
||||||
|
|
||||||
|
await fileEditHandler.execute({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
cwd: session.cwd ?? process.cwd(),
|
||||||
|
filePath,
|
||||||
|
edits: Array.isArray(fields.edits) ? fields.edits : undefined,
|
||||||
|
platform: 'transcript'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private maybeParseJson(value: unknown): unknown {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return value;
|
||||||
|
if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) return value;
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseApplyPatchFiles(patch: string): string[] {
|
||||||
|
const files: string[] = [];
|
||||||
|
const lines = patch.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith('*** Update File: ')) {
|
||||||
|
files.push(trimmed.replace('*** Update File: ', '').trim());
|
||||||
|
} else if (trimmed.startsWith('*** Add File: ')) {
|
||||||
|
files.push(trimmed.replace('*** Add File: ', '').trim());
|
||||||
|
} else if (trimmed.startsWith('*** Delete File: ')) {
|
||||||
|
files.push(trimmed.replace('*** Delete File: ', '').trim());
|
||||||
|
} else if (trimmed.startsWith('*** Move to: ')) {
|
||||||
|
files.push(trimmed.replace('*** Move to: ', '').trim());
|
||||||
|
} else if (trimmed.startsWith('+++ ')) {
|
||||||
|
const path = trimmed.replace('+++ ', '').replace(/^b\//, '').trim();
|
||||||
|
if (path && path !== '/dev/null') files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(new Set(files));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSessionEnd(session: SessionState, watch: WatchTarget): Promise<void> {
|
||||||
|
await this.queueSummary(session);
|
||||||
|
await sessionCompleteHandler.execute({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
cwd: session.cwd ?? process.cwd(),
|
||||||
|
platform: 'transcript'
|
||||||
|
});
|
||||||
|
await this.updateContext(session, watch);
|
||||||
|
session.pendingTools.clear();
|
||||||
|
const key = this.getSessionKey(watch, session.sessionId);
|
||||||
|
this.sessions.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async queueSummary(session: SessionState): Promise<void> {
|
||||||
|
const workerReady = await ensureWorkerRunning();
|
||||||
|
if (!workerReady) return;
|
||||||
|
|
||||||
|
const port = getWorkerPort();
|
||||||
|
const lastAssistantMessage = session.lastAssistantMessage ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`http://127.0.0.1:${port}/api/sessions/summarize`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contentSessionId: session.sessionId,
|
||||||
|
last_assistant_message: lastAssistantMessage
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('TRANSCRIPT', 'Summary request failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateContext(session: SessionState, watch: WatchTarget): Promise<void> {
|
||||||
|
if (!watch.context) return;
|
||||||
|
if (watch.context.mode !== 'agents') return;
|
||||||
|
|
||||||
|
const workerReady = await ensureWorkerRunning();
|
||||||
|
if (!workerReady) return;
|
||||||
|
|
||||||
|
const cwd = session.cwd ?? watch.workspace;
|
||||||
|
if (!cwd) return;
|
||||||
|
|
||||||
|
const context = getProjectContext(cwd);
|
||||||
|
const projectsParam = context.allProjects.join(',');
|
||||||
|
const port = getWorkerPort();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`http://127.0.0.1:${port}/api/context/inject?projects=${encodeURIComponent(projectsParam)}`
|
||||||
|
);
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const content = (await response.text()).trim();
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const agentsPath = expandHomePath(watch.context.path ?? `${cwd}/AGENTS.md`);
|
||||||
|
writeAgentsMd(agentsPath, content);
|
||||||
|
logger.debug('TRANSCRIPT', 'Updated AGENTS.md context', { agentsPath, watch: watch.name });
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('TRANSCRIPT', 'Failed to update AGENTS.md context', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/services/transcripts/state.ts
Normal file
40
src/services/transcripts/state.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
export interface TranscriptWatchState {
|
||||||
|
offsets: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadWatchState(statePath: string): TranscriptWatchState {
|
||||||
|
try {
|
||||||
|
if (!existsSync(statePath)) {
|
||||||
|
return { offsets: {} };
|
||||||
|
}
|
||||||
|
const raw = readFileSync(statePath, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw) as TranscriptWatchState;
|
||||||
|
if (!parsed.offsets) return { offsets: {} };
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('TRANSCRIPT', 'Failed to load watch state, starting fresh', {
|
||||||
|
statePath,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
return { offsets: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveWatchState(statePath: string, state: TranscriptWatchState): void {
|
||||||
|
try {
|
||||||
|
const dir = dirname(statePath);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
writeFileSync(statePath, JSON.stringify(state, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('TRANSCRIPT', 'Failed to save watch state', {
|
||||||
|
statePath,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/services/transcripts/types.ts
Normal file
70
src/services/transcripts/types.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export type FieldSpec =
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
path?: string;
|
||||||
|
value?: unknown;
|
||||||
|
coalesce?: FieldSpec[];
|
||||||
|
default?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MatchRule {
|
||||||
|
path?: string;
|
||||||
|
equals?: unknown;
|
||||||
|
in?: unknown[];
|
||||||
|
contains?: string;
|
||||||
|
exists?: boolean;
|
||||||
|
regex?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventAction =
|
||||||
|
| 'session_init'
|
||||||
|
| 'session_context'
|
||||||
|
| 'user_message'
|
||||||
|
| 'assistant_message'
|
||||||
|
| 'tool_use'
|
||||||
|
| 'tool_result'
|
||||||
|
| 'observation'
|
||||||
|
| 'file_edit'
|
||||||
|
| 'session_end';
|
||||||
|
|
||||||
|
export interface SchemaEvent {
|
||||||
|
name: string;
|
||||||
|
match?: MatchRule;
|
||||||
|
action: EventAction;
|
||||||
|
fields?: Record<string, FieldSpec>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptSchema {
|
||||||
|
name: string;
|
||||||
|
version?: string;
|
||||||
|
description?: string;
|
||||||
|
eventTypePath?: string;
|
||||||
|
sessionIdPath?: string;
|
||||||
|
cwdPath?: string;
|
||||||
|
projectPath?: string;
|
||||||
|
events: SchemaEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchContextConfig {
|
||||||
|
mode: 'agents';
|
||||||
|
path?: string;
|
||||||
|
updateOn?: Array<'session_start' | 'session_end'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchTarget {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
schema: string | TranscriptSchema;
|
||||||
|
workspace?: string;
|
||||||
|
project?: string;
|
||||||
|
context?: WatchContextConfig;
|
||||||
|
rescanIntervalMs?: number;
|
||||||
|
startAtEnd?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptWatchConfig {
|
||||||
|
version: 1;
|
||||||
|
schemas?: Record<string, TranscriptSchema>;
|
||||||
|
watches: WatchTarget[];
|
||||||
|
stateFile?: string;
|
||||||
|
}
|
||||||
224
src/services/transcripts/watcher.ts
Normal file
224
src/services/transcripts/watcher.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { existsSync, statSync, watch as fsWatch, createReadStream } from 'fs';
|
||||||
|
import { basename, join } from 'path';
|
||||||
|
import { globSync } from 'glob';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { expandHomePath } from './config.js';
|
||||||
|
import { loadWatchState, saveWatchState, type TranscriptWatchState } from './state.js';
|
||||||
|
import type { TranscriptWatchConfig, TranscriptSchema, WatchTarget } from './types.js';
|
||||||
|
import { TranscriptEventProcessor } from './processor.js';
|
||||||
|
|
||||||
|
interface TailState {
|
||||||
|
offset: number;
|
||||||
|
partial: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileTailer {
|
||||||
|
private watcher: ReturnType<typeof fsWatch> | null = null;
|
||||||
|
private tailState: TailState;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private filePath: string,
|
||||||
|
initialOffset: number,
|
||||||
|
private onLine: (line: string) => Promise<void>,
|
||||||
|
private onOffset: (offset: number) => void
|
||||||
|
) {
|
||||||
|
this.tailState = { offset: initialOffset, partial: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.readNewData().catch(() => undefined);
|
||||||
|
this.watcher = fsWatch(this.filePath, { persistent: true }, () => {
|
||||||
|
this.readNewData().catch(() => undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.watcher?.close();
|
||||||
|
this.watcher = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readNewData(): Promise<void> {
|
||||||
|
if (!existsSync(this.filePath)) return;
|
||||||
|
|
||||||
|
let size = 0;
|
||||||
|
try {
|
||||||
|
size = statSync(this.filePath).size;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size < this.tailState.offset) {
|
||||||
|
this.tailState.offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size === this.tailState.offset) return;
|
||||||
|
|
||||||
|
const stream = createReadStream(this.filePath, {
|
||||||
|
start: this.tailState.offset,
|
||||||
|
end: size - 1,
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
|
||||||
|
let data = '';
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
data += chunk as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tailState.offset = size;
|
||||||
|
this.onOffset(this.tailState.offset);
|
||||||
|
|
||||||
|
const combined = this.tailState.partial + data;
|
||||||
|
const lines = combined.split('\n');
|
||||||
|
this.tailState.partial = lines.pop() ?? '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
await this.onLine(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TranscriptWatcher {
|
||||||
|
private processor = new TranscriptEventProcessor();
|
||||||
|
private tailers = new Map<string, FileTailer>();
|
||||||
|
private state: TranscriptWatchState;
|
||||||
|
private rescanTimers: Array<NodeJS.Timeout> = [];
|
||||||
|
|
||||||
|
constructor(private config: TranscriptWatchConfig, private statePath: string) {
|
||||||
|
this.state = loadWatchState(statePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
for (const watch of this.config.watches) {
|
||||||
|
await this.setupWatch(watch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
for (const tailer of this.tailers.values()) {
|
||||||
|
tailer.close();
|
||||||
|
}
|
||||||
|
this.tailers.clear();
|
||||||
|
for (const timer of this.rescanTimers) {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
this.rescanTimers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setupWatch(watch: WatchTarget): Promise<void> {
|
||||||
|
const schema = this.resolveSchema(watch);
|
||||||
|
if (!schema) {
|
||||||
|
logger.warn('TRANSCRIPT', 'Missing schema for watch', { watch: watch.name });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = expandHomePath(watch.path);
|
||||||
|
const files = this.resolveWatchFiles(resolvedPath);
|
||||||
|
|
||||||
|
for (const filePath of files) {
|
||||||
|
await this.addTailer(filePath, watch, schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rescanIntervalMs = watch.rescanIntervalMs ?? 5000;
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
const newFiles = this.resolveWatchFiles(resolvedPath);
|
||||||
|
for (const filePath of newFiles) {
|
||||||
|
if (!this.tailers.has(filePath)) {
|
||||||
|
await this.addTailer(filePath, watch, schema);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, rescanIntervalMs);
|
||||||
|
this.rescanTimers.push(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveSchema(watch: WatchTarget): TranscriptSchema | null {
|
||||||
|
if (typeof watch.schema === 'string') {
|
||||||
|
return this.config.schemas?.[watch.schema] ?? null;
|
||||||
|
}
|
||||||
|
return watch.schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveWatchFiles(inputPath: string): string[] {
|
||||||
|
if (this.hasGlob(inputPath)) {
|
||||||
|
return globSync(inputPath, { nodir: true, absolute: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(inputPath)) {
|
||||||
|
try {
|
||||||
|
const stat = statSync(inputPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const pattern = join(inputPath, '**', '*.jsonl');
|
||||||
|
return globSync(pattern, { nodir: true, absolute: true });
|
||||||
|
}
|
||||||
|
return [inputPath];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasGlob(inputPath: string): boolean {
|
||||||
|
return /[*?[\]{}()]/.test(inputPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async addTailer(filePath: string, watch: WatchTarget, schema: TranscriptSchema): Promise<void> {
|
||||||
|
if (this.tailers.has(filePath)) return;
|
||||||
|
|
||||||
|
const sessionIdOverride = this.extractSessionIdFromPath(filePath);
|
||||||
|
|
||||||
|
let offset = this.state.offsets[filePath] ?? 0;
|
||||||
|
if (offset === 0 && watch.startAtEnd) {
|
||||||
|
try {
|
||||||
|
offset = statSync(filePath).size;
|
||||||
|
} catch {
|
||||||
|
offset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tailer = new FileTailer(
|
||||||
|
filePath,
|
||||||
|
offset,
|
||||||
|
async (line: string) => {
|
||||||
|
await this.handleLine(line, watch, schema, filePath, sessionIdOverride);
|
||||||
|
},
|
||||||
|
(newOffset: number) => {
|
||||||
|
this.state.offsets[filePath] = newOffset;
|
||||||
|
saveWatchState(this.statePath, this.state);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
tailer.start();
|
||||||
|
this.tailers.set(filePath, tailer);
|
||||||
|
logger.info('TRANSCRIPT', 'Watching transcript file', {
|
||||||
|
file: filePath,
|
||||||
|
watch: watch.name,
|
||||||
|
schema: schema.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleLine(
|
||||||
|
line: string,
|
||||||
|
watch: WatchTarget,
|
||||||
|
schema: TranscriptSchema,
|
||||||
|
filePath: string,
|
||||||
|
sessionIdOverride?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
await this.processor.processEntry(entry, watch, schema, sessionIdOverride ?? undefined);
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('TRANSCRIPT', 'Failed to parse transcript line', {
|
||||||
|
watch: watch.name,
|
||||||
|
file: basename(filePath)
|
||||||
|
}, error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractSessionIdFromPath(filePath: string): string | null {
|
||||||
|
const match = filePath.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
|
||||||
|
return match ? match[0] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/utils/agents-md-utils.ts
Normal file
33
src/utils/agents-md-utils.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
import { replaceTaggedContent } from './claude-md-utils.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write AGENTS.md with claude-mem context, preserving user content outside tags.
|
||||||
|
* Uses atomic write to prevent partial writes.
|
||||||
|
*/
|
||||||
|
export function writeAgentsMd(agentsPath: string, context: string): void {
|
||||||
|
if (!agentsPath) return;
|
||||||
|
|
||||||
|
const dir = dirname(agentsPath);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingContent = '';
|
||||||
|
if (existsSync(agentsPath)) {
|
||||||
|
existingContent = readFileSync(agentsPath, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentBlock = `# Memory Context\n\n${context}`;
|
||||||
|
const finalContent = replaceTaggedContent(existingContent, contentBlock);
|
||||||
|
const tempFile = `${agentsPath}.tmp`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(tempFile, finalContent);
|
||||||
|
renameSync(tempFile, agentsPath);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AGENTS_MD', 'Failed to write AGENTS.md', { agentsPath }, error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
transcript-watch.example.json
Normal file
94
transcript-watch.example.json
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"schemas": {
|
||||||
|
"codex": {
|
||||||
|
"name": "codex",
|
||||||
|
"version": "0.2",
|
||||||
|
"description": "Schema for Codex session JSONL files under ~/.codex/sessions.",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"name": "session-meta",
|
||||||
|
"match": { "path": "type", "equals": "session_meta" },
|
||||||
|
"action": "session_context",
|
||||||
|
"fields": {
|
||||||
|
"sessionId": "payload.id",
|
||||||
|
"cwd": "payload.cwd"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "turn-context",
|
||||||
|
"match": { "path": "type", "equals": "turn_context" },
|
||||||
|
"action": "session_context",
|
||||||
|
"fields": {
|
||||||
|
"cwd": "payload.cwd"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user-message",
|
||||||
|
"match": { "path": "payload.type", "equals": "user_message" },
|
||||||
|
"action": "session_init",
|
||||||
|
"fields": {
|
||||||
|
"prompt": "payload.message"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "assistant-message",
|
||||||
|
"match": { "path": "payload.type", "equals": "agent_message" },
|
||||||
|
"action": "assistant_message",
|
||||||
|
"fields": {
|
||||||
|
"message": "payload.message"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tool-use",
|
||||||
|
"match": { "path": "payload.type", "in": ["function_call", "custom_tool_call", "web_search_call"] },
|
||||||
|
"action": "tool_use",
|
||||||
|
"fields": {
|
||||||
|
"toolId": "payload.call_id",
|
||||||
|
"toolName": {
|
||||||
|
"coalesce": [
|
||||||
|
"payload.name",
|
||||||
|
{ "value": "web_search" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"toolInput": {
|
||||||
|
"coalesce": [
|
||||||
|
"payload.arguments",
|
||||||
|
"payload.input",
|
||||||
|
"payload.action"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tool-result",
|
||||||
|
"match": { "path": "payload.type", "in": ["function_call_output", "custom_tool_call_output"] },
|
||||||
|
"action": "tool_result",
|
||||||
|
"fields": {
|
||||||
|
"toolId": "payload.call_id",
|
||||||
|
"toolResponse": "payload.output"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "session-end",
|
||||||
|
"match": { "path": "payload.type", "equals": "turn_aborted" },
|
||||||
|
"action": "session_end"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"watches": [
|
||||||
|
{
|
||||||
|
"name": "codex",
|
||||||
|
"path": "~/.codex/sessions/**/*.jsonl",
|
||||||
|
"schema": "codex",
|
||||||
|
"startAtEnd": true,
|
||||||
|
"context": {
|
||||||
|
"mode": "agents",
|
||||||
|
"path": "~/.codex/AGENTS.md",
|
||||||
|
"updateOn": ["session_start", "session_end"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stateFile": "~/.claude-mem/transcript-watch-state.json"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user