@actagent/diffs 2026.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,159 @@
1
+ // Diffs plugin module implements language hints behavior.
2
+ import { resolveLanguage } from "@pierre/diffs";
3
+ import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
4
+ import {
5
+ bundledLanguagesBase,
6
+ bundledLanguagesInfo,
7
+ getBundledLanguageAliases,
8
+ } from "./shiki-curated-languages.js";
9
+ import type { DiffViewerPayload } from "./types.js";
10
+
11
+ export const BASE_DIFF_VIEWER_LANGUAGE_HINTS = [
12
+ ...Object.keys(bundledLanguagesBase),
13
+ "text",
14
+ "ansi",
15
+ ] as const satisfies readonly SupportedLanguages[];
16
+ export type DiffViewerBaseLanguage = (typeof BASE_DIFF_VIEWER_LANGUAGE_HINTS)[number];
17
+
18
+ const BASE_LANGUAGE_HINTS = new Set<SupportedLanguages>(BASE_DIFF_VIEWER_LANGUAGE_HINTS);
19
+ const BASE_LANGUAGE_ALIASES = new Map<string, SupportedLanguages>(
20
+ bundledLanguagesInfo.flatMap((language) =>
21
+ getBundledLanguageAliases(language).map((alias) => [alias, language.id as SupportedLanguages]),
22
+ ),
23
+ );
24
+ type DiffPayloadFile = FileContents | FileDiffMetadata;
25
+
26
+ function normalizeOptionalString(value: unknown): string | undefined {
27
+ if (typeof value !== "string") {
28
+ return undefined;
29
+ }
30
+ const trimmed = value.trim();
31
+ return trimmed ? trimmed : undefined;
32
+ }
33
+
34
+ export async function normalizeSupportedLanguageHint(
35
+ value?: string,
36
+ options: { languagePackAvailable?: boolean } = {},
37
+ ): Promise<SupportedLanguages | undefined> {
38
+ const normalized = normalizeOptionalString(value);
39
+ if (!normalized) {
40
+ return undefined;
41
+ }
42
+ const baseAlias = BASE_LANGUAGE_ALIASES.get(normalized);
43
+ if (baseAlias) {
44
+ return baseAlias;
45
+ }
46
+ if (BASE_LANGUAGE_HINTS.has(normalized as SupportedLanguages)) {
47
+ return normalized as SupportedLanguages;
48
+ }
49
+ if (!options.languagePackAvailable) {
50
+ return undefined;
51
+ }
52
+ try {
53
+ await resolveLanguage(normalized as Exclude<SupportedLanguages, "text" | "ansi">);
54
+ return normalized as SupportedLanguages;
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ export async function filterSupportedLanguageHints(
61
+ values: Iterable<string>,
62
+ options: { languagePackAvailable?: boolean } = {},
63
+ ): Promise<SupportedLanguages[]> {
64
+ return normalizeSupportedLanguageHints(values, { fallbackToText: true, ...options });
65
+ }
66
+
67
+ async function normalizeSupportedLanguageHints(
68
+ values: Iterable<string>,
69
+ options: { fallbackToText: boolean; languagePackAvailable?: boolean },
70
+ ): Promise<SupportedLanguages[]> {
71
+ const supported = new Set<SupportedLanguages>();
72
+ for (const value of values) {
73
+ const normalized = await normalizeSupportedLanguageHint(value, options);
74
+ if (!normalized) {
75
+ continue;
76
+ }
77
+ supported.add(normalized);
78
+ }
79
+ if (options.fallbackToText && supported.size === 0) {
80
+ supported.add("text");
81
+ }
82
+ return [...supported];
83
+ }
84
+
85
+ export function collectDiffPayloadLanguageHints(payload: {
86
+ fileDiff?: FileDiffMetadata;
87
+ oldFile?: FileContents;
88
+ newFile?: FileContents;
89
+ }): SupportedLanguages[] {
90
+ const langs = new Set<SupportedLanguages>();
91
+ if (payload.fileDiff?.lang) {
92
+ langs.add(payload.fileDiff.lang);
93
+ }
94
+ if (payload.oldFile?.lang) {
95
+ langs.add(payload.oldFile.lang);
96
+ }
97
+ if (payload.newFile?.lang) {
98
+ langs.add(payload.newFile.lang);
99
+ }
100
+ return [...langs];
101
+ }
102
+
103
+ async function normalizeDiffPayloadFileLanguage(
104
+ file: DiffPayloadFile | undefined,
105
+ options: { languagePackAvailable?: boolean },
106
+ ): Promise<DiffPayloadFile | undefined> {
107
+ if (!file) {
108
+ return undefined;
109
+ }
110
+ if (typeof file.lang !== "string") {
111
+ return file;
112
+ }
113
+ const normalized = await normalizeSupportedLanguageHint(file.lang, options);
114
+ if (file.lang === normalized) {
115
+ return file;
116
+ }
117
+ if (!normalized) {
118
+ return {
119
+ ...file,
120
+ lang: "text",
121
+ };
122
+ }
123
+ return {
124
+ ...file,
125
+ lang: normalized,
126
+ };
127
+ }
128
+
129
+ export async function normalizeDiffViewerPayloadLanguages(
130
+ payload: DiffViewerPayload,
131
+ options: { languagePackAvailable?: boolean } = {},
132
+ ): Promise<DiffViewerPayload> {
133
+ const [fileDiff, oldFile, newFile, payloadLangs] = await Promise.all([
134
+ normalizeDiffPayloadFileLanguage(payload.fileDiff, options) as Promise<
135
+ FileDiffMetadata | undefined
136
+ >,
137
+ normalizeDiffPayloadFileLanguage(payload.oldFile, options) as Promise<FileContents | undefined>,
138
+ normalizeDiffPayloadFileLanguage(payload.newFile, options) as Promise<FileContents | undefined>,
139
+ normalizeSupportedLanguageHints(payload.langs, { fallbackToText: false, ...options }),
140
+ ]);
141
+ const langs = new Set<SupportedLanguages>(payloadLangs);
142
+ for (const lang of collectDiffPayloadLanguageHints({ fileDiff, oldFile, newFile })) {
143
+ langs.add(lang);
144
+ }
145
+ if (langs.size === 0) {
146
+ langs.add("text");
147
+ }
148
+ return {
149
+ ...payload,
150
+ fileDiff,
151
+ oldFile,
152
+ newFile,
153
+ langs: [...langs],
154
+ };
155
+ }
156
+
157
+ export function isBaseDiffViewerLanguage(lang: string): boolean {
158
+ return BASE_LANGUAGE_HINTS.has(lang as SupportedLanguages);
159
+ }
@@ -0,0 +1,17 @@
1
+ // Diffs tests cover manifest plugin behavior.
2
+ import fs from "node:fs";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ type DiffsPackageManifest = {
6
+ dependencies?: Record<string, string>;
7
+ };
8
+
9
+ describe("diffs package manifest", () => {
10
+ it("keeps runtime dependencies in the package manifest", () => {
11
+ const packageJson = JSON.parse(
12
+ fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
13
+ ) as DiffsPackageManifest;
14
+
15
+ expect(packageJson.dependencies).toHaveProperty("@pierre/diffs");
16
+ });
17
+ });
@@ -0,0 +1,60 @@
1
+ // Diffs plugin module implements pierre themes behavior.
2
+ import { createRequire } from "node:module";
3
+ import type { ThemeRegistrationResolved } from "@pierre/diffs";
4
+ import { RegisteredCustomThemes, ResolvedThemes, ResolvingThemes } from "@pierre/diffs";
5
+ import { readJsonFileWithFallback } from "actagent/plugin-sdk/json-store";
6
+
7
+ type PierreThemeName = "pierre-dark" | "pierre-light";
8
+ const themeRequire = createRequire(import.meta.url);
9
+ const PIERRE_THEME_SPECS = [
10
+ ["pierre-dark", "@pierre/theme/themes/pierre-dark.json"],
11
+ ["pierre-light", "@pierre/theme/themes/pierre-light.json"],
12
+ ] as const satisfies ReadonlyArray<readonly [PierreThemeName, string]>;
13
+
14
+ function createThemeLoader(
15
+ themeName: PierreThemeName,
16
+ themeSpecifier: string,
17
+ ): () => Promise<ThemeRegistrationResolved> {
18
+ let cachedTheme: ThemeRegistrationResolved | undefined;
19
+ return async () => {
20
+ if (cachedTheme) {
21
+ return cachedTheme;
22
+ }
23
+ const themePath = themeRequire.resolve(themeSpecifier);
24
+ const { value: theme } = await readJsonFileWithFallback<Record<string, unknown>>(themePath, {});
25
+ cachedTheme = {
26
+ ...theme,
27
+ name: themeName,
28
+ } as ThemeRegistrationResolved;
29
+ return cachedTheme;
30
+ };
31
+ }
32
+
33
+ const PIERRE_THEME_LOADERS = new Map(
34
+ PIERRE_THEME_SPECS.map(([themeName, themeSpecifier]) => [
35
+ themeName,
36
+ createThemeLoader(themeName, themeSpecifier),
37
+ ]),
38
+ );
39
+
40
+ export function ensurePierreThemesRegistered(): void {
41
+ let replacedThemeLoader = false;
42
+
43
+ for (const [themeName, loader] of PIERRE_THEME_LOADERS) {
44
+ if (RegisteredCustomThemes.get(themeName) !== loader) {
45
+ RegisteredCustomThemes.set(themeName, loader);
46
+ replacedThemeLoader = true;
47
+ }
48
+ }
49
+
50
+ if (!replacedThemeLoader) {
51
+ return;
52
+ }
53
+
54
+ // If another path swapped these loaders, clear the resolver caches so the
55
+ // next render rehydrates the highlighter with the Node-safe theme source.
56
+ for (const [themeName] of PIERRE_THEME_LOADERS) {
57
+ ResolvedThemes.delete(themeName);
58
+ ResolvingThemes.delete(themeName);
59
+ }
60
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,111 @@
1
+ // Diffs plugin module implements plugin behavior.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { resolveLivePluginConfigObject } from "actagent/plugin-sdk/plugin-config-runtime";
5
+ import {
6
+ resolvePreferredACTAgentTmpDir,
7
+ type ACTAgentConfig,
8
+ type ACTAgentPluginApi,
9
+ } from "../api.js";
10
+ import {
11
+ resolveDiffsPluginDefaults,
12
+ resolveDiffsPluginSecurity,
13
+ resolveDiffsPluginViewerBaseUrl,
14
+ } from "./config.js";
15
+ import { createDiffsHttpHandler } from "./http.js";
16
+ import { DIFFS_AGENT_GUIDANCE } from "./prompt-guidance.js";
17
+ import { DiffArtifactStore } from "./store.js";
18
+ import { createDiffsTool } from "./tool.js";
19
+
20
+ const DIFFS_LANGUAGE_PACK_PLUGIN_ID = "diffs-language-pack";
21
+
22
+ export function registerDiffsPlugin(api: ACTAgentPluginApi): void {
23
+ const store = new DiffArtifactStore({
24
+ rootDir: path.join(resolvePreferredACTAgentTmpDir(), "actagent-diffs"),
25
+ logger: api.logger,
26
+ });
27
+ const resolveCurrentPluginConfig = () =>
28
+ resolveLivePluginConfigObject(
29
+ api.runtime.config?.current
30
+ ? () => api.runtime.config.current() as ACTAgentConfig
31
+ : undefined,
32
+ "diffs",
33
+ api.pluginConfig as Record<string, unknown>,
34
+ ) ?? {};
35
+ const resolveCurrentAccessConfig = () => {
36
+ const currentConfig = (api.runtime.config?.current?.() ?? api.config) as ACTAgentConfig;
37
+ const pluginConfig = resolveCurrentPluginConfig();
38
+ return {
39
+ allowRemoteViewer: resolveDiffsPluginSecurity(pluginConfig).allowRemoteViewer,
40
+ trustedProxies: currentConfig.gateway?.trustedProxies,
41
+ allowRealIpFallback: currentConfig.gateway?.allowRealIpFallback === true,
42
+ };
43
+ };
44
+ const initialAccessConfig = resolveCurrentAccessConfig();
45
+
46
+ api.registerTool(
47
+ (ctx) => {
48
+ const pluginConfig = resolveCurrentPluginConfig();
49
+ return createDiffsTool({
50
+ api,
51
+ store,
52
+ defaults: resolveDiffsPluginDefaults(pluginConfig),
53
+ viewerBaseUrl: resolveDiffsPluginViewerBaseUrl(pluginConfig),
54
+ languagePackAvailable: resolveDiffsLanguagePackAvailability(api),
55
+ context: ctx,
56
+ });
57
+ },
58
+ {
59
+ name: "diffs",
60
+ },
61
+ );
62
+ api.registerHttpRoute({
63
+ path: "/plugins/diffs",
64
+ auth: "plugin",
65
+ match: "prefix",
66
+ handler: createDiffsHttpHandler({
67
+ store,
68
+ logger: api.logger,
69
+ allowRemoteViewer: initialAccessConfig.allowRemoteViewer,
70
+ trustedProxies: initialAccessConfig.trustedProxies,
71
+ allowRealIpFallback: initialAccessConfig.allowRealIpFallback,
72
+ resolveAccessConfig: resolveCurrentAccessConfig,
73
+ }),
74
+ });
75
+ api.on("before_prompt_build", async () => ({
76
+ prependSystemContext: DIFFS_AGENT_GUIDANCE,
77
+ }));
78
+ }
79
+
80
+ export function resolveDiffsLanguagePackAvailability(api: ACTAgentPluginApi): boolean {
81
+ const currentConfig = (api.runtime.config?.current?.() ?? api.config) as ACTAgentConfig;
82
+ const plugins = currentConfig.plugins;
83
+ if (plugins?.enabled === false) {
84
+ return false;
85
+ }
86
+ if (plugins?.deny?.includes(DIFFS_LANGUAGE_PACK_PLUGIN_ID)) {
87
+ return false;
88
+ }
89
+ if (plugins?.allow && !plugins.allow.includes(DIFFS_LANGUAGE_PACK_PLUGIN_ID)) {
90
+ return false;
91
+ }
92
+ if (plugins?.entries?.[DIFFS_LANGUAGE_PACK_PLUGIN_ID]?.enabled === false) {
93
+ return false;
94
+ }
95
+ return hasSiblingLanguagePackRuntime(api.rootDir);
96
+ }
97
+
98
+ function hasSiblingLanguagePackRuntime(rootDir: string | undefined): boolean {
99
+ if (!rootDir) {
100
+ return false;
101
+ }
102
+ const languagePackRoot = path.join(path.dirname(rootDir), DIFFS_LANGUAGE_PACK_PLUGIN_ID);
103
+ const runtimePaths = [
104
+ path.join(languagePackRoot, "assets", "viewer-runtime.js"),
105
+ path.join(languagePackRoot, "dist", "assets", "viewer-runtime.js"),
106
+ ];
107
+ return (
108
+ fs.existsSync(path.join(languagePackRoot, "actagent.plugin.json")) &&
109
+ runtimePaths.some((runtimePath) => fs.existsSync(runtimePath))
110
+ );
111
+ }
@@ -0,0 +1,8 @@
1
+ // Diffs plugin module implements prompt guidance behavior.
2
+ export const DIFFS_AGENT_GUIDANCE = [
3
+ "When you need to show edits as a real diff, prefer the `diffs` tool instead of writing a manual summary.",
4
+ "It accepts either `before` + `after` text or a unified `patch`.",
5
+ "`mode=view` returns `details.viewerUrl` for canvas use; `mode=file` returns `details.filePath`; `mode=both` returns both.",
6
+ "If you need to send the rendered file, use the `message` tool with `path` or `filePath`.",
7
+ "Include `path` when you know the filename, and omit presentation overrides unless needed.",
8
+ ].join("\n");
@@ -0,0 +1,133 @@
1
+ // Diffs tests cover render target plugin behavior.
2
+ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const { preloadFileDiffMock, preloadMultiFileDiffMock } = vi.hoisted(() => ({
5
+ preloadFileDiffMock: vi.fn(async ({ fileDiff }: { fileDiff: unknown }) => ({
6
+ prerenderedHTML: "<div>mock diff</div>",
7
+ fileDiff,
8
+ })),
9
+ preloadMultiFileDiffMock: vi.fn(
10
+ async ({ oldFile, newFile }: { oldFile: unknown; newFile: unknown }) => ({
11
+ prerenderedHTML: "<div>mock diff</div>",
12
+ oldFile,
13
+ newFile,
14
+ }),
15
+ ),
16
+ }));
17
+
18
+ vi.mock("@pierre/diffs/ssr", () => ({
19
+ preloadFileDiff: preloadFileDiffMock,
20
+ preloadMultiFileDiff: preloadMultiFileDiffMock,
21
+ }));
22
+
23
+ afterAll(() => {
24
+ vi.doUnmock("@pierre/diffs/ssr");
25
+ vi.resetModules();
26
+ });
27
+
28
+ import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js";
29
+ import { renderDiffDocument } from "./render.js";
30
+ import { parseViewerPayloadJson } from "./viewer-payload.js";
31
+
32
+ function createRenderOptions() {
33
+ return {
34
+ presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
35
+ image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
36
+ expandUnchanged: false,
37
+ };
38
+ }
39
+
40
+ describe("renderDiffDocument render targets", () => {
41
+ beforeEach(() => {
42
+ preloadFileDiffMock.mockClear();
43
+ preloadMultiFileDiffMock.mockClear();
44
+ });
45
+
46
+ it("renders only the viewer variant for before/after viewer mode", async () => {
47
+ const rendered = await renderDiffDocument(
48
+ {
49
+ kind: "before_after",
50
+ before: "one\n",
51
+ after: "two\n",
52
+ },
53
+ createRenderOptions(),
54
+ "viewer",
55
+ );
56
+
57
+ expect(rendered.html).toContain("mock diff");
58
+ expect(rendered.imageHtml).toBeUndefined();
59
+ expect(preloadMultiFileDiffMock).toHaveBeenCalledTimes(1);
60
+ });
61
+
62
+ it("renders both variants for before/after both mode", async () => {
63
+ const rendered = await renderDiffDocument(
64
+ {
65
+ kind: "before_after",
66
+ before: "one\n",
67
+ after: "two\n",
68
+ },
69
+ createRenderOptions(),
70
+ "both",
71
+ );
72
+
73
+ expect(rendered.html).toContain("mock diff");
74
+ expect(rendered.imageHtml).toContain("mock diff");
75
+ expect(preloadMultiFileDiffMock).toHaveBeenCalledTimes(2);
76
+ });
77
+
78
+ it("renders only the image variant for patch image mode", async () => {
79
+ const rendered = await renderDiffDocument(
80
+ {
81
+ kind: "patch",
82
+ patch: [
83
+ "diff --git a/a.ts b/a.ts",
84
+ "--- a/a.ts",
85
+ "+++ b/a.ts",
86
+ "@@ -1 +1 @@",
87
+ "-a",
88
+ "+b",
89
+ ].join("\n"),
90
+ },
91
+ createRenderOptions(),
92
+ "image",
93
+ );
94
+
95
+ expect(rendered.html).toBeUndefined();
96
+ expect(rendered.imageHtml).toContain("mock diff");
97
+ expect(preloadFileDiffMock).toHaveBeenCalledTimes(1);
98
+ });
99
+
100
+ it("normalizes stale patch payload languages before serializing viewer output", async () => {
101
+ preloadFileDiffMock.mockResolvedValueOnce({
102
+ prerenderedHTML: "<div>mock diff</div>",
103
+ fileDiff: {
104
+ name: "a.ts",
105
+ lang: "not-a-real-language",
106
+ },
107
+ });
108
+
109
+ const rendered = await renderDiffDocument(
110
+ {
111
+ kind: "patch",
112
+ patch: [
113
+ "diff --git a/a.ts b/a.ts",
114
+ "--- a/a.ts",
115
+ "+++ b/a.ts",
116
+ "@@ -1 +1 @@",
117
+ "-a",
118
+ "+b",
119
+ ].join("\n"),
120
+ },
121
+ createRenderOptions(),
122
+ "viewer",
123
+ );
124
+
125
+ const payloads = [
126
+ ...(rendered.html ?? "").matchAll(/data-actagent-diff-payload>(.*?)<\/script>/g),
127
+ ].map((match) => parseViewerPayloadJson(match[1] ?? ""));
128
+
129
+ expect(payloads).toHaveLength(1);
130
+ expect(payloads[0]?.langs).toEqual(["text"]);
131
+ expect(payloads[0]?.fileDiff?.lang).toBe("text");
132
+ });
133
+ });