Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 <path>', '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 ──────────────────────────────────────────────────────────────
Expand Down
116 changes: 116 additions & 0 deletions src/current.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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'}`;
}
4 changes: 4 additions & 0 deletions src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 8 additions & 6 deletions src/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <query> --json\`
4. Search bookmarks when reading history or saved X/Twitter posts matter: \`ft search <query> --json\`
5. Inspect exact files or bookmarks with \`ft library show <path> --json\`, \`ft show <id> --json\`, or \`ft commands show <name> --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 <path>\`
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 <query> --json\`
5. Search bookmarks when reading history or saved X/Twitter posts matter: \`ft search <query> --json\`
6. Inspect exact files or bookmarks with \`ft library show <path> --json\`, \`ft show <id> --json\`, or \`ft commands show <name> --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 <path>\`

## Possible Roadmap Workflow

Expand Down Expand Up @@ -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 <query> # Full-text BM25 search ("exact phrase", AND, OR, NOT)
Expand Down
4 changes: 2 additions & 2 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
});
Expand Down
74 changes: 74 additions & 0 deletions tests/current.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});