orionfold-relay 0.15.3 → 0.15.5
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.
- package/dist/cli.js +43 -4
- package/next.config.mjs +21 -1
- package/package.json +1 -1
- package/src/app/layout.tsx +7 -1
- package/src/components/instance/instance-section.tsx +1 -1
- package/src/components/settings/providers-runtimes-section.tsx +47 -1
- package/src/lib/chat/engine.ts +55 -4
- package/src/lib/chat/permission-bridge.ts +26 -3
- package/src/lib/chat/system-prompt.ts +1 -0
- package/src/lib/chat/tools/project-tools.ts +71 -0
package/dist/cli.js
CHANGED
|
@@ -9347,6 +9347,26 @@ var init_helpers2 = __esm({
|
|
|
9347
9347
|
// src/lib/chat/tools/project-tools.ts
|
|
9348
9348
|
import { z as z9 } from "zod";
|
|
9349
9349
|
import { eq as eq21, and as and10, count } from "drizzle-orm";
|
|
9350
|
+
async function findSimilarProjects(candidateName) {
|
|
9351
|
+
const candidateNameLower = candidateName.trim().toLowerCase();
|
|
9352
|
+
if (!candidateNameLower) return [];
|
|
9353
|
+
const existing = await db.select({
|
|
9354
|
+
id: projects.id,
|
|
9355
|
+
name: projects.name,
|
|
9356
|
+
description: projects.description
|
|
9357
|
+
}).from(projects);
|
|
9358
|
+
const matches = [];
|
|
9359
|
+
for (const row of existing) {
|
|
9360
|
+
if (row.name.trim().toLowerCase() === candidateNameLower) {
|
|
9361
|
+
matches.push({
|
|
9362
|
+
id: row.id,
|
|
9363
|
+
name: row.name,
|
|
9364
|
+
reason: `Same name: "${row.name}"`
|
|
9365
|
+
});
|
|
9366
|
+
}
|
|
9367
|
+
}
|
|
9368
|
+
return matches;
|
|
9369
|
+
}
|
|
9350
9370
|
function projectTools(ctx) {
|
|
9351
9371
|
return [
|
|
9352
9372
|
defineTool(
|
|
@@ -9380,10 +9400,23 @@ function projectTools(ctx) {
|
|
|
9380
9400
|
{
|
|
9381
9401
|
name: z9.string().min(1).max(100).describe("Project name"),
|
|
9382
9402
|
description: z9.string().max(500).optional().describe("Project description"),
|
|
9383
|
-
workingDirectory: z9.string().max(500).optional().describe("Absolute path to the project's working directory")
|
|
9403
|
+
workingDirectory: z9.string().max(500).optional().describe("Absolute path to the project's working directory"),
|
|
9404
|
+
force: z9.boolean().optional().describe(
|
|
9405
|
+
"Set to true to create a project even when one with the same name already exists. Only use this when the user has explicitly confirmed they want a second same-named project. Default false \u2014 normally you should reuse the existing project returned by the near-duplicate check (its id) instead of creating a duplicate."
|
|
9406
|
+
)
|
|
9384
9407
|
},
|
|
9385
9408
|
async (args) => {
|
|
9386
9409
|
try {
|
|
9410
|
+
if (!args.force) {
|
|
9411
|
+
const similar = await findSimilarProjects(args.name);
|
|
9412
|
+
if (similar.length > 0) {
|
|
9413
|
+
return ok({
|
|
9414
|
+
status: "similar-found",
|
|
9415
|
+
message: "A project with this name already exists. Reuse it by its id for subsequent artifacts (profiles, tables, workflows), or pass force=true to create a separate same-named project.",
|
|
9416
|
+
matches: similar
|
|
9417
|
+
});
|
|
9418
|
+
}
|
|
9419
|
+
}
|
|
9387
9420
|
const now = /* @__PURE__ */ new Date();
|
|
9388
9421
|
const id = crypto.randomUUID();
|
|
9389
9422
|
await db.insert(projects).values({
|
|
@@ -25202,8 +25235,8 @@ import { execFileSync as execFileSync3 } from "child_process";
|
|
|
25202
25235
|
import yaml12 from "js-yaml";
|
|
25203
25236
|
import semver from "semver";
|
|
25204
25237
|
function relayCoreVersion() {
|
|
25205
|
-
if (semver.valid("0.15.
|
|
25206
|
-
return "0.15.
|
|
25238
|
+
if (semver.valid("0.15.5")) {
|
|
25239
|
+
return "0.15.5";
|
|
25207
25240
|
}
|
|
25208
25241
|
try {
|
|
25209
25242
|
const root = getAppRoot(import.meta.dirname, 3);
|
|
@@ -26016,7 +26049,13 @@ async function main() {
|
|
|
26016
26049
|
RELAY_DATA_DIR: DATA_DIR,
|
|
26017
26050
|
RELAY_LAUNCH_CWD: launchCwd2,
|
|
26018
26051
|
PORT: String(actualPort),
|
|
26019
|
-
...opts.safeMode ? { RELAY_SAFE_MODE: "true" } : {}
|
|
26052
|
+
...opts.safeMode ? { RELAY_SAFE_MODE: "true" } : {},
|
|
26053
|
+
// In dev mode, Next blocks cross-origin /_next/* dev-asset requests from
|
|
26054
|
+
// the LAN client's IP, breaking the app over the network (issue #13).
|
|
26055
|
+
// When the operator has opted into non-loopback binding, tell next.config
|
|
26056
|
+
// to allow any dev origin. Mirrors the same trust decision as the warning
|
|
26057
|
+
// above; harmless for the prebuilt `next start` path (no dev-origin gate).
|
|
26058
|
+
...isNonLoopbackHost(bindHost) ? { RELAY_ALLOW_LAN_ORIGINS: "true" } : {}
|
|
26020
26059
|
}
|
|
26021
26060
|
});
|
|
26022
26061
|
if (opts.open !== false) {
|
package/next.config.mjs
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
1
|
+
// When the operator opts into LAN binding (`--hostname` to a non-loopback host,
|
|
2
|
+
// see bin/cli.ts), the CLI sets RELAY_ALLOW_LAN_ORIGINS=true. In dev mode Next
|
|
3
|
+
// otherwise blocks cross-origin requests to /_next/* dev assets from the LAN
|
|
4
|
+
// client's IP — which silently breaks the whole app over the network (issue
|
|
5
|
+
// #13). The client IP is unknowable at config-load time (the bind host is
|
|
6
|
+
// 0.0.0.0 = "all interfaces"), and Next's matcher explicitly rejects a bare
|
|
7
|
+
// "*"/"**" catch-all, so we allow every RFC1918 private-network range instead.
|
|
8
|
+
// This matches the "trusted network" assumption the --hostname warning already
|
|
9
|
+
// states, while still blocking public origins. (Prod `next start` has no such
|
|
10
|
+
// gate; this only affects the dev-mode npx path.)
|
|
11
|
+
const RFC1918_DEV_ORIGINS = [
|
|
12
|
+
"10.*.*.*",
|
|
13
|
+
"192.168.*.*",
|
|
14
|
+
// 172.16.0.0/12 — Next's matcher globs per-octet, so enumerate 16–31.
|
|
15
|
+
...Array.from({ length: 16 }, (_, i) => `172.${16 + i}.*.*`),
|
|
16
|
+
];
|
|
17
|
+
const allowLanDevOrigins = process.env.RELAY_ALLOW_LAN_ORIGINS === "true";
|
|
18
|
+
|
|
1
19
|
/** @type {import('next').NextConfig} */
|
|
2
20
|
const nextConfig = {
|
|
3
21
|
serverExternalPackages: ["better-sqlite3", "pdf-parse", "pdfjs-dist"],
|
|
4
22
|
devIndicators: false,
|
|
5
|
-
allowedDevOrigins:
|
|
23
|
+
allowedDevOrigins: allowLanDevOrigins
|
|
24
|
+
? ["127.0.0.1", ...RFC1918_DEV_ORIGINS]
|
|
25
|
+
: ["127.0.0.1"],
|
|
6
26
|
// The in-app kindle reader was removed; the book lives at ainative.business.
|
|
7
27
|
// Redirect legacy /book links (and any chapter-anchored deep links) there.
|
|
8
28
|
async redirects() {
|
package/package.json
CHANGED
package/src/app/layout.tsx
CHANGED
|
@@ -68,7 +68,13 @@ const CRITICAL_THEME_CSS = `
|
|
|
68
68
|
--surface-2: oklch(0.16 0.02 250);
|
|
69
69
|
--border: oklch(0.26 0.015 250);
|
|
70
70
|
}
|
|
71
|
-
|
|
71
|
+
/* Root rem base. Fixed 14px left the whole rem-based design tiny on high-res
|
|
72
|
+
4K displays (issue #4) — text stayed 14px regardless of viewport, forcing
|
|
73
|
+
browser zoom. This clamp holds a flat 14px through 1920px-wide viewports
|
|
74
|
+
(unchanged for existing laptop/desktop users) and only ramps up on QHD/4K:
|
|
75
|
+
~16px at 2560px, capped at 18px from 3200px+. Type and spacing grow
|
|
76
|
+
proportionally (the design is rem-based) with no component changes. */
|
|
77
|
+
html { background: var(--background); font-size: clamp(14px, 0.35vw + 7.3px, 18px); }
|
|
72
78
|
`.replace(/\s+/g, " ").trim();
|
|
73
79
|
|
|
74
80
|
export default async function RootLayout({
|
|
@@ -187,7 +187,7 @@ export function InstanceSection() {
|
|
|
187
187
|
Running on the main dev repo. Instance upgrade features are disabled.
|
|
188
188
|
Set{" "}
|
|
189
189
|
<code className="font-mono text-[11px] px-1 py-0.5 rounded bg-muted">
|
|
190
|
-
|
|
190
|
+
RELAY_INSTANCE_MODE=true
|
|
191
191
|
</code>{" "}
|
|
192
192
|
to test.
|
|
193
193
|
</p>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import {
|
|
5
|
+
AlertTriangle,
|
|
5
6
|
ChevronDown,
|
|
6
7
|
ChevronRight,
|
|
7
8
|
Network,
|
|
@@ -286,6 +287,7 @@ function ProviderRow({
|
|
|
286
287
|
export function ProvidersAndRuntimesSection() {
|
|
287
288
|
const [data, setData] = useState<ProvidersPayload | null>(null);
|
|
288
289
|
const [loading, setLoading] = useState(true);
|
|
290
|
+
const [error, setError] = useState<string | null>(null);
|
|
289
291
|
const [anthropicOpen, setAnthropicOpen] = useState(false);
|
|
290
292
|
const [openAIOpen, setOpenAIOpen] = useState(false);
|
|
291
293
|
const [openAILoginState, setOpenAILoginState] = useState<OpenAILoginState | null>(null);
|
|
@@ -298,8 +300,20 @@ export function ProvidersAndRuntimesSection() {
|
|
|
298
300
|
if (res.ok) {
|
|
299
301
|
const json = (await res.json()) as ProvidersPayload;
|
|
300
302
|
setData(json);
|
|
303
|
+
setError(null);
|
|
301
304
|
return json;
|
|
302
305
|
}
|
|
306
|
+
// A non-OK response left the section spinning forever (issue #9): the
|
|
307
|
+
// render guard is `loading || !data`, so `data` staying null on error
|
|
308
|
+
// meant a permanent "Loading…" card with no visible failure. Surface it.
|
|
309
|
+
setError(`Failed to load provider configuration (HTTP ${res.status}).`);
|
|
310
|
+
return null;
|
|
311
|
+
} catch (err) {
|
|
312
|
+
setError(
|
|
313
|
+
err instanceof Error
|
|
314
|
+
? `Failed to load provider configuration: ${err.message}`
|
|
315
|
+
: "Failed to load provider configuration.",
|
|
316
|
+
);
|
|
303
317
|
return null;
|
|
304
318
|
} finally {
|
|
305
319
|
setLoading(false);
|
|
@@ -630,7 +644,7 @@ export function ProvidersAndRuntimesSection() {
|
|
|
630
644
|
|
|
631
645
|
// ── Render ───────────────────────────────────────────────────────
|
|
632
646
|
|
|
633
|
-
if (loading
|
|
647
|
+
if (loading) {
|
|
634
648
|
return (
|
|
635
649
|
<Card className="surface-card">
|
|
636
650
|
<CardHeader>
|
|
@@ -644,6 +658,38 @@ export function ProvidersAndRuntimesSection() {
|
|
|
644
658
|
);
|
|
645
659
|
}
|
|
646
660
|
|
|
661
|
+
// Fetch finished but produced no data (non-OK response or thrown error).
|
|
662
|
+
// Show an actionable error with a retry instead of an endless spinner —
|
|
663
|
+
// the previous `loading || !data` guard silently hung here (issue #9).
|
|
664
|
+
if (!data) {
|
|
665
|
+
return (
|
|
666
|
+
<Card className="surface-card">
|
|
667
|
+
<CardHeader>
|
|
668
|
+
<CardTitle className="flex items-center gap-2">
|
|
669
|
+
<AlertTriangle className="h-5 w-5 text-destructive" />
|
|
670
|
+
Providers & Runtimes
|
|
671
|
+
</CardTitle>
|
|
672
|
+
<CardDescription>
|
|
673
|
+
{error ?? "Failed to load provider configuration."}
|
|
674
|
+
</CardDescription>
|
|
675
|
+
</CardHeader>
|
|
676
|
+
<CardContent>
|
|
677
|
+
<button
|
|
678
|
+
type="button"
|
|
679
|
+
onClick={() => {
|
|
680
|
+
setLoading(true);
|
|
681
|
+
setError(null);
|
|
682
|
+
void fetchData();
|
|
683
|
+
}}
|
|
684
|
+
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium hover:bg-accent hover:text-accent-foreground"
|
|
685
|
+
>
|
|
686
|
+
Retry
|
|
687
|
+
</button>
|
|
688
|
+
</CardContent>
|
|
689
|
+
</Card>
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
647
693
|
const { providers, routingPreference, configuredProviderCount } = data;
|
|
648
694
|
const openAIProvider: ProviderState = {
|
|
649
695
|
...providers.openai,
|
package/src/lib/chat/engine.ts
CHANGED
|
@@ -672,9 +672,32 @@ export async function* sendMessage(
|
|
|
672
672
|
const pendingScreenshotTools = new Set<string>(); // tool_use IDs for screenshot tools
|
|
673
673
|
const screenshotAttachments: ScreenshotAttachment[] = [];
|
|
674
674
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
675
|
+
// Race the SDK iterator against the permission side-channel.
|
|
676
|
+
//
|
|
677
|
+
// The Agent SDK's canUseTool callback pauses the SDK indefinitely while a
|
|
678
|
+
// permission gate is pending (see docs: "Execution remains paused until
|
|
679
|
+
// your callback returns"). During that pause the SDK emits NO events, so a
|
|
680
|
+
// plain `for await` over the iterator parks — and any UI event a *second*
|
|
681
|
+
// concurrent gate pushes onto the side-channel would sit undrained until
|
|
682
|
+
// the 120s auto-deny fired. That was the "silent second gate" deadlock:
|
|
683
|
+
// the loop's only driver (SDK events) is exactly what the pending gate
|
|
684
|
+
// stalls.
|
|
685
|
+
//
|
|
686
|
+
// Instead we drive the iterator manually and race each `.next()` against a
|
|
687
|
+
// blocking `sideChannel.pull()`. When a side-channel event wins (a gate
|
|
688
|
+
// surfacing while the SDK is paused), we yield it immediately and loop
|
|
689
|
+
// again WITHOUT re-issuing `.next()` — the outstanding SDK promise is kept
|
|
690
|
+
// in `sdkNext` and only replaced once it resolves, so we never call
|
|
691
|
+
// `.next()` twice concurrently. This mirrors the codex-engine wake-signal
|
|
692
|
+
// loop, which never had this bug.
|
|
693
|
+
const sdkIterator = (response as AsyncIterable<Record<string, unknown>>)[
|
|
694
|
+
Symbol.asyncIterator
|
|
695
|
+
]();
|
|
696
|
+
let sdkNext: Promise<IteratorResult<Record<string, unknown>>> | null =
|
|
697
|
+
sdkIterator.next();
|
|
698
|
+
let sdkDone = false;
|
|
699
|
+
|
|
700
|
+
while (!sdkDone) {
|
|
678
701
|
if (signal?.aborted) break;
|
|
679
702
|
|
|
680
703
|
// Signal that the model has connected and is processing
|
|
@@ -683,7 +706,35 @@ export async function* sendMessage(
|
|
|
683
706
|
yield { type: "status", phase: "generating", message: "Generating response..." };
|
|
684
707
|
}
|
|
685
708
|
|
|
686
|
-
//
|
|
709
|
+
// Race: whichever of the SDK event or a side-channel push resolves first.
|
|
710
|
+
// The SDK promise is persistent (never re-issued while pending); the pull
|
|
711
|
+
// is recreated each turn and resolves with `undefined` on channel close.
|
|
712
|
+
const winner = await Promise.race([
|
|
713
|
+
sdkNext.then((res) => ({ kind: "sdk" as const, res })),
|
|
714
|
+
sideChannel
|
|
715
|
+
.pull()
|
|
716
|
+
.then((event) => ({ kind: "side-channel" as const, event })),
|
|
717
|
+
]);
|
|
718
|
+
|
|
719
|
+
// Side-channel won: the SDK is (likely) paused on a gate. Surface the
|
|
720
|
+
// event now and loop again — `sdkNext` stays pending, unresolved.
|
|
721
|
+
if (winner.kind === "side-channel") {
|
|
722
|
+
// `undefined` means the channel closed (turn ending); ignore it.
|
|
723
|
+
if (winner.event) yield winner.event;
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// SDK event won: consume it and re-arm the iterator for the next turn.
|
|
728
|
+
const { res } = winner;
|
|
729
|
+
sdkNext = null;
|
|
730
|
+
if (res.done) {
|
|
731
|
+
sdkDone = true;
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
const raw = res.value;
|
|
735
|
+
sdkNext = sdkIterator.next();
|
|
736
|
+
|
|
737
|
+
// Drain any side-channel events buffered alongside this SDK event.
|
|
687
738
|
for (const sideEvent of sideChannel.drain()) {
|
|
688
739
|
yield sideEvent;
|
|
689
740
|
}
|
|
@@ -40,7 +40,7 @@ const pendingRequests = new Map<string, PendingRequest>();
|
|
|
40
40
|
*/
|
|
41
41
|
export class AsyncQueue<T> {
|
|
42
42
|
private buffer: T[] = [];
|
|
43
|
-
private waiters: Array<(value: T) => void> = [];
|
|
43
|
+
private waiters: Array<(value: T | undefined) => void> = [];
|
|
44
44
|
private closed = false;
|
|
45
45
|
|
|
46
46
|
push(item: T) {
|
|
@@ -60,12 +60,35 @@ export class AsyncQueue<T> {
|
|
|
60
60
|
return items;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Block until the next item is available, then resolve with it.
|
|
65
|
+
*
|
|
66
|
+
* This is the wake-signal the Claude chat engine races against the SDK
|
|
67
|
+
* iterator: when `canUseTool` pauses the SDK (no more SDK events until a
|
|
68
|
+
* gate resolves), a pull() that began awaiting before the next
|
|
69
|
+
* `emitSideChannelEvent` still resolves the moment that event is pushed —
|
|
70
|
+
* so a second permission gate surfaces immediately instead of stalling
|
|
71
|
+
* until the 120s auto-deny. Resolves with `undefined` (not a rejection)
|
|
72
|
+
* when the queue closes, so a losing race branch never throws.
|
|
73
|
+
*/
|
|
74
|
+
pull(): Promise<T | undefined> {
|
|
75
|
+
if (this.buffer.length > 0) {
|
|
76
|
+
return Promise.resolve(this.buffer.shift());
|
|
77
|
+
}
|
|
78
|
+
if (this.closed) {
|
|
79
|
+
return Promise.resolve(undefined);
|
|
80
|
+
}
|
|
81
|
+
return new Promise<T | undefined>((resolve) => {
|
|
82
|
+
this.waiters.push(resolve);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Close the queue — any pending waiters resolve with the `undefined` sentinel */
|
|
64
87
|
close() {
|
|
65
88
|
this.closed = true;
|
|
66
89
|
this.buffer = [];
|
|
67
90
|
for (const waiter of this.waiters) {
|
|
68
|
-
|
|
91
|
+
waiter(undefined);
|
|
69
92
|
}
|
|
70
93
|
this.waiters = [];
|
|
71
94
|
}
|
|
@@ -128,6 +128,7 @@ Be proactive with tools. If the user asks about project status, use list_tasks t
|
|
|
128
128
|
- For workflows, valid patterns are: sequence, parallel, checkpoint, planner-executor, swarm, loop.
|
|
129
129
|
- **Delay steps** (sequence pattern only): a step with \`delayDuration\` (format: Nm|Nh|Nd|Nw, bounds 1m..30d) pauses the workflow between task steps. Format examples: "30m", "2h", "3d", "1w". Delay steps must have NO profile or prompt — they are pure waits. Use them for outreach sequences, drip campaigns, cooling periods, staged rollouts. A paused workflow resumes automatically when its scheduled time arrives, or immediately when the user clicks "Resume Now".
|
|
130
130
|
- **enrich_table idempotency:** \`enrich_table\` skips rows where the target column already has a non-empty value. If the user wants to overwrite existing values, explain that force re-enrichment is not supported in v1 — they must manually clear the target column first (e.g. via update_row) before re-running.
|
|
131
|
+
- **create_project dedup / reuse:** When composing an app for a named client, reuse an existing project instead of creating a duplicate. \`create_project\` performs its own exact-name check and will return \`{status: "similar-found", matches: [...]}\` instead of inserting when a same-named project already exists — when that happens, use the returned project \`id\` for every subsequent artifact (profiles, tables, workflows, schedules) rather than retrying \`create_project\`. Only pass \`force: true\` when the user has explicitly confirmed they want a second same-named project.
|
|
131
132
|
- **create_workflow dedup:** Before calling \`create_workflow\`, call \`list_workflows\` (filtered by the current project) to check whether a similar workflow already exists. If the user asks to "redesign", "redo", or "update" an existing workflow, call \`update_workflow\` on the matching row instead of creating a new one. \`create_workflow\` performs its own near-duplicate check and will return \`{status: "similar-found", matches: [...]}\` instead of inserting when it finds one — when that happens, surface the matches to the user and confirm intent. Only pass \`force: true\` to \`create_workflow\` when the user has explicitly confirmed they want a second workflow alongside a similar one (e.g., "v2", "alternate approach").
|
|
132
133
|
- When a working directory is specified, always create files relative to it. Never assume the git root is the working directory — they may differ in worktree environments.
|
|
133
134
|
|
|
@@ -7,6 +7,54 @@ import { ok, err, type ToolContext } from "./helpers";
|
|
|
7
7
|
|
|
8
8
|
const VALID_PROJECT_STATUSES = ["active", "paused", "completed"] as const;
|
|
9
9
|
|
|
10
|
+
export interface SimilarProjectMatch {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Find existing projects that duplicate a candidate by name.
|
|
18
|
+
*
|
|
19
|
+
* Projects are top-level (not scoped like workflows), so this scans all of
|
|
20
|
+
* them. Unlike workflow dedup, there is no fuzzy step-text signal to compare —
|
|
21
|
+
* a project is just a name + description — so this is an exact, case- and
|
|
22
|
+
* whitespace-insensitive name match only. That precisely targets the observed
|
|
23
|
+
* compose bug: a long chat truncates the earlier `create_project` call out of
|
|
24
|
+
* the sliding-window context, so the model re-creates the same named client
|
|
25
|
+
* ("Northstar CRE") instead of reusing it. Fuzzy matching would add
|
|
26
|
+
* false-positive risk with no evidence it is needed (engineering principle #6).
|
|
27
|
+
*
|
|
28
|
+
* Used by `create_project` to warn the model before blindly inserting; bypass
|
|
29
|
+
* with `force: true` when the user genuinely wants a second same-named project.
|
|
30
|
+
*/
|
|
31
|
+
export async function findSimilarProjects(
|
|
32
|
+
candidateName: string
|
|
33
|
+
): Promise<SimilarProjectMatch[]> {
|
|
34
|
+
const candidateNameLower = candidateName.trim().toLowerCase();
|
|
35
|
+
if (!candidateNameLower) return [];
|
|
36
|
+
|
|
37
|
+
const existing = await db
|
|
38
|
+
.select({
|
|
39
|
+
id: projects.id,
|
|
40
|
+
name: projects.name,
|
|
41
|
+
description: projects.description,
|
|
42
|
+
})
|
|
43
|
+
.from(projects);
|
|
44
|
+
|
|
45
|
+
const matches: SimilarProjectMatch[] = [];
|
|
46
|
+
for (const row of existing) {
|
|
47
|
+
if (row.name.trim().toLowerCase() === candidateNameLower) {
|
|
48
|
+
matches.push({
|
|
49
|
+
id: row.id,
|
|
50
|
+
name: row.name,
|
|
51
|
+
reason: `Same name: "${row.name}"`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return matches;
|
|
56
|
+
}
|
|
57
|
+
|
|
10
58
|
export function projectTools(ctx: ToolContext) {
|
|
11
59
|
return [
|
|
12
60
|
defineTool(
|
|
@@ -57,9 +105,32 @@ export function projectTools(ctx: ToolContext) {
|
|
|
57
105
|
.max(500)
|
|
58
106
|
.optional()
|
|
59
107
|
.describe("Absolute path to the project's working directory"),
|
|
108
|
+
force: z
|
|
109
|
+
.boolean()
|
|
110
|
+
.optional()
|
|
111
|
+
.describe(
|
|
112
|
+
"Set to true to create a project even when one with the same name already exists. Only use this when the user has explicitly confirmed they want a second same-named project. Default false — normally you should reuse the existing project returned by the near-duplicate check (its id) instead of creating a duplicate."
|
|
113
|
+
),
|
|
60
114
|
},
|
|
61
115
|
async (args) => {
|
|
62
116
|
try {
|
|
117
|
+
// Dedup guard: in a long compose conversation the sliding-window
|
|
118
|
+
// context can evict the earlier create_project call, so the model
|
|
119
|
+
// re-creates the same named client instead of reusing it. Check for
|
|
120
|
+
// an existing same-named project before inserting; pass force=true to
|
|
121
|
+
// bypass. Mirrors the create_workflow near-duplicate pattern.
|
|
122
|
+
if (!args.force) {
|
|
123
|
+
const similar = await findSimilarProjects(args.name);
|
|
124
|
+
if (similar.length > 0) {
|
|
125
|
+
return ok({
|
|
126
|
+
status: "similar-found",
|
|
127
|
+
message:
|
|
128
|
+
"A project with this name already exists. Reuse it by its id for subsequent artifacts (profiles, tables, workflows), or pass force=true to create a separate same-named project.",
|
|
129
|
+
matches: similar,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
63
134
|
const now = new Date();
|
|
64
135
|
const id = crypto.randomUUID();
|
|
65
136
|
|