mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Align duplicated markdown and app-server helpers
This commit is contained in:
parent
1946ecffe0
commit
a9e68e159d
16 changed files with 206 additions and 120 deletions
|
|
@ -1,5 +1,10 @@
|
|||
import type { SkillMetadata } from "../../domain/catalog/metadata";
|
||||
import { type DiagnosticProbeResult, diagnosticProbeError, diagnosticProbeOk } from "../../domain/server/diagnostics";
|
||||
import {
|
||||
type DiagnosticProbeResult,
|
||||
diagnosticProbeError,
|
||||
diagnosticProbeOk,
|
||||
shortDiagnosticErrorMessage,
|
||||
} from "../../domain/server/diagnostics";
|
||||
import {
|
||||
type McpServerDiagnostic,
|
||||
type McpServerStatusSummary,
|
||||
|
|
@ -92,7 +97,7 @@ async function readPlugins(
|
|||
return {
|
||||
items: null,
|
||||
marketplaceErrors: [],
|
||||
error: shortErrorMessage(error),
|
||||
error: shortDiagnosticErrorMessage(error),
|
||||
probe: diagnosticProbeError("plugins", error, checkedAt),
|
||||
};
|
||||
}
|
||||
|
|
@ -118,7 +123,7 @@ async function readMcpServers(
|
|||
} catch (error) {
|
||||
return {
|
||||
items: null,
|
||||
error: shortErrorMessage(error),
|
||||
error: shortDiagnosticErrorMessage(error),
|
||||
probe: diagnosticProbeError("mcpServers", error, checkedAt),
|
||||
};
|
||||
}
|
||||
|
|
@ -142,12 +147,6 @@ async function readSkills(
|
|||
probe: diagnosticProbeOk("skills", `${String(catalog.totalCount)} skills`, checkedAt),
|
||||
};
|
||||
} catch (error) {
|
||||
return { items: null, error: shortErrorMessage(error), probe: diagnosticProbeError("skills", error, checkedAt) };
|
||||
return { items: null, error: shortDiagnosticErrorMessage(error), probe: diagnosticProbeError("skills", error, checkedAt) };
|
||||
}
|
||||
}
|
||||
|
||||
function shortErrorMessage(error: unknown, maxLength = 160): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const compact = message.replace(/\s+/g, " ").trim() || "Codex app-server request failed.";
|
||||
return compact.length > maxLength ? `${compact.slice(0, maxLength - 3)}...` : compact;
|
||||
}
|
||||
|
|
|
|||
7
src/domain/markdown/frontmatter.ts
Normal file
7
src/domain/markdown/frontmatter.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function yamlFrontmatterString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function yamlFrontmatterInlineList(values: readonly string[]): string {
|
||||
return `[${values.map(yamlFrontmatterString).join(", ")}]`;
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ export function diagnosticProbeError(id: DiagnosticProbeId, error: unknown, chec
|
|||
return {
|
||||
id,
|
||||
status: "failed",
|
||||
message: shortErrorMessage(error),
|
||||
message: shortDiagnosticErrorMessage(error),
|
||||
summary: null,
|
||||
checkedAt,
|
||||
};
|
||||
|
|
@ -125,7 +125,7 @@ export function upsertMcpServerStatusDiagnostics(diagnostics: Diagnostics, serve
|
|||
return next;
|
||||
}
|
||||
|
||||
function shortErrorMessage(error: unknown, maxLength = 160): string {
|
||||
export function shortDiagnosticErrorMessage(error: unknown, maxLength = 160): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const compact = message.replace(/\s+/g, " ").trim() || "Codex app-server request failed.";
|
||||
return compact.length > maxLength ? `${compact.slice(0, maxLength - 3)}...` : compact;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { yamlFrontmatterInlineList, yamlFrontmatterString } from "../markdown/frontmatter";
|
||||
import { isExternalFileHref, parseFileHref } from "../vault/file-hrefs";
|
||||
import { isFilesystemAbsolutePath, isVaultConfigPath, normalizeFilePath, vaultRelativePath } from "../vault/paths";
|
||||
import type { Thread } from "./model";
|
||||
|
|
@ -33,9 +34,9 @@ export function archivedThreadMarkdown(
|
|||
const tags = normalizedArchiveTags(settings?.archiveExportTags ?? "");
|
||||
const lines = [
|
||||
"---",
|
||||
`title: ${yamlString(title)}`,
|
||||
`thread_id: ${yamlString(thread.id)}`,
|
||||
`created: ${yamlString(formatDate(exportedAt))}`,
|
||||
`title: ${yamlFrontmatterString(title)}`,
|
||||
`thread_id: ${yamlFrontmatterString(thread.id)}`,
|
||||
`created: ${yamlFrontmatterString(formatDate(exportedAt))}`,
|
||||
...frontmatterTagsLines(tags),
|
||||
"---",
|
||||
"",
|
||||
|
|
@ -131,7 +132,7 @@ function formatUnixTimestamp(unixSeconds: number | null): string | null {
|
|||
}
|
||||
|
||||
function frontmatterTagsLines(tags: string[]): string[] {
|
||||
return tags.length > 0 ? [`tags: [${tags.map(yamlString).join(", ")}]`] : [];
|
||||
return tags.length > 0 ? [`tags: ${yamlFrontmatterInlineList(tags)}`] : [];
|
||||
}
|
||||
|
||||
function stripLeadingHashes(value: string): string {
|
||||
|
|
@ -199,10 +200,6 @@ function isInsideInlineCode(line: string, offset: number): boolean {
|
|||
return inCode;
|
||||
}
|
||||
|
||||
function yamlString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
|
|
|
|||
49
src/domain/vault/markdown-write-templates.ts
Normal file
49
src/domain/vault/markdown-write-templates.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { sanitizeVaultPathSegment, vaultRelativeFolderPath } from "./write-paths";
|
||||
|
||||
interface VaultFolderMessages {
|
||||
emptyPathMessage: string;
|
||||
absolutePathMessage: string;
|
||||
relativeSegmentMessage: string;
|
||||
}
|
||||
|
||||
const TEMPLATE_TOKEN_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9]*)\s*}}/g;
|
||||
|
||||
export function vaultMarkdownTemplateDate(date: Date): string {
|
||||
return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
|
||||
export function vaultMarkdownTemplateTime(date: Date): string {
|
||||
return `${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function expandVaultMarkdownTemplate(template: string, context: object): string {
|
||||
const values = context as Record<string, string | undefined>;
|
||||
return template.replace(TEMPLATE_TOKEN_PATTERN, (match, key: string) => values[key] ?? match);
|
||||
}
|
||||
|
||||
export function vaultMarkdownFolderPath(value: string, normalizePath: (path: string) => string, messages: VaultFolderMessages): string {
|
||||
return vaultRelativeFolderPath(value.trim().replaceAll("\\", "/"), {
|
||||
normalizePath,
|
||||
emptyPathMessage: messages.emptyPathMessage,
|
||||
absolutePathMessage: messages.absolutePathMessage,
|
||||
relativeSegmentMessage: messages.relativeSegmentMessage,
|
||||
});
|
||||
}
|
||||
|
||||
export function vaultMarkdownFilenameFromTemplate(
|
||||
template: string,
|
||||
context: object,
|
||||
normalizePath: (path: string) => string,
|
||||
emptyFilenameMessage: string,
|
||||
): string {
|
||||
const expanded = expandVaultMarkdownTemplate(template, context)
|
||||
.trim()
|
||||
.replace(/[\\/]+/g, "-");
|
||||
const filename = normalizePath(sanitizeVaultPathSegment(expanded));
|
||||
if (!filename || filename === "." || filename === "..") throw new Error(emptyFilenameMessage);
|
||||
return filename.toLowerCase().endsWith(".md") ? filename : `${filename}.md`;
|
||||
}
|
||||
|
||||
function pad2(value: number): string {
|
||||
return value.toString().padStart(2, "0");
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import type { AppServerClient } from "../../../app-server/connection/client";
|
||||
import type { AppServerClientAccess } from "../../../app-server/connection/client-access";
|
||||
import type { AppServerClientAccess, AppServerClientAccessOptions } from "../../../app-server/connection/client-access";
|
||||
import { withShortLivedAppServerClient } from "../../../app-server/connection/short-lived-client";
|
||||
import type { CodexInput } from "../../../domain/chat/input";
|
||||
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
|
||||
import { createThreadReferenceResolver, type ThreadReferenceResolver } from "./thread-reference-resolver";
|
||||
import { type ChatSessionTransports, createChatSessionTransports } from "./transports/session-transports";
|
||||
|
||||
export interface ChatAppServerGatewayHost {
|
||||
codexPath(): string;
|
||||
vaultPath: string;
|
||||
currentClient(): AppServerClient | null;
|
||||
connectedClient(): Promise<AppServerClient | null>;
|
||||
|
|
@ -26,7 +28,7 @@ export interface ChatAppServerGateway extends ChatSessionTransports {
|
|||
|
||||
export function createChatAppServerGateway(host: ChatAppServerGatewayHost): ChatAppServerGateway {
|
||||
return {
|
||||
clientAccess: createCurrentClientAccess(() => host.currentClient()),
|
||||
clientAccess: createCurrentClientAccess(host),
|
||||
...createChatSessionTransports(host),
|
||||
connectionAvailable: () => host.currentClient() !== null,
|
||||
readFileBase64: (path, options) => readCurrentClientFileBase64(host, path, options),
|
||||
|
|
@ -44,13 +46,17 @@ export function createChatAppServerGateway(host: ChatAppServerGatewayHost): Chat
|
|||
};
|
||||
}
|
||||
|
||||
function createCurrentClientAccess(currentClient: () => AppServerClient | null): AppServerClientAccess {
|
||||
function createCurrentClientAccess(host: ChatAppServerGatewayHost): AppServerClientAccess {
|
||||
return {
|
||||
withClient: async (operation) => {
|
||||
const client = currentClient();
|
||||
withClient: async (operation, options: AppServerClientAccessOptions = {}) => {
|
||||
if (options.serverRequests?.kind === "reject") {
|
||||
return withShortLivedAppServerClient(host.codexPath(), host.vaultPath, operation, options);
|
||||
}
|
||||
|
||||
const client = host.currentClient();
|
||||
if (!client) throw new Error("Codex app-server is not connected.");
|
||||
const result = await operation(client);
|
||||
if (currentClient() !== client) {
|
||||
if (host.currentClient() !== client) {
|
||||
throw new Error("Codex app-server connection changed while running the operation.");
|
||||
}
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { yamlFrontmatterInlineList, yamlFrontmatterString } from "../../../../domain/markdown/frontmatter";
|
||||
import {
|
||||
vaultMarkdownFilenameFromTemplate,
|
||||
vaultMarkdownFolderPath,
|
||||
vaultMarkdownTemplateDate,
|
||||
vaultMarkdownTemplateTime,
|
||||
} from "../../../../domain/vault/markdown-write-templates";
|
||||
import {
|
||||
ensureVaultFolder,
|
||||
sanitizeVaultPathSegment,
|
||||
uniqueVaultPath,
|
||||
type VaultMarkdownDestination,
|
||||
vaultRelativeFolderPath,
|
||||
} from "../../../../domain/vault/write-paths";
|
||||
|
||||
export interface WebClipSettings {
|
||||
|
|
@ -59,9 +65,9 @@ export function webClipMarkdown(page: WebClipPage, settings: Pick<WebClipSetting
|
|||
const tags = normalizedClipTags(settings.clipTags ?? "");
|
||||
const frontmatter = [
|
||||
"---",
|
||||
`title: ${yamlString(normalizedTitle)}`,
|
||||
`url: ${yamlString(page.url)}`,
|
||||
`created: ${yamlString(now.toISOString())}`,
|
||||
`title: ${yamlFrontmatterString(normalizedTitle)}`,
|
||||
`url: ${yamlFrontmatterString(page.url)}`,
|
||||
`created: ${yamlFrontmatterString(now.toISOString())}`,
|
||||
...frontmatterTagsLines(tags),
|
||||
"---",
|
||||
"",
|
||||
|
|
@ -72,8 +78,8 @@ export function webClipMarkdown(page: WebClipPage, settings: Pick<WebClipSetting
|
|||
function templateContext(page: WebClipPage, now: Date): TemplateContext {
|
||||
const title = sanitizeVaultPathSegment(normalizedDisplayTitle(page.title));
|
||||
return {
|
||||
date: formatDate(now),
|
||||
time: formatTime(now),
|
||||
date: vaultMarkdownTemplateDate(now),
|
||||
time: vaultMarkdownTemplateTime(now),
|
||||
title,
|
||||
site: sanitizeVaultPathSegment(page.site?.trim() || ""),
|
||||
domain: sanitizeVaultPathSegment(page.domain?.trim() || hostnameFromUrl(page.url) || ""),
|
||||
|
|
@ -84,13 +90,8 @@ function normalizedDisplayTitle(title: string): string {
|
|||
return title.replace(/\s+/g, " ").trim() || DEFAULT_CLIP_TITLE;
|
||||
}
|
||||
|
||||
function expandTemplate(template: string, context: TemplateContext): string {
|
||||
return template.replace(/{{\s*(date|time|title|site|domain)\s*}}/g, (_match, key: keyof TemplateContext) => context[key]);
|
||||
}
|
||||
|
||||
function folderPath(value: string, normalizePath: (path: string) => string): string {
|
||||
return vaultRelativeFolderPath(value, {
|
||||
normalizePath,
|
||||
return vaultMarkdownFolderPath(value, normalizePath, {
|
||||
emptyPathMessage: "Clip folder produced an empty path.",
|
||||
absolutePathMessage: "Clip folder must be relative to the vault.",
|
||||
relativeSegmentMessage: "Clip folder cannot contain relative path segments.",
|
||||
|
|
@ -98,12 +99,7 @@ function folderPath(value: string, normalizePath: (path: string) => string): str
|
|||
}
|
||||
|
||||
function filenameFromTemplate(template: string, context: TemplateContext, normalizePath: (path: string) => string): string {
|
||||
const expanded = expandTemplate(template, context)
|
||||
.trim()
|
||||
.replace(/[\\/]+/g, "-");
|
||||
const filename = normalizePath(sanitizeVaultPathSegment(expanded));
|
||||
if (!filename || filename === "." || filename === "..") throw new Error("Clip filename template produced an empty filename.");
|
||||
return filename.toLowerCase().endsWith(".md") ? filename : `${filename}.md`;
|
||||
return vaultMarkdownFilenameFromTemplate(template, context, normalizePath, "Clip filename template produced an empty filename.");
|
||||
}
|
||||
|
||||
function normalizedClipTags(input: string): string[] {
|
||||
|
|
@ -123,11 +119,7 @@ function normalizedClipTags(input: string): string[] {
|
|||
}
|
||||
|
||||
function frontmatterTagsLines(tags: string[]): string[] {
|
||||
return tags.length > 0 ? [`tags: [${tags.map(yamlString).join(", ")}]`] : [];
|
||||
}
|
||||
|
||||
function yamlString(value: string): string {
|
||||
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
return tags.length > 0 ? [`tags: ${yamlFrontmatterInlineList(tags)}`] : [];
|
||||
}
|
||||
|
||||
function hostnameFromUrl(url: string): string {
|
||||
|
|
@ -137,15 +129,3 @@ function hostnameFromUrl(url: string): string {
|
|||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return `${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function pad2(value: number): string {
|
||||
return value.toString().padStart(2, "0");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
import { truncate } from "../../../../../domain/display/text-preview";
|
||||
|
||||
export function agentMessagePreview(message: string | null, maxLength: number): string | null {
|
||||
if (!message) return null;
|
||||
const firstLine = message
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0);
|
||||
if (!firstLine) return null;
|
||||
return truncate(firstLine.replace(/\s+/g, " "), maxLength);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { truncate } from "../../../../../domain/display/text-preview";
|
||||
import { agentMessagePreview } from "../format/agent-message-preview";
|
||||
import type {
|
||||
AgentRunSummary,
|
||||
AgentRunSummaryAgent,
|
||||
|
|
@ -118,16 +118,6 @@ function activeAgentRunSummaryAnchorId(items: readonly MessageStreamSemanticClas
|
|||
return firstActiveAgent?.item.id ?? null;
|
||||
}
|
||||
|
||||
function agentMessagePreview(message: string | null, maxLength: number): string | null {
|
||||
if (!message) return null;
|
||||
const firstLine = message
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0);
|
||||
if (!firstLine) return null;
|
||||
return truncate(firstLine.replace(/\s+/g, " "), maxLength);
|
||||
}
|
||||
|
||||
function agentRunState(agent: AgentStateSummary): AgentRunState {
|
||||
return agent.executionState ?? "running";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
|
|||
return currentClient();
|
||||
};
|
||||
const appServer = createChatAppServerGateway({
|
||||
codexPath: () => environment.plugin.settingsRef.settings.codexPath(),
|
||||
vaultPath: environment.plugin.settingsRef.vaultPath,
|
||||
currentClient,
|
||||
connectedClient,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { truncate } from "../../../../domain/display/text-preview";
|
||||
import { shortThreadId } from "../../../../domain/threads/id";
|
||||
import { pathRelativeToRoot } from "../../../../domain/vault/paths";
|
||||
import { agentMessagePreview } from "../../domain/message-stream/format/agent-message-preview";
|
||||
import type {
|
||||
AgentMessageStreamItem,
|
||||
ApprovalResultMessageStreamItem,
|
||||
|
|
@ -449,16 +450,6 @@ function agentStatusLabel(status: string, message: string | null): string {
|
|||
return preview ? `${status}: ${preview}` : status;
|
||||
}
|
||||
|
||||
function agentMessagePreview(message: string | null, maxLength: number): string | null {
|
||||
if (!message) return null;
|
||||
const firstLine = message
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0);
|
||||
if (!firstLine) return null;
|
||||
return truncate(firstLine.replace(/\s+/g, " "), maxLength);
|
||||
}
|
||||
|
||||
function isLongAgentMessage(message: string): boolean {
|
||||
return message.length > AGENT_ROW_MESSAGE_PREVIEW_LIMIT || message.includes("\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import { type ArchiveExportSettings, type ArchiveThreadInput, archivedThreadMarkdown } from "../../../domain/threads/archive-markdown";
|
||||
import { shortThreadId } from "../../../domain/threads/id";
|
||||
import { threadArchiveTitle } from "../../../domain/threads/title";
|
||||
import {
|
||||
vaultMarkdownFilenameFromTemplate,
|
||||
vaultMarkdownFolderPath,
|
||||
vaultMarkdownTemplateDate,
|
||||
vaultMarkdownTemplateTime,
|
||||
} from "../../../domain/vault/markdown-write-templates";
|
||||
import {
|
||||
ensureVaultFolder,
|
||||
sanitizeVaultPathSegment,
|
||||
uniqueVaultPath,
|
||||
type VaultMarkdownDestination,
|
||||
vaultRelativeFolderPath,
|
||||
} from "../../../domain/vault/write-paths";
|
||||
|
||||
export interface ArchiveExportResult {
|
||||
|
|
@ -31,7 +36,7 @@ export async function exportArchivedThreadMarkdown(
|
|||
): Promise<ArchiveExportResult> {
|
||||
const context = templateContext(thread, now);
|
||||
const normalizePath = (path: string): string => destination.normalizePath(path);
|
||||
const folder = folderPathFromTemplate(settings.archiveExportFolderTemplate, context, normalizePath);
|
||||
const folder = folderPath(settings.archiveExportFolderTemplate, normalizePath);
|
||||
const filename = filenameFromTemplate(settings.archiveExportFilenameTemplate, context, normalizePath);
|
||||
await ensureVaultFolder(destination, folder);
|
||||
|
||||
|
|
@ -43,47 +48,27 @@ export async function exportArchivedThreadMarkdown(
|
|||
function templateContext(thread: ArchiveThreadInput, now: Date): TemplateContext {
|
||||
const title = sanitizeVaultPathSegment(threadArchiveTitle(thread));
|
||||
return {
|
||||
date: formatDate(now),
|
||||
time: formatTime(now),
|
||||
date: vaultMarkdownTemplateDate(now),
|
||||
time: vaultMarkdownTemplateTime(now),
|
||||
title,
|
||||
id: sanitizeVaultPathSegment(thread.id),
|
||||
shortId: sanitizeVaultPathSegment(shortThreadId(thread.id)),
|
||||
};
|
||||
}
|
||||
|
||||
function expandTemplate(template: string, context: TemplateContext): string {
|
||||
return template.replace(/{{\s*(date|time|title|id|shortId)\s*}}/g, (_match, key: keyof TemplateContext) => context[key]);
|
||||
}
|
||||
|
||||
function folderPathFromTemplate(template: string, context: TemplateContext, normalizePath: (path: string) => string): string {
|
||||
const expanded = expandTemplate(template, context).trim().replaceAll("\\", "/");
|
||||
return vaultRelativeFolderPath(expanded, {
|
||||
normalizePath,
|
||||
emptyPathMessage: "Archive export folder template produced an empty path.",
|
||||
function folderPath(value: string, normalizePath: (path: string) => string): string {
|
||||
return vaultMarkdownFolderPath(value, normalizePath, {
|
||||
emptyPathMessage: "Archive export folder produced an empty path.",
|
||||
absolutePathMessage: "Archive export folder must be relative to the vault.",
|
||||
relativeSegmentMessage: "Archive export folder cannot contain relative path segments.",
|
||||
});
|
||||
}
|
||||
|
||||
function filenameFromTemplate(template: string, context: TemplateContext, normalizePath: (path: string) => string): string {
|
||||
const expanded = expandTemplate(template, context)
|
||||
.trim()
|
||||
.replace(/[\\/]+/g, "-");
|
||||
const filename = normalizePath(sanitizeVaultPathSegment(expanded));
|
||||
if (!filename || filename === "." || filename === "..") {
|
||||
throw new Error("Archive export filename template produced an empty filename.");
|
||||
}
|
||||
return filename.toLowerCase().endsWith(".md") ? filename : `${filename}.md`;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return `${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function pad2(value: number): string {
|
||||
return value.toString().padStart(2, "0");
|
||||
return vaultMarkdownFilenameFromTemplate(
|
||||
template,
|
||||
context,
|
||||
normalizePath,
|
||||
"Archive export filename template produced an empty filename.",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState })
|
|||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow name="Saved note folder" desc="Vault-relative folder template for archived thread notes.">
|
||||
<SettingRow name="Saved note folder" desc="Vault-relative folder for archived thread notes.">
|
||||
<ObsidianCommitTextInput
|
||||
placeholder={DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE}
|
||||
value={state.exportFolderTemplate}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AppServerClient, ClientResponseByMethod } from "../../../../src/app-server/connection/client";
|
||||
import * as shortLivedClient from "../../../../src/app-server/connection/short-lived-client";
|
||||
import type { ThreadRecord } from "../../../../src/app-server/protocol/thread";
|
||||
import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn";
|
||||
import type { CodexInput } from "../../../../src/domain/chat/input";
|
||||
|
|
@ -342,16 +343,68 @@ describe("chat app-server transports", () => {
|
|||
expect(result?.referencedThread).toMatchObject({ title: "Other", includedTurns: 1, turnLimit: 20 });
|
||||
expect(setStatus).toHaveBeenCalledWith("Referencing 019abcde (1/20 turns).");
|
||||
});
|
||||
|
||||
it("uses a short-lived client for clientAccess operations that reject server requests", async () => {
|
||||
const currentRequest = vi.fn();
|
||||
const currentClient = { request: currentRequest } as unknown as AppServerClient;
|
||||
const shortClient = { request: vi.fn().mockResolvedValue("short-lived") } as unknown as AppServerClient;
|
||||
const withShortLived = vi
|
||||
.spyOn(shortLivedClient, "withShortLivedAppServerClient")
|
||||
.mockImplementation(async (_codexPath, _cwd, operation) => {
|
||||
return operation(shortClient);
|
||||
});
|
||||
const gateway = createTestGateway({
|
||||
codexPath: "/usr/local/bin/codex",
|
||||
currentClient: () => currentClient,
|
||||
});
|
||||
|
||||
await expect(
|
||||
gateway.clientAccess.withClient((client) => client.request("thread/list", {}), {
|
||||
serverRequests: { kind: "reject", message: "Background operation cannot answer server requests." },
|
||||
}),
|
||||
).resolves.toBe("short-lived");
|
||||
|
||||
expect(withShortLived).toHaveBeenCalledWith("/usr/local/bin/codex", "/vault", expect.any(Function), {
|
||||
serverRequests: { kind: "reject", message: "Background operation cannot answer server requests." },
|
||||
});
|
||||
expect(currentRequest).not.toHaveBeenCalled();
|
||||
withShortLived.mockRestore();
|
||||
});
|
||||
|
||||
it("reads the current Codex command when creating short-lived clientAccess clients", async () => {
|
||||
let codexPath = "/first/codex";
|
||||
const shortClient = { request: vi.fn().mockResolvedValue("short-lived") } as unknown as AppServerClient;
|
||||
const withShortLived = vi
|
||||
.spyOn(shortLivedClient, "withShortLivedAppServerClient")
|
||||
.mockImplementation(async (_codexPath, _cwd, operation) => {
|
||||
return operation(shortClient);
|
||||
});
|
||||
const gateway = createTestGateway({
|
||||
codexPath: () => codexPath,
|
||||
currentClient: () => ({ request: vi.fn() }) as unknown as AppServerClient,
|
||||
});
|
||||
|
||||
codexPath = "/second/codex";
|
||||
await gateway.clientAccess.withClient((client) => client.request("thread/list", {}), {
|
||||
serverRequests: { kind: "reject", message: "Background operation cannot answer server requests." },
|
||||
});
|
||||
|
||||
expect(withShortLived).toHaveBeenCalledWith("/second/codex", "/vault", expect.any(Function), expect.any(Object));
|
||||
withShortLived.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
type AppServerThreadResumeResponse = ClientResponseByMethod["thread/resume"];
|
||||
|
||||
function createTestGateway(options: {
|
||||
codexPath?: string | (() => string);
|
||||
vaultPath?: string;
|
||||
currentClient: () => AppServerClient | null;
|
||||
connectedClient?: () => Promise<AppServerClient | null>;
|
||||
}) {
|
||||
const codexPath = options.codexPath;
|
||||
return createChatAppServerGateway({
|
||||
codexPath: typeof codexPath === "function" ? codexPath : () => codexPath ?? "codex",
|
||||
vaultPath: options.vaultPath ?? "/vault",
|
||||
currentClient: options.currentClient,
|
||||
connectedClient: options.connectedClient ?? (async () => options.currentClient()),
|
||||
|
|
|
|||
|
|
@ -36,6 +36,23 @@ describe("web clipping", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("escapes frontmatter strings with YAML-safe JSON string literals", () => {
|
||||
const output = webClipMarkdown(
|
||||
{
|
||||
url: "https://example.com/post?title=line\nbreak",
|
||||
title: "Example Post",
|
||||
content: "Body",
|
||||
},
|
||||
{ clipTags: "web\nclip, tab\tvalue" },
|
||||
"Example\nPost",
|
||||
new Date("2026-07-05T03:04:05.000Z"),
|
||||
);
|
||||
|
||||
expect(output).toContain('title: "Example Post"');
|
||||
expect(output).toContain('url: "https://example.com/post?title=line\\nbreak"');
|
||||
expect(output).toContain('tags: ["web\\nclip", "tab\\tvalue"]');
|
||||
});
|
||||
|
||||
it("saves clips under a unique path from the filename template", async () => {
|
||||
const destination = memoryDestination(["Codex Clippings/Example Site - Example-Post.md"]);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import type { ThreadTranscriptEntry } from "../../../../src/domain/threads/trans
|
|||
import { type ArchiveExportDestination, exportArchivedThreadMarkdown } from "../../../../src/features/threads/workflows/archive-export";
|
||||
|
||||
describe("archive export workflow", () => {
|
||||
it("expands templates, sanitizes paths, creates folders, and preserves existing files", async () => {
|
||||
const destination = new MemoryDestination(["Codex Archives/2026-05-18/My-Thread- abcdef12.md"]);
|
||||
it("uses a fixed folder path and expands the filename template", async () => {
|
||||
const destination = new MemoryDestination(["Codex Archives/{{date}}/My-Thread- abcdef12.md"]);
|
||||
|
||||
const result = await exportArchivedThreadMarkdown(
|
||||
thread({ id: "abcdef12-9999", name: "My/Thread?" }),
|
||||
|
|
@ -19,9 +19,9 @@ describe("archive export workflow", () => {
|
|||
new Date(2026, 4, 18, 9, 8, 7),
|
||||
);
|
||||
|
||||
expect(result.path).toBe("Codex Archives/2026-05-18/My-Thread- abcdef12 2.md");
|
||||
expect(result.path).toBe("Codex Archives/{{date}}/My-Thread- abcdef12 2.md");
|
||||
expect(destination.folders).toContain("Codex Archives");
|
||||
expect(destination.folders).toContain("Codex Archives/2026-05-18");
|
||||
expect(destination.folders).toContain("Codex Archives/{{date}}");
|
||||
expect(destination.files.get(result.path)).toContain('thread_id: "abcdef12-9999"');
|
||||
expect(destination.files.get(result.path)).toContain('tags: ["codex", "archive"]');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue