@junejs/server 1.0.0-dev.14 → 1.0.0-dev.17

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.
@@ -1,5 +1,5 @@
1
- import { rolldownCssModulesPlugin } from "./css-modules.js";
2
1
  import { jsxTransform } from "./tsconfig-jsx.js";
2
+ import { rolldownCssModulesPlugin } from "./css-modules.js";
3
3
  import { existsSync } from "node:fs";
4
4
  import { mkdir, writeFile } from "node:fs/promises";
5
5
  import { dirname, join } from "node:path";
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AdapterCapabilities, AdapterEmitContext, AdapterEntry, JuneAdapter, ResourcePlan, deno, staticSite, vercel, workers } from "./adapter.js";
2
+ import { AGENT_MODULE_FILE, AgentDirScan, emitAgentModule, findAgentDir, generateAgentModule, scanAgentDir } from "./agent-compile.js";
2
3
  import { CreateAppOptions, JuneApp, createApp } from "./app.js";
3
4
  import { R2Bucket, localBlob, r2 } from "./blob.js";
4
5
  import { a as Pipeline, d as createPipeline, n as LayoutComponent, o as PipelineConfig, s as Resolved, u as RouteResolver } from "./pipeline-cCR6MGNK.js";
@@ -24,4 +25,4 @@ import { Negotiated, negotiate } from "./negotiate.js";
24
25
  import { blob, currentServices, db, kv } from "@junejs/db";
25
26
  import { ServicesConfig, defineServices } from "@junejs/core/config";
26
27
  import { I18nConfig, LOCALE_COOKIE, LocaleConfig, LocaleMatch, dirForLang, localeDir, localeHref, matchPinnedLocale, negotiateLocale, parseAcceptLanguage, resolveRequestLocale } from "@junejs/core/i18n";
27
- export { type AdapterCapabilities, type AdapterEmitContext, type AdapterEntry, type BuildResult, type ContentEntry, type CreateAppOptions, type D1Database, type D1Exec, type DeployResult, type DevServer, type DevServerOptions, type I18nConfig, type JuneAdapter, type JuneApp, type JuneDb, type JuneHost, LOCALE_COOKIE, type LayoutComponent, type LocaleConfig, type LocaleMatch, type MatchOptions, type MigrateResult, type Migration, type Negotiated, type Pipeline, type PipelineConfig, type R2Bucket, type Resolved, type ResourceFlags, type ResourcePlan, type RouteMatch, type RouteResolver, type RouteTreeMatch, type RunResult, type SegmentMatch, type ServeHandle, type ServicesConfig, type SpawnedModule, type WorkerEnv, type WorkerManifest, bindWorkerResources, blob, blockedMessage, buildManifest, classify, collection, createApp, createPipeline, createWorker, currentServices, d1, db, defineServices, deno, dirForLang, entry, freezeConfig, generateContent, host, installAsyncContext, juneBuild, juneDeploy, kv, listRoutes, loadJuneConfig, localBlob, localeDir, localeHref, matchPinnedLocale, matchRoute, matchRouteTree, memoizeResources, memoryKv, migrate, migrateApp, migrateD1, mysql, negotiate, negotiateLocale, parseAcceptLanguage, postgres, r2, readMigrations, redisKv, resolveD1Database, resolveNotFound, resolveRequestLocale, scanRoutes, sqlite, startDevServer, staticSite, turso, typesApp, vercel, workers, wranglerD1 };
28
+ export { AGENT_MODULE_FILE, type AdapterCapabilities, type AdapterEmitContext, type AdapterEntry, type AgentDirScan, type BuildResult, type ContentEntry, type CreateAppOptions, type D1Database, type D1Exec, type DeployResult, type DevServer, type DevServerOptions, type I18nConfig, type JuneAdapter, type JuneApp, type JuneDb, type JuneHost, LOCALE_COOKIE, type LayoutComponent, type LocaleConfig, type LocaleMatch, type MatchOptions, type MigrateResult, type Migration, type Negotiated, type Pipeline, type PipelineConfig, type R2Bucket, type Resolved, type ResourceFlags, type ResourcePlan, type RouteMatch, type RouteResolver, type RouteTreeMatch, type RunResult, type SegmentMatch, type ServeHandle, type ServicesConfig, type SpawnedModule, type WorkerEnv, type WorkerManifest, bindWorkerResources, blob, blockedMessage, buildManifest, classify, collection, createApp, createPipeline, createWorker, currentServices, d1, db, defineServices, deno, dirForLang, emitAgentModule, entry, findAgentDir, freezeConfig, generateAgentModule, generateContent, host, installAsyncContext, juneBuild, juneDeploy, kv, listRoutes, loadJuneConfig, localBlob, localeDir, localeHref, matchPinnedLocale, matchRoute, matchRouteTree, memoizeResources, memoryKv, migrate, migrateApp, migrateD1, mysql, negotiate, negotiateLocale, parseAcceptLanguage, postgres, r2, readMigrations, redisKv, resolveD1Database, resolveNotFound, resolveRequestLocale, scanAgentDir, scanRoutes, sqlite, startDevServer, staticSite, turso, typesApp, vercel, workers, wranglerD1 };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { deno, staticSite, vercel, workers } from "./adapter.js";
2
+ import { AGENT_MODULE_FILE, emitAgentModule, findAgentDir, generateAgentModule, scanAgentDir } from "./agent-compile.js";
2
3
  import { listRoutes, matchRoute, matchRouteTree, resolveNotFound } from "./router.js";
3
4
  import { negotiate } from "./negotiate.js";
4
5
  import { createPipeline } from "./pipeline.js";
@@ -24,4 +25,4 @@ import { memoryKv, redisKv } from "./kv.js";
24
25
  import { blob, currentServices, db, kv } from "@junejs/db";
25
26
  import { defineServices } from "@junejs/core/config";
26
27
  import { LOCALE_COOKIE, dirForLang, localeDir, localeHref, matchPinnedLocale, negotiateLocale, parseAcceptLanguage, resolveRequestLocale } from "@junejs/core/i18n";
27
- export { LOCALE_COOKIE, bindWorkerResources, blob, blockedMessage, buildManifest, classify, collection, createApp, createPipeline, createWorker, currentServices, d1, db, defineServices, deno, dirForLang, entry, freezeConfig, generateContent, host, installAsyncContext, juneBuild, juneDeploy, kv, listRoutes, loadJuneConfig, localBlob, localeDir, localeHref, matchPinnedLocale, matchRoute, matchRouteTree, memoizeResources, memoryKv, migrate, migrateApp, migrateD1, mysql, negotiate, negotiateLocale, parseAcceptLanguage, postgres, r2, readMigrations, redisKv, resolveD1Database, resolveNotFound, resolveRequestLocale, scanRoutes, sqlite, startDevServer, staticSite, turso, typesApp, vercel, workers, wranglerD1 };
28
+ export { AGENT_MODULE_FILE, LOCALE_COOKIE, bindWorkerResources, blob, blockedMessage, buildManifest, classify, collection, createApp, createPipeline, createWorker, currentServices, d1, db, defineServices, deno, dirForLang, emitAgentModule, entry, findAgentDir, freezeConfig, generateAgentModule, generateContent, host, installAsyncContext, juneBuild, juneDeploy, kv, listRoutes, loadJuneConfig, localBlob, localeDir, localeHref, matchPinnedLocale, matchRoute, matchRouteTree, memoizeResources, memoryKv, migrate, migrateApp, migrateD1, mysql, negotiate, negotiateLocale, parseAcceptLanguage, postgres, r2, readMigrations, redisKv, resolveD1Database, resolveNotFound, resolveRequestLocale, scanAgentDir, scanRoutes, sqlite, startDevServer, staticSite, turso, typesApp, vercel, workers, wranglerD1 };
@@ -1,4 +1,5 @@
1
1
  //#region src/tsconfig-jsx.d.ts
2
+ declare const stripJsonc: (s: string) => string;
2
3
  /** Read the app's tsconfig.json and return compilerOptions.jsxImportSource.
3
4
  * Follows one level of `extends` so apps that inherit from a base tsconfig
4
5
  * (e.g. "@kurajs/docs/tsconfig.kura.json") are handled correctly.
@@ -12,4 +13,4 @@ declare function jsxTransform(appRoot: string): Promise<{
12
13
  importSource?: string;
13
14
  }>;
14
15
  //#endregion
15
- export { appJsxImportSource, jsxTransform };
16
+ export { appJsxImportSource, jsxTransform, stripJsonc };
@@ -32,4 +32,4 @@ async function jsxTransform(appRoot) {
32
32
  };
33
33
  }
34
34
  //#endregion
35
- export { appJsxImportSource, jsxTransform };
35
+ export { appJsxImportSource, jsxTransform, stripJsonc };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "1.0.0-dev.14",
3
+ "version": "1.0.0-dev.17",
4
4
  "description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,6 +36,12 @@
36
36
  "types": "./dist/agent-discover.d.ts",
37
37
  "default": "./dist/agent-discover.js"
38
38
  },
39
+ "./agent-compile": {
40
+ "source": "./src/agent-compile.ts",
41
+ "bun": "./src/agent-compile.ts",
42
+ "types": "./dist/agent-compile.d.ts",
43
+ "default": "./dist/agent-compile.js"
44
+ },
39
45
  "./db": {
40
46
  "source": "./src/db.ts",
41
47
  "bun": "./src/db.ts",
@@ -132,13 +138,14 @@
132
138
  "rolldown": "^1.1.0"
133
139
  },
134
140
  "peerDependencies": {
135
- "@junejs/core": ">=0.2.0-dev.32",
141
+ "@junejs/core": ">=0.2.0-dev.33",
136
142
  "react": "^19.2.0",
137
143
  "react-dom": "^19.2.0",
138
144
  "react-server-dom-webpack": "19.2.7",
139
145
  "pg": "^8.0.0",
140
146
  "mysql2": "^3.0.0",
141
- "@libsql/client": "^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0"
147
+ "@libsql/client": "^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0",
148
+ "@anthropic-ai/sdk": ">=0.60.0"
142
149
  },
143
150
  "peerDependenciesMeta": {
144
151
  "react-server-dom-webpack": {
@@ -152,6 +159,9 @@
152
159
  },
153
160
  "@libsql/client": {
154
161
  "optional": true
162
+ },
163
+ "@anthropic-ai/sdk": {
164
+ "optional": true
155
165
  }
156
166
  },
157
167
  "repository": {
package/src/adapter.ts CHANGED
@@ -7,10 +7,11 @@
7
7
  // `workers()` is the zero-config default (this file, no import). Other targets
8
8
  // are explicit `deploy: { adapter: vercel() }` from their own package.
9
9
  import { existsSync } from "node:fs";
10
- import { cp, copyFile, mkdir, readdir, rm, writeFile } from "node:fs/promises";
10
+ import { cp, copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
11
11
  import { basename, dirname, join } from "node:path";
12
12
 
13
13
  import type { JuneConfig } from "@junejs/core/config";
14
+ import { stripJsonc } from "./tsconfig-jsx";
14
15
 
15
16
  export type AdapterCapabilities = {
16
17
  runtime: "edge" | "node" | "serverless" | "native" | "static";
@@ -20,6 +21,9 @@ export type AdapterCapabilities = {
20
21
  persistentConnections: boolean;
21
22
  // Who serves prerendered static files: the platform CDN, the server, or none.
22
23
  assets: "platform" | "server" | "none";
24
+ // Durable Objects (the per-session agent actor). Only workers() today; the
25
+ // build skips the durable-agent mount (with a notice) when absent.
26
+ durableObjects?: boolean;
23
27
  };
24
28
 
25
29
  // What the adapter contributes to the generated worker entry: extra imports and
@@ -37,6 +41,11 @@ export type ResourcePlan = {
37
41
  // A declared `db` → a D1 binding named `binding`. The runtime provider
38
42
  // (bindWorkerResources) reads env[binding]; keep in sync (default "DB").
39
43
  db?: { binding: string; databaseName: string };
44
+ // A durable agent (an app/agent directory on a DO-capable target) → the DO
45
+ // namespace binding createWorker reads (env.AGENT) and the class the
46
+ // generated entry exports. workers() emits durable_objects + the SQLite-class
47
+ // migration from it.
48
+ agent?: { binding: string; className: string };
40
49
  };
41
50
 
42
51
  export type AdapterEmitContext = {
@@ -77,13 +86,60 @@ export interface JuneAdapter {
77
86
  emit(ctx: AdapterEmitContext): Promise<void>;
78
87
  }
79
88
 
89
+ // Whether an app-owned wrangler config actually BINDS the generated DO class
90
+ // under the binding name createWorker reads (env.AGENT) — not merely mentions
91
+ // the class (a migrations entry does that too, and a binding under another
92
+ // name never reaches the chat route). JSONC is parsed for the real
93
+ // durable_objects.bindings entry; TOML is scanned per
94
+ // [[durable_objects.bindings]] table with comments stripped, so a
95
+ // commented-out binding never counts. The TOML scan covers wrangler's
96
+ // canonical table form; an exotic inline-table config may draw a spurious
97
+ // (advisory) warning, never a suppressed one. Pure — unit-tested directly.
98
+ export function hasDurableBinding(text: string, file: string, binding: string, className: string): boolean {
99
+ if (file.endsWith(".toml")) {
100
+ // Strip comments: '#' to end of line, unless inside a double-quoted string.
101
+ const lines = text.split("\n").map((line) => {
102
+ let inString = false;
103
+ for (let i = 0; i < line.length; i++) {
104
+ const ch = line[i];
105
+ if (ch === '"' && line[i - 1] !== "\\") inString = !inString;
106
+ else if (ch === "#" && !inString) return line.slice(0, i);
107
+ }
108
+ return line;
109
+ });
110
+ let inBindings = false;
111
+ let name: string | undefined;
112
+ let cls: string | undefined;
113
+ const matches = () => name === binding && cls === className;
114
+ for (const line of lines) {
115
+ const t = line.trim();
116
+ if (t.startsWith("[")) {
117
+ if (inBindings && matches()) return true; // the table that just ended bound it
118
+ inBindings = /^\[\[\s*durable_objects\.bindings\s*\]\]$/.test(t);
119
+ name = cls = undefined;
120
+ continue;
121
+ }
122
+ if (!inBindings) continue;
123
+ const kv = t.match(/^(name|class_name)\s*=\s*"([^"]*)"/);
124
+ if (kv) kv[1] === "name" ? (name = kv[2]) : (cls = kv[2]);
125
+ }
126
+ return inBindings && matches();
127
+ }
128
+ try {
129
+ const parsed = JSON.parse(stripJsonc(text)) as { durable_objects?: { bindings?: { name?: string; class_name?: string }[] } };
130
+ return (parsed.durable_objects?.bindings ?? []).some((b) => b.name === binding && b.class_name === className);
131
+ } catch {
132
+ return text.includes(className); // unparsable — loose fallback, never crash the build
133
+ }
134
+ }
135
+
80
136
  // The built-in default. Reproduces exactly what build.ts emitted inline:
81
137
  // withAssets(pipeline) as the entry, and a wrangler.jsonc with the assets
82
138
  // binding (run_worker_first) + optional custom domain.
83
139
  export function workers(opts?: { name?: string; domain?: string }): JuneAdapter {
84
140
  return {
85
141
  name: "workers",
86
- capabilities: { runtime: "edge", persistentConnections: true, assets: "platform" },
142
+ capabilities: { runtime: "edge", persistentConnections: true, assets: "platform", durableObjects: true },
87
143
  conditions: ["source", "workerd", "edge", "import", "default"],
88
144
  // workers-og is a workerd-native WASM package: rolldown cannot bundle its
89
145
  // .wasm assets (wrangler's own bundler applies the CompiledWasm rule). Mark
@@ -100,7 +156,23 @@ export function workers(opts?: { name?: string; domain?: string }): JuneAdapter
100
156
 
101
157
  async emit({ appRoot, outDir, hasAssets, config, plan, defaultName }) {
102
158
  // An app that manages its own wrangler config wins — don't overwrite it.
103
- if (existsSync(join(appRoot, "wrangler.toml")) || existsSync(join(appRoot, "wrangler.jsonc"))) {
159
+ // But a durable agent NEEDS its DO binding: warn when the app-owned config
160
+ // doesn't BIND the generated class under the name createWorker reads
161
+ // (hasDurableBinding above — a migrations-only mention, a binding under
162
+ // another name, or a commented-out table must all still warn), instead of
163
+ // silently shipping a worker whose chat surface 404s.
164
+ const own = ["wrangler.toml", "wrangler.jsonc"].map((f) => join(appRoot, f)).find(existsSync);
165
+ if (own) {
166
+ if (plan.agent) {
167
+ const cls = plan.agent.className;
168
+ const bound = hasDurableBinding(await readFile(own, "utf8"), own, plan.agent.binding, cls);
169
+ if (!bound) {
170
+ const snippet = own.endsWith(".toml")
171
+ ? `[[durable_objects.bindings]]\nname = "${plan.agent.binding}"\nclass_name = "${cls}"\n\n[[migrations]]\ntag = "v1"\nnew_sqlite_classes = ["${cls}"]`
172
+ : `"durable_objects": { "bindings": [{ "name": "${plan.agent.binding}", "class_name": "${cls}" }] },\n"migrations": [{ "tag": "v1", "new_sqlite_classes": ["${cls}"] }]`;
173
+ console.warn(`[june build] durable agent built, but ${basename(own)} doesn't bind it — add:\n${snippet}`);
174
+ }
175
+ }
104
176
  return;
105
177
  }
106
178
  const deployCfg = config.deploy;
@@ -133,6 +205,15 @@ export function workers(opts?: { name?: string; domain?: string }): JuneAdapter
133
205
  ],
134
206
  }
135
207
  : {}),
208
+ // The durable agent: one SQLite-backed DO class = the per-session
209
+ // AgentSession actor (its ctx.storage.sql is the durable loop's
210
+ // synchronous store). The binding name is what createWorker reads.
211
+ ...(plan.agent
212
+ ? {
213
+ durable_objects: { bindings: [{ name: plan.agent.binding, class_name: plan.agent.className }] },
214
+ migrations: [{ tag: "v1", new_sqlite_classes: [plan.agent.className] }],
215
+ }
216
+ : {}),
136
217
  // config deploy.domain → a Workers custom domain; without it the
137
218
  // regenerated file would silently drop a hand-attached domain.
138
219
  ...(opts?.domain ?? deployCfg?.domain
@@ -0,0 +1,245 @@
1
+ // agent-compile.ts — compile an agent/ directory into a static module
2
+ // (_agent.gen.ts) so the SAME directory convention mounts where filesystem
3
+ // discovery cannot run: workerd has no fs, so discoverAgent (dynamic import +
4
+ // readFile) is native-only. The generated module is plain erasable TypeScript —
5
+ // static imports of tools/channels/connections, markdown inlined as string
6
+ // literals — bundleable by wrangler or Rolldown, importable under Node type
7
+ // stripping and bun test alike. No [[rules]] Text hack, no md loader plugins,
8
+ // no hand-maintained tool registry.
9
+ //
10
+ // Mirrors the content freeze (content.ts): a pure emitter (emitAgentModule)
11
+ // over a plain scan (scanAgentDir), with a thin fs wrapper
12
+ // (generateAgentModule) — the emitter is unit-testable without touching disk.
13
+ // Assembly logic is NOT duplicated here: the generated module exports the raw
14
+ // AgentModule shape, and both runtimes assemble it through
15
+ // @junejs/core/agent-config (assembleAgent natively, assembleDurable in a DO
16
+ // shell). Skill files are inlined raw and parsed at module init via core's
17
+ // parseSkill, so a parser improvement never requires regeneration.
18
+
19
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { basename, dirname, join, resolve } from "node:path";
21
+
22
+ import { stripJsonc } from "./tsconfig-jsx";
23
+
24
+ export const AGENT_MODULE_FILE = "_agent.gen.ts";
25
+
26
+ // The raw scan of an agent directory: file paths for code (imported statically
27
+ // by the emitted module), file contents for prose (inlined as literals).
28
+ export type AgentDirScan = {
29
+ dir: string;
30
+ // basename fallback when agent.ts is absent (mirrors discoverAgent).
31
+ name: string;
32
+ hasConfig: boolean;
33
+ instructions: string | null;
34
+ tools: string[]; // relative specifiers, e.g. "./tools/create_order.ts"
35
+ skills: { name: string; raw: string }[];
36
+ channels: string[]; // "./channels/slack.ts"
37
+ channelOverlays: { source: string; raw: string }[]; // channels/<source>.md
38
+ connections: string[]; // "./connections/teachify.ts"
39
+ };
40
+
41
+ // `_`-prefixed files are private by convention — most importantly the generated
42
+ // module itself, which must never be scanned back in. Sorted for determinism.
43
+ function list(dir: string, ext: string): string[] {
44
+ if (!existsSync(dir)) return [];
45
+ return readdirSync(dir, { withFileTypes: true })
46
+ .filter((e) => e.isFile() && e.name.endsWith(ext) && !e.name.startsWith("_"))
47
+ .map((e) => e.name)
48
+ .sort();
49
+ }
50
+
51
+ // Scan an agent directory (fs only — no dynamic imports; the point is to defer
52
+ // module loading to the bundler). Returns null when the directory holds nothing
53
+ // agent-shaped, so callers can probe candidate paths cheaply.
54
+ export function scanAgentDir(dir: string): AgentDirScan | null {
55
+ if (!existsSync(dir)) return null;
56
+ const instructionsFile = join(dir, "instructions.md");
57
+ const scan: AgentDirScan = {
58
+ dir,
59
+ name: basename(dir),
60
+ hasConfig: existsSync(join(dir, "agent.ts")),
61
+ instructions: existsSync(instructionsFile) ? readFileSync(instructionsFile, "utf8") : null,
62
+ tools: list(join(dir, "tools"), ".ts").map((f) => `./tools/${f}`),
63
+ skills: list(join(dir, "skills"), ".md").map((f) => ({
64
+ name: basename(f, ".md"),
65
+ raw: readFileSync(join(dir, "skills", f), "utf8"),
66
+ })),
67
+ channels: list(join(dir, "channels"), ".ts").map((f) => `./channels/${f}`),
68
+ channelOverlays: list(join(dir, "channels"), ".md").map((f) => ({
69
+ source: basename(f, ".md"),
70
+ raw: readFileSync(join(dir, "channels", f), "utf8"),
71
+ })),
72
+ connections: list(join(dir, "connections"), ".ts").map((f) => `./connections/${f}`),
73
+ };
74
+ const empty =
75
+ !scan.hasConfig &&
76
+ scan.instructions === null &&
77
+ !scan.tools.length &&
78
+ !scan.skills.length &&
79
+ !scan.channels.length &&
80
+ !scan.channelOverlays.length &&
81
+ !scan.connections.length;
82
+ return empty ? null : scan;
83
+ }
84
+
85
+ // A stable, readable import identifier from a file specifier: "./tools/create_order.ts"
86
+ // → "tool_create_order". Collisions (foo-bar vs foo_bar) get a numeric suffix.
87
+ function identFor(prefix: string, spec: string, taken: Set<string>): string {
88
+ const stem = basename(spec).replace(/\.ts$/, "").replace(/[^A-Za-z0-9]/g, "_");
89
+ let id = `${prefix}_${stem}`;
90
+ for (let i = 2; taken.has(id); i++) id = `${prefix}_${stem}_${i}`;
91
+ taken.add(id);
92
+ return id;
93
+ }
94
+
95
+ export type EmitOptions = {
96
+ // Keep ".ts" on relative import specifiers. Required by consumers that run
97
+ // the module under Node's native type stripping (Node never resolves
98
+ // extensionless imports); rejected (TS5097) by tsconfigs without
99
+ // allowImportingTsExtensions. generateAgentModule sniffs the consumer's
100
+ // tsconfig; pass explicitly to override.
101
+ tsExtensions?: boolean;
102
+ };
103
+
104
+ // Emit the module source for a scan. Pure — the golden test pins this output.
105
+ export function emitAgentModule(scan: AgentDirScan, opts?: EmitOptions): string {
106
+ const spec = (s: string) => JSON.stringify(opts?.tsExtensions ? s : s.replace(/\.ts$/, ""));
107
+ const taken = new Set<string>();
108
+ const imports: string[] = [];
109
+ const toolIds = scan.tools.map((s) => {
110
+ const id = identFor("tool", s, taken);
111
+ imports.push(`import ${id} from ${spec(s)};`);
112
+ return id;
113
+ });
114
+ const channelIds = scan.channels.map((s) => {
115
+ const id = identFor("channel", s, taken);
116
+ imports.push(`import ${id} from ${spec(s)};`);
117
+ return { key: basename(s, ".ts"), id };
118
+ });
119
+ const connectionIds = scan.connections.map((s) => {
120
+ const id = identFor("connection", s, taken);
121
+ imports.push(`import ${id} from ${spec(s)};`);
122
+ return id;
123
+ });
124
+ if (scan.hasConfig) imports.push(`import config from ${spec("./agent.ts")};`);
125
+
126
+ const lines: string[] = [
127
+ "// AUTO-GENERATED by June — do not edit. Compiled from this agent/ directory so",
128
+ "// the definition mounts without filesystem discovery (workerd has no fs).",
129
+ "// Regenerate with `june gen` after adding, removing, or editing agent files.",
130
+ ...imports,
131
+ scan.skills.length
132
+ ? `import { parseSkill, type AgentModule } from "@junejs/core/agent-config";`
133
+ : `import type { AgentModule } from "@junejs/core/agent-config";`,
134
+ "",
135
+ ];
136
+ if (!scan.hasConfig) lines.push(`const config = { name: ${JSON.stringify(scan.name)} };`, "");
137
+
138
+ lines.push(
139
+ "export const agentModule: AgentModule = {",
140
+ " config,",
141
+ ` instructions: ${JSON.stringify(scan.instructions ?? "")},`,
142
+ ` tools: [${toolIds.join(", ")}],`,
143
+ scan.skills.length
144
+ ? ` skills: [\n${scan.skills.map((s) => ` parseSkill(${JSON.stringify(s.name)}, ${JSON.stringify(s.raw)}),`).join("\n")}\n ],`
145
+ : " skills: [],",
146
+ channelIds.length
147
+ ? ` channels: { ${channelIds.map((c) => `${JSON.stringify(c.key)}: ${c.id}`).join(", ")} },`
148
+ : " channels: {},",
149
+ scan.channelOverlays.length
150
+ ? ` channelInstructions: {\n${scan.channelOverlays.map((o) => ` ${JSON.stringify(o.source)}: ${JSON.stringify(o.raw)},`).join("\n")}\n },`
151
+ : " channelInstructions: {},",
152
+ ` connections: [${connectionIds.join(", ")}],`,
153
+ "};",
154
+ "export default agentModule;",
155
+ "",
156
+ );
157
+ return lines.join("\n");
158
+ }
159
+
160
+ // Locate the agent directory for a project root: a June app keeps it at
161
+ // app/<dir> (the app.ts convention); a wrangler-first worker keeps it at
162
+ // <root>/<dir>. `dirName` follows june.config's agent.runtime.dir (default
163
+ // "agent").
164
+ export function findAgentDir(root: string, dirName = "agent"): string | null {
165
+ for (const candidate of [join(root, "app", dirName), join(root, dirName)]) {
166
+ if (existsSync(candidate)) return candidate;
167
+ }
168
+ return null;
169
+ }
170
+
171
+ // Whether the consumer's tsconfig keeps ".ts" on import specifiers
172
+ // (allowImportingTsExtensions): the nearest tsconfig.json walking up from the
173
+ // agent dir, parsed as JSONC (same stripJsonc as tsconfig-jsx.ts — a commented-
174
+ // out option must not count) and resolved through `extends` (relative, bare
175
+ // package specifiers, and the TS 5 array form; own options win, later array
176
+ // entries beat earlier ones). Explicit opts.tsExtensions overrides the sniff.
177
+ type TsconfigLite = { extends?: string | string[]; compilerOptions?: { allowImportingTsExtensions?: boolean } };
178
+
179
+ function readTsconfig(path: string): TsconfigLite | undefined {
180
+ try {
181
+ return JSON.parse(stripJsonc(readFileSync(path, "utf8"))) as TsconfigLite;
182
+ } catch {
183
+ return undefined; // absent or unparsable — treat as "not declared"
184
+ }
185
+ }
186
+
187
+ // Resolve an `extends` specifier from the extending config's directory: relative
188
+ // paths directly, bare specifiers through the nearest node_modules walking up.
189
+ // tsc appends ".json" when the target has no extension — mirror that.
190
+ function resolveExtends(fromDir: string, spec: string): string | undefined {
191
+ const withJson = (p: string) => (existsSync(p) ? p : !p.endsWith(".json") && existsSync(`${p}.json`) ? `${p}.json` : undefined);
192
+ if (spec.startsWith(".")) return withJson(resolve(fromDir, spec));
193
+ for (let d = fromDir; ; ) {
194
+ const candidate = withJson(join(d, "node_modules", spec));
195
+ if (candidate) return candidate;
196
+ const parent = dirname(d);
197
+ if (parent === d) return undefined;
198
+ d = parent;
199
+ }
200
+ }
201
+
202
+ function resolveAllowTsExtensions(path: string, depth: number): boolean | undefined {
203
+ if (depth > 8) return undefined; // extends cycles/degenerate chains — give up
204
+ const tc = readTsconfig(path);
205
+ if (!tc) return undefined;
206
+ const own = tc.compilerOptions?.allowImportingTsExtensions;
207
+ if (own !== undefined) return own;
208
+ // TS extends-array semantics: later entries override earlier ones.
209
+ const bases = typeof tc.extends === "string" ? [tc.extends] : [...(tc.extends ?? [])].reverse();
210
+ for (const spec of bases) {
211
+ const base = resolveExtends(dirname(path), spec);
212
+ const inherited = base ? resolveAllowTsExtensions(base, depth + 1) : undefined;
213
+ if (inherited !== undefined) return inherited;
214
+ }
215
+ return undefined;
216
+ }
217
+
218
+ function sniffTsExtensions(dir: string): boolean {
219
+ for (let d = dir; ; ) {
220
+ const candidate = join(d, "tsconfig.json");
221
+ if (existsSync(candidate)) return resolveAllowTsExtensions(candidate, 0) ?? false;
222
+ const parent = dirname(d);
223
+ if (parent === d) return false;
224
+ d = parent;
225
+ }
226
+ }
227
+
228
+ // Generate <dir>/_agent.gen.ts. With `check: true` nothing is written — `stale`
229
+ // reports whether the file on disk differs from what would be generated (the CI
230
+ // gate; `june gen && git diff --exit-code` works too). Returns null when the
231
+ // directory holds nothing agent-shaped.
232
+ export function generateAgentModule(
233
+ dir: string,
234
+ opts?: { check?: boolean } & EmitOptions,
235
+ ): { file: string; code: string; stale: boolean; written: boolean } | null {
236
+ const scan = scanAgentDir(dir);
237
+ if (!scan) return null;
238
+ const code = emitAgentModule(scan, { tsExtensions: opts?.tsExtensions ?? sniffTsExtensions(dir) });
239
+ const file = join(dir, AGENT_MODULE_FILE);
240
+ const existing = existsSync(file) ? readFileSync(file, "utf8") : null;
241
+ const stale = existing !== code;
242
+ if (opts?.check) return { file, code, stale, written: false };
243
+ if (stale) writeFileSync(file, code);
244
+ return { file, code, stale, written: stale };
245
+ }
@@ -8,12 +8,15 @@
8
8
  // tools/*.ts → each default-exports a defineAction (a tool)
9
9
  // skills/*.md → each a procedure, loaded on demand (progressive disclosure)
10
10
  // channels/*.ts → each default-exports a Channel (an inbound edge)
11
+ // channels/*.md → source-keyed system overlay for the same-named channel
11
12
  // connections/*.ts → each default-exports a Connection (an outbound tool source)
12
13
  //
13
14
  // There is no central registry to keep in sync — the directory IS the manifest.
14
15
  // Because tools are `defineAction`s, the SAME directory is also an MCP server and
15
16
  // a `.agent` manifest (surfaces land in a later step). fs lives here in the host
16
- // package; the pure assembly is @junejs/core/agent-config's defineAgent().
17
+ // package; the pure assembly is @junejs/core/agent-config's assembleAgent() — the
18
+ // SAME entry point a compiled agent module (agent-compile.ts, the edge path) goes
19
+ // through, so native discovery and the generated module cannot drift.
17
20
 
18
21
  import { existsSync } from "node:fs";
19
22
  import { readdir, readFile } from "node:fs/promises";
@@ -21,52 +24,32 @@ import { basename, extname, join } from "node:path";
21
24
  import { pathToFileURL } from "node:url";
22
25
 
23
26
  import type { AnyAction } from "@junejs/core/agent";
24
- import { defineAgent, resolveChannel, type AgentConfigFile, type AgentDefinition, type Channel, type ChannelFactory, type Skill } from "@junejs/core/agent-config";
25
- import { connectAll, type Connection } from "@junejs/core/connections";
27
+ import { assembleAgent, parseSkill, type AgentConfigFile, type AgentDefinition, type AgentModule, type Channel, type ChannelFactory, type Skill } from "@junejs/core/agent-config";
28
+ import type { Connection } from "@junejs/core/connections";
26
29
 
30
+ // `_`-prefixed files are private by convention (mirrors the app/ router) — most
31
+ // importantly the generated `_agent.gen.ts` itself, which must never be scanned
32
+ // back in as a tool/channel.
27
33
  async function scan(dir: string, ext: string): Promise<string[]> {
28
34
  if (!existsSync(dir)) return [];
29
35
  const entries = await readdir(dir, { withFileTypes: true });
30
36
  return entries
31
- .filter((e) => e.isFile() && extname(e.name) === ext)
37
+ .filter((e) => e.isFile() && extname(e.name) === ext && !e.name.startsWith("_"))
32
38
  .map((e) => join(dir, e.name))
33
39
  .sort();
34
40
  }
35
41
 
36
- // Optional YAML-ish frontmatter: --- \n key: value \n --- \n body. Falls back to
37
- // the first non-empty line (minus a leading "# ") as the description.
38
- function parseSkill(name: string, text: string): Skill {
39
- if (text.startsWith("---")) {
40
- const end = text.indexOf("\n---", 3);
41
- if (end !== -1) {
42
- const front = text.slice(3, end);
43
- const body = text.slice(end + 4).replace(/^\s*\n/, "");
44
- const meta: Record<string, string> = {};
45
- for (const line of front.split("\n")) {
46
- const m = line.match(/^\s*(\w+)\s*:\s*(.+?)\s*$/);
47
- if (m) meta[m[1]!] = m[2]!;
48
- }
49
- return { name: meta.name ?? name, description: meta.description ?? "", body };
50
- }
51
- }
52
- const firstLine = text.split("\n").find((l) => l.trim()) ?? "";
53
- return { name, description: firstLine.replace(/^#\s*/, ""), body: text };
54
- }
55
-
56
- // Discover an agent from its directory, returning the assembled AgentDefinition
57
- // (tools already adapted from defineActions; read_skill added if any skills
58
- // exist). Mount it on a runtime with createNativeRuntime({ [agent.name]: { model,
59
- // tools: agent.tools } }).
60
- export async function discoverAgent(dir: string): Promise<AgentDefinition> {
42
+ // Read an agent directory into its raw, unassembled shape (fs + dynamic import —
43
+ // native only). The pure sibling for the edge is agent-compile.ts, which emits
44
+ // the same AgentModule as static code.
45
+ export async function discoverAgentModule(dir: string): Promise<AgentModule> {
61
46
  const configFile = join(dir, "agent.ts");
62
47
  const config: AgentConfigFile = existsSync(configFile)
63
48
  ? ((await import(pathToFileURL(configFile).href)).default as AgentConfigFile)
64
49
  : { name: basename(dir) };
65
50
 
66
51
  const instructionsFile = join(dir, "instructions.md");
67
- const instructions = existsSync(instructionsFile)
68
- ? await readFile(instructionsFile, "utf8")
69
- : (config.instructions ?? "");
52
+ const instructions = existsSync(instructionsFile) ? await readFile(instructionsFile, "utf8") : "";
70
53
 
71
54
  const tools: AnyAction[] = [];
72
55
  for (const f of await scan(join(dir, "tools"), ".ts")) {
@@ -80,34 +63,36 @@ export async function discoverAgent(dir: string): Promise<AgentDefinition> {
80
63
  }
81
64
 
82
65
  // A channel module default-exports a Channel OR a `(env) => Channel` factory (the
83
- // form workerd needs — secrets live in env, not at module scope). On native, env is
84
- // process.env (present at load), so resolve here; the plain form stays supported.
85
- const channels: Channel[] = [];
66
+ // form workerd needs — secrets live in env, not at module scope). Resolution is
67
+ // assembleAgent's job (native: process.env; a DO: its own env) — keep raw here.
68
+ const channels: Record<string, Channel | ChannelFactory> = {};
86
69
  for (const f of await scan(join(dir, "channels"), ".ts")) {
87
70
  const mod = await import(pathToFileURL(f).href);
88
- if (mod.default) channels.push(resolveChannel(mod.default as Channel | ChannelFactory, process.env));
71
+ if (mod.default) channels[basename(f, ".ts")] = mod.default as Channel | ChannelFactory;
89
72
  }
90
73
 
91
- // connections/*.ts — outbound: wire external MCP/OpenAPI servers, turning their
92
- // operations into `<connection>__<tool>` defineActions that join `tools`.
93
- const connectionDefs: Connection[] = [];
74
+ // channels/<source>.md — a system overlay applied when a turn's inbound event
75
+ // source matches the file's basename (see AgentDefinition.channelInstructions).
76
+ const channelInstructions: Record<string, string> = {};
77
+ for (const f of await scan(join(dir, "channels"), ".md")) {
78
+ channelInstructions[basename(f, ".md")] = await readFile(f, "utf8");
79
+ }
80
+
81
+ // connections/*.ts — outbound: definitions of external MCP/OpenAPI servers;
82
+ // assembleAgent wires them (connectAll) where the agent actually runs.
83
+ const connections: Connection[] = [];
94
84
  for (const f of await scan(join(dir, "connections"), ".ts")) {
95
85
  const mod = await import(pathToFileURL(f).href);
96
- if (mod.default) connectionDefs.push(mod.default as Connection);
86
+ if (mod.default) connections.push(mod.default as Connection);
97
87
  }
98
- const { actions: connectionActions, report: connections } = connectionDefs.length
99
- ? await connectAll(connectionDefs)
100
- : { actions: [], report: [] };
101
- tools.push(...connectionActions);
102
88
 
103
- return defineAgent({
104
- name: config.name,
105
- model: config.model,
106
- description: config.description,
107
- instructions,
108
- tools,
109
- skills,
110
- channels,
111
- connections,
112
- });
89
+ return { config, instructions, tools, skills, channels, channelInstructions, connections };
90
+ }
91
+
92
+ // Discover an agent from its directory, returning the assembled AgentDefinition
93
+ // (tools already adapted from defineActions; read_skill added if any skills
94
+ // exist). Mount it on a runtime with createNativeRuntime({ [agent.name]: { model,
95
+ // tools: agent.tools } }).
96
+ export async function discoverAgent(dir: string): Promise<AgentDefinition> {
97
+ return assembleAgent(await discoverAgentModule(dir), process.env);
113
98
  }