diff --git a/src/cli.ts b/src/cli.ts index 36187a9..79f1760 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,6 +39,7 @@ import { skillWithFrontmatter, installSkill, uninstallSkill } from './skill.js'; import { registerCompanionCommands } from './companion-cli.js'; import { getPathReport } from './field-status.js'; import { formatAgentContext, getAgentContext } from './agent-context.js'; +import { formatCurrentDocumentContext, readCurrentDocumentContext } from './current.js'; import { formatIdeasIntro, formatRunList, @@ -440,7 +441,7 @@ function isInternalWorkerCommand(command: Command): boolean { function shouldSkipCommandChrome(command: Command): boolean { if (isInternalWorkerCommand(command)) return true; if (command.opts().json) return true; - if (command.name() === 'path' || command.name() === 'paths' || command.name() === 'recent') return true; + if (command.name() === 'path' || command.name() === 'paths' || command.name() === 'current' || command.name() === 'recent') return true; if (command.name() === 'show' && command.parent?.name() === 'skill') return true; return false; } @@ -1529,6 +1530,25 @@ export function buildCli() { process.stdout.write(formatAgentContext(context)); })); + program + .command('current') + .description('Show the active Field Theory document attached to the Mac app terminal') + .option('--manifest ', 'Read a specific context manifest') + .option('--content-only', 'Print only the active document markdown/content') + .option('--json', 'JSON output') + .action(safe(async (options) => { + const context = readCurrentDocumentContext(options.manifest); + if (options.json) { + printJson(context); + return; + } + if (options.contentOnly) { + process.stdout.write(context.content.endsWith('\n') ? context.content : `${context.content}\n`); + return; + } + process.stdout.write(formatCurrentDocumentContext(context)); + })); + registerCompanionCommands(program, safe); // ── sample ────────────────────────────────────────────────────────────── diff --git a/src/current.ts b/src/current.ts new file mode 100644 index 0000000..4940e7f --- /dev/null +++ b/src/current.ts @@ -0,0 +1,116 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { codexContextSessionsDir } from './paths.js'; + +export interface CurrentDocumentContext { + manifestPath: string; + updatedAt: string | null; + activeDocument: { + title: string | null; + path: string | null; + kind: string | null; + contentMode: string | null; + contentPath: string; + }; + content: string; +} + +type ManifestRecord = Record; + +function readJsonObject(filePath: string): ManifestRecord { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Context manifest is not an object: ${filePath}`); + } + return parsed as ManifestRecord; +} + +function stringField(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function statMtimeMs(filePath: string): number { + try { + return fs.statSync(filePath).mtimeMs; + } catch { + return 0; + } +} + +function assertInsideDirectory(filePath: string, dirPath: string): void { + const resolvedFilePath = path.resolve(filePath); + const resolvedDirPath = path.resolve(dirPath); + const relativePath = path.relative(resolvedDirPath, resolvedFilePath); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + throw new Error(`Context content path must stay inside its session directory: ${filePath}`); + } +} + +export function findCurrentContextManifest(sessionsDir = codexContextSessionsDir()): string | null { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(sessionsDir, { withFileTypes: true }); + } catch { + return null; + } + + const manifests = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(sessionsDir, entry.name, 'context.json')) + .filter((manifestPath) => fs.existsSync(manifestPath)) + .sort((a, b) => statMtimeMs(b) - statMtimeMs(a)); + + return manifests[0] ?? null; +} + +export function readCurrentDocumentContext(manifestPath = findCurrentContextManifest()): CurrentDocumentContext { + if (!manifestPath) { + throw new Error('No active Field Theory context found. Open a Field Theory document and attach a Codex terminal first.'); + } + + const manifest = readJsonObject(manifestPath); + const activeDocument = manifest.activeDocument; + if (!activeDocument || typeof activeDocument !== 'object' || Array.isArray(activeDocument)) { + throw new Error(`Context manifest has no activeDocument object: ${manifestPath}`); + } + + const documentRecord = activeDocument as ManifestRecord; + const contentPath = stringField(documentRecord.contentPath); + if (!contentPath) { + throw new Error(`Context manifest has no activeDocument.contentPath: ${manifestPath}`); + } + assertInsideDirectory(contentPath, path.dirname(manifestPath)); + + return { + manifestPath, + updatedAt: stringField(manifest.updatedAt), + activeDocument: { + title: stringField(documentRecord.title), + path: stringField(documentRecord.path), + kind: stringField(documentRecord.kind), + contentMode: stringField(documentRecord.contentMode), + contentPath, + }, + content: fs.readFileSync(contentPath, 'utf-8'), + }; +} + +export function formatCurrentDocumentContext(context: CurrentDocumentContext): string { + const lines = [ + '# Field Theory Current Document', + '', + `title: ${context.activeDocument.title ?? '(untitled)'}`, + `source: ${context.activeDocument.path ?? '(unknown)'}`, + `kind: ${context.activeDocument.kind ?? '(unknown)'}`, + `contentMode: ${context.activeDocument.contentMode ?? '(unknown)'}`, + `updatedAt: ${context.updatedAt ?? '(unknown)'}`, + `manifest: ${context.manifestPath}`, + `content: ${context.activeDocument.contentPath}`, + '', + '---', + '', + context.content, + ]; + + return `${lines.join('\n')}${context.content.endsWith('\n') ? '' : '\n'}`; +} diff --git a/src/paths.ts b/src/paths.ts index 9691285..90fa4f9 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -31,6 +31,10 @@ export function canonicalCommandsDir(): string { return process.env.FT_COMMANDS_DIR ?? path.join(fieldTheoryDir(), 'commands'); } +export function codexContextSessionsDir(): string { + return path.join(canonicalLibraryDir(), 'Codex Context', 'sessions'); +} + export function libraryDir(): string { const override = process.env.FT_LIBRARY_DIR; if (override) return override; diff --git a/src/skill.ts b/src/skill.ts index 9c7f98f..2e79127 100644 --- a/src/skill.ts +++ b/src/skill.ts @@ -35,12 +35,13 @@ Field Theory has three main local surfaces: ## Search Workflow 1. Check paths and status when setup matters: \`ft paths --json\`, \`ft status --json\` -2. When the user says "that file" or "the recent file", inspect current repo recency with \`ft recent --json\` -3. Search durable notes first when prior project knowledge matters: \`ft library search --json\` -4. Search bookmarks when reading history or saved X/Twitter posts matter: \`ft search --json\` -5. Inspect exact files or bookmarks with \`ft library show --json\`, \`ft show --json\`, or \`ft commands show --json\` -6. Create or update durable Library notes and portable commands only when the user asks for a saved artifact -7. Open useful Library pages in the Mac app with \`ft library open \` +2. When the user asks what Field Theory document they are looking at, run \`ft current --json\` +3. When the user says "that file" or "the recent file", inspect current repo recency with \`ft recent --json\` +4. Search durable notes first when prior project knowledge matters: \`ft library search --json\` +5. Search bookmarks when reading history or saved X/Twitter posts matter: \`ft search --json\` +6. Inspect exact files or bookmarks with \`ft library show --json\`, \`ft show --json\`, or \`ft commands show --json\` +7. Create or update durable Library notes and portable commands only when the user asks for a saved artifact +8. Open useful Library pages in the Mac app with \`ft library open \` ## Possible Roadmap Workflow @@ -87,6 +88,7 @@ If the user says "debate", use the existing \`ft possible\` pipeline as generate \`\`\`bash ft paths --json # Canonical bookmarks, library, commands paths ft status --json # Bookmark/classification status plus paths +ft current --json # Active Field Theory document attached to the Mac app terminal ft recent --json # Current repo last-modified file and recent files for agent references ft search # Full-text BM25 search ("exact phrase", AND, OR, NOT) diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 864cee5..c981a92 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -73,9 +73,9 @@ test('ft search, stats, and status expose --json', () => { } }); -test('ft paths, recent, library, commands, app, and install command groups are registered', () => { +test('ft paths, current, recent, library, commands, app, and install command groups are registered', () => { const program = buildCli(); - for (const name of ['paths', 'recent', 'library', 'commands', 'app', 'install']) { + for (const name of ['paths', 'current', 'recent', 'library', 'commands', 'app', 'install']) { assert.ok(program.commands.find((c: any) => c.name() === name), `${name} command should be registered`); } }); diff --git a/tests/current.test.ts b/tests/current.test.ts new file mode 100644 index 0000000..c8035db --- /dev/null +++ b/tests/current.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + findCurrentContextManifest, + formatCurrentDocumentContext, + readCurrentDocumentContext, +} from '../src/current.js'; + +function writeContext(root: string, id: string, title: string, content: string, updatedAt: string): string { + const sessionDir = path.join(root, id); + fs.mkdirSync(sessionDir, { recursive: true }); + const contentPath = path.join(sessionDir, 'active.md'); + const manifestPath = path.join(sessionDir, 'context.json'); + fs.writeFileSync(contentPath, content); + fs.writeFileSync(manifestPath, JSON.stringify({ + version: 1, + updatedAt, + activeDocument: { + title, + path: `/library/${title}.md`, + kind: 'wiki', + contentMode: 'rendered', + contentPath, + }, + })); + return manifestPath; +} + +test('readCurrentDocumentContext reads newest Field Theory context manifest', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ft-current-')); + try { + const sessionsDir = path.join(tmpDir, 'sessions'); + const olderManifest = writeContext(sessionsDir, 'older', 'Older Page', 'old body', '2026-01-01T00:00:00.000Z'); + const newerManifest = writeContext(sessionsDir, 'newer', 'Newer Page', '# Newer\n', '2026-01-02T00:00:00.000Z'); + const olderTime = new Date('2026-01-01T00:00:00.000Z'); + const newerTime = new Date('2026-01-02T00:00:00.000Z'); + fs.utimesSync(olderManifest, olderTime, olderTime); + fs.utimesSync(newerManifest, newerTime, newerTime); + + assert.equal(findCurrentContextManifest(sessionsDir), newerManifest); + + const context = readCurrentDocumentContext(newerManifest); + assert.equal(context.activeDocument.title, 'Newer Page'); + assert.equal(context.content, '# Newer\n'); + assert.match(formatCurrentDocumentContext(context), /title: Newer Page/); + assert.match(formatCurrentDocumentContext(context), /# Newer/); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('readCurrentDocumentContext rejects content paths outside the session directory', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ft-current-')); + try { + const sessionsDir = path.join(tmpDir, 'sessions'); + const manifestPath = writeContext(sessionsDir, 'session', 'Page', 'body', '2026-01-01T00:00:00.000Z'); + const secretPath = path.join(tmpDir, 'secret.md'); + fs.writeFileSync(secretPath, 'do not read'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + manifest.activeDocument.contentPath = secretPath; + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + + assert.throws( + () => readCurrentDocumentContext(manifestPath), + /must stay inside its session directory/, + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +});