@dugleelabs/copair 1.0.1 → 1.1.0

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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { join as join9 } from "path";
4
+ import { join as join13 } from "path";
5
5
 
6
6
  // src/cli/args.ts
7
7
  import { Command } from "commander";
@@ -32,6 +32,9 @@ function parseArgs(argv = process.argv) {
32
32
  };
33
33
  }
34
34
 
35
+ // src/providers/interface.ts
36
+ var NATIVE_SEARCH_MARKER = "_native_web_search";
37
+
35
38
  // src/core/conversation.ts
36
39
  var ConversationManager = class {
37
40
  messages = [];
@@ -366,6 +369,12 @@ function formatToolCall(name, argsJson) {
366
369
  case "grep":
367
370
  raw = `grep: ${args.pattern ?? ""}`;
368
371
  break;
372
+ case "web_search":
373
+ raw = `copair search: "${args.query ?? ""}"`;
374
+ break;
375
+ case "_native_web_search":
376
+ raw = `provider search: "${args.query ?? ""}"`;
377
+ break;
369
378
  default:
370
379
  raw = name;
371
380
  break;
@@ -419,7 +428,7 @@ var Renderer = class {
419
428
  this.endToolIndicator();
420
429
  }
421
430
  const raw = chunk.text ?? "";
422
- const display = textFilter ? textFilter(raw) : raw;
431
+ const display = textFilter ? textFilter.write(raw) : raw;
423
432
  if (display && this.mdWriter) this.mdWriter.write(display);
424
433
  fullText += raw;
425
434
  if (display) this.bridge?.emit("stream-text", display);
@@ -492,6 +501,11 @@ Error: ${chunk.error}
492
501
  break;
493
502
  }
494
503
  }
504
+ if (textFilter) {
505
+ const trailing = textFilter.flush();
506
+ if (trailing && this.mdWriter) this.mdWriter.write(trailing);
507
+ if (trailing) this.bridge?.emit("stream-text", trailing);
508
+ }
495
509
  if (this.mdWriter) {
496
510
  this.mdWriter.flush();
497
511
  this.mdWriter = null;
@@ -545,33 +559,41 @@ Error: ${chunk.error}
545
559
  */
546
560
  showGitDiff(output) {
547
561
  if (!output.trim()) return;
548
- if (this.inkMode) return;
549
- const maxLines = 80;
550
- const lines = output.split("\n");
551
- const display = lines.slice(0, maxLines);
552
- process.stderr.write("\n");
553
- for (const line of display) {
554
- if (line.startsWith("+++") || line.startsWith("---")) {
555
- process.stderr.write(chalk3.bold.white(line) + "\n");
556
- } else if (line.startsWith("+")) {
557
- process.stderr.write(chalk3.bgGreen.black(line) + "\n");
558
- } else if (line.startsWith("-")) {
559
- process.stderr.write(chalk3.bgRedBright.black(line) + "\n");
560
- } else if (line.startsWith("@@")) {
561
- process.stderr.write(chalk3.cyan(line) + "\n");
562
- } else if (line.startsWith("diff ")) {
563
- process.stderr.write(chalk3.bold.yellow(line) + "\n");
564
- } else if (line.startsWith("index ")) {
565
- process.stderr.write(chalk3.gray(line) + "\n");
566
- } else {
567
- process.stderr.write(chalk3.gray(line) + "\n");
562
+ if (!this.inkMode) {
563
+ const maxLines = 80;
564
+ const lines = output.split("\n");
565
+ const display = lines.slice(0, maxLines);
566
+ process.stderr.write("\n");
567
+ for (const line of display) {
568
+ if (line.startsWith("+++") || line.startsWith("---")) {
569
+ process.stderr.write(chalk3.bold.white(line) + "\n");
570
+ } else if (line.startsWith("+")) {
571
+ process.stderr.write(chalk3.bgGreen.black(line) + "\n");
572
+ } else if (line.startsWith("-")) {
573
+ process.stderr.write(chalk3.bgRedBright.black(line) + "\n");
574
+ } else if (line.startsWith("@@")) {
575
+ process.stderr.write(chalk3.cyan(line) + "\n");
576
+ } else if (line.startsWith("diff ")) {
577
+ process.stderr.write(chalk3.bold.yellow(line) + "\n");
578
+ } else if (line.startsWith("index ")) {
579
+ process.stderr.write(chalk3.gray(line) + "\n");
580
+ } else {
581
+ process.stderr.write(chalk3.gray(line) + "\n");
582
+ }
568
583
  }
569
- }
570
- if (lines.length > maxLines) {
571
- process.stderr.write(chalk3.gray(` ... ${lines.length - maxLines} more lines
584
+ if (lines.length > maxLines) {
585
+ process.stderr.write(chalk3.gray(` ... ${lines.length - maxLines} more lines
572
586
  `));
587
+ }
588
+ process.stderr.write("\n");
589
+ }
590
+ if (this.bridge) {
591
+ const lines = output.split("\n");
592
+ this.bridge.emit("diff", {
593
+ filePath: extractDiffFilePath(lines),
594
+ hunks: [{ oldStart: 0, newStart: 0, lines }]
595
+ });
573
596
  }
574
- process.stderr.write("\n");
575
597
  }
576
598
  /**
577
599
  * Show a diff snippet for file mutations (write/edit).
@@ -694,6 +716,68 @@ function formatDuration(ms) {
694
716
  if (ms < 1e3) return `${Math.round(ms)}ms`;
695
717
  return `${(ms / 1e3).toFixed(1)}s`;
696
718
  }
719
+ function extractDiffFilePath(lines) {
720
+ for (const line of lines) {
721
+ if (line.startsWith("diff --git")) {
722
+ const match = line.match(/b\/(.+)$/);
723
+ if (match) return match[1];
724
+ }
725
+ }
726
+ return "git diff";
727
+ }
728
+
729
+ // src/core/logger.ts
730
+ var SECRET_PATTERNS = [
731
+ /sk-[a-zA-Z0-9_-]{20,}/g,
732
+ /lin_api_[a-zA-Z0-9_-]+/g,
733
+ /AIza[a-zA-Z0-9_-]{35}/g,
734
+ /Bearer\s+[a-zA-Z0-9._-]+/g
735
+ ];
736
+ function redactSecrets(text) {
737
+ let result = text;
738
+ for (const pattern of SECRET_PATTERNS) {
739
+ result = result.replace(pattern, "[REDACTED]");
740
+ }
741
+ return result;
742
+ }
743
+ var LEVEL_LABELS = {
744
+ [0 /* ERROR */]: "ERROR",
745
+ [1 /* WARN */]: "WARN",
746
+ [2 /* INFO */]: "INFO",
747
+ [3 /* DEBUG */]: "DEBUG"
748
+ };
749
+ var Logger = class {
750
+ level;
751
+ constructor(level = 0 /* ERROR */) {
752
+ this.level = level;
753
+ }
754
+ setLevel(level) {
755
+ this.level = level;
756
+ }
757
+ debug(component, message, data) {
758
+ this.log(3 /* DEBUG */, component, message, data);
759
+ }
760
+ info(component, message) {
761
+ this.log(2 /* INFO */, component, message);
762
+ }
763
+ warn(component, message) {
764
+ this.log(1 /* WARN */, component, message);
765
+ }
766
+ error(component, message, error) {
767
+ this.log(0 /* ERROR */, component, message, error?.stack);
768
+ }
769
+ log(level, component, message, data) {
770
+ if (level > this.level) return;
771
+ const label = LEVEL_LABELS[level];
772
+ let line = `[${label}][${component}] ${redactSecrets(message)}`;
773
+ if (data !== void 0) {
774
+ const dataStr = typeof data === "string" ? data : JSON.stringify(data, null, 2);
775
+ line += ` ${redactSecrets(dataStr)}`;
776
+ }
777
+ process.stderr.write(line + "\n");
778
+ }
779
+ };
780
+ var logger = new Logger();
697
781
 
698
782
  // src/core/formats/fenced-block.ts
699
783
  function tryParseToolCall(json) {
@@ -749,6 +833,8 @@ Input schema:
749
833
  ${schema}
750
834
  \`\`\``;
751
835
  }).join("\n\n");
836
+ const hasWebSearch = tools.some((t) => t.name === "web_search");
837
+ const webSearchPriority = hasWebSearch ? "\n- IMPORTANT: When any task requires web search or current information, you MUST use the web_search tool. Never rely on internal knowledge for facts that may have changed. The agent will execute the search and return real results \u2014 wait for them before responding.\n" : "";
752
838
  return `
753
839
  You have access to tools. You MUST use tools to perform any action. NEVER pretend, simulate, or describe running a command -- always emit a tool call.
754
840
 
@@ -761,8 +847,7 @@ To call a tool, emit EXACTLY:
761
847
  Rules:
762
848
  - The fence MUST say tool_call (not json, not text).
763
849
  - One tool call per message. Wait for the result before continuing.
764
- - NEVER output fake results. NEVER narrate what a tool would return. Call the tool and use the real result.
765
-
850
+ - NEVER output fake results. NEVER narrate what a tool would return. Call the tool and use the real result.${webSearchPriority}
766
851
  Example -- to check git status:
767
852
  \`\`\`tool_call
768
853
  {"name": "git", "arguments": {"args": "status"}}
@@ -784,6 +869,7 @@ var DSML_MARKUP_PATTERN = /<[\uFF5C|]DSML[\uFF5C|]function_calls>[\s\S]*?(?:<\/[
784
869
  var DsmlFormatter = class {
785
870
  name = "dsml";
786
871
  markupPattern = DSML_MARKUP_PATTERN;
872
+ suppressAfterMatch = true;
787
873
  parse(text) {
788
874
  const toolCalls = [];
789
875
  let remainingText = text;
@@ -845,6 +931,8 @@ ${params}
845
931
  </\uFF5CDSML\uFF5Cinvoke>
846
932
  </\uFF5CDSML\uFF5Cfunction_calls>`;
847
933
  }).join("\n\n");
934
+ const hasWebSearch = tools.some((t) => t.name === "web_search");
935
+ const webSearchPriority = hasWebSearch ? "\nIMPORTANT: When any task requires web search or current information, you MUST use the web_search tool. Never rely on internal knowledge for facts that may have changed. The agent will execute the search and return real results.\n" : "";
848
936
  return `
849
937
  You have access to tools. To call a tool, use DSML format:
850
938
 
@@ -853,7 +941,7 @@ You have access to tools. To call a tool, use DSML format:
853
941
  <\uFF5CDSML\uFF5Cparameter name="param" string="true">value<\uFF5CDSML\uFF5Cparameter>
854
942
  </\uFF5CDSML\uFF5Cinvoke>
855
943
  </\uFF5CDSML\uFF5Cfunction_calls>
856
-
944
+ ${webSearchPriority}
857
945
  ## Tools
858
946
 
859
947
  ${toolDescriptions}
@@ -868,6 +956,9 @@ var MARKUP_PATTERN2 = /<tool_call>[\s\S]*?(?:<\/tool_call>|$)/g;
868
956
  var QwenXmlFormatter = class {
869
957
  name = "qwen-xml";
870
958
  markupPattern = MARKUP_PATTERN2;
959
+ openTag = "<tool_call>";
960
+ closeTag = "</tool_call>";
961
+ suppressAfterMatch = true;
871
962
  parse(text) {
872
963
  const toolCalls = [];
873
964
  let remainingText = text;
@@ -897,6 +988,8 @@ Input schema:
897
988
  ${schema}
898
989
  \`\`\``;
899
990
  }).join("\n\n");
991
+ const hasWebSearch = tools.some((t) => t.name === "web_search");
992
+ const webSearchPriority = hasWebSearch ? "\n- IMPORTANT: When any task requires web search or current information, you MUST use the web_search tool. Never rely on internal knowledge for facts that may have changed. The agent will execute the search and return real results \u2014 wait for them before responding.\n" : "";
900
993
  return `
901
994
  You have access to tools. You MUST use tools to perform any action. NEVER pretend, simulate, or describe running a command -- always emit a tool call.
902
995
 
@@ -909,7 +1002,7 @@ To call a tool, emit EXACTLY:
909
1002
  Rules:
910
1003
  - One tool call per message. Wait for the result before continuing.
911
1004
  - NEVER output fake results. NEVER narrate what a tool would return. Call the tool and use the real result.
912
-
1005
+ - NEVER continue talking after emitting a tool call. Stop immediately after </tool_call> and wait for the result.${webSearchPriority}
913
1006
  Example -- to check git status:
914
1007
  <tool_call>
915
1008
  {"name": "git", "arguments": {"args": "status"}}
@@ -942,10 +1035,85 @@ function createFormatter(name) {
942
1035
  return new FencedBlockFormatter();
943
1036
  }
944
1037
  }
945
- function buildTextFilter(formatter) {
946
- return (text) => {
947
- return text.replace(new RegExp(formatter.markupPattern.source, formatter.markupPattern.flags), "");
948
- };
1038
+ var StreamingMarkupFilter = class {
1039
+ buffer = "";
1040
+ suppressing = false;
1041
+ /** Set to true once the first complete tool-call block has been processed.
1042
+ * When `suppressAfterMatch` is enabled, all further text is discarded. */
1043
+ matchSeen = false;
1044
+ openTag;
1045
+ closeTag;
1046
+ suppressAfterMatch;
1047
+ fallbackRe;
1048
+ constructor(formatter) {
1049
+ if (formatter.openTag && formatter.closeTag) {
1050
+ this.openTag = formatter.openTag;
1051
+ this.closeTag = formatter.closeTag;
1052
+ this.suppressAfterMatch = formatter.suppressAfterMatch ?? false;
1053
+ } else {
1054
+ this.suppressAfterMatch = false;
1055
+ this.fallbackRe = new RegExp(
1056
+ formatter.markupPattern.source,
1057
+ formatter.markupPattern.flags
1058
+ );
1059
+ }
1060
+ }
1061
+ /** Feed the next streaming chunk; returns text safe to display. */
1062
+ write(chunk) {
1063
+ if (!this.openTag || !this.closeTag) {
1064
+ return this.fallbackRe ? chunk.replace(this.fallbackRe, "") : chunk;
1065
+ }
1066
+ if (this.suppressAfterMatch && this.matchSeen) return "";
1067
+ this.buffer += chunk;
1068
+ let output = "";
1069
+ while (this.buffer.length > 0) {
1070
+ if (!this.suppressing) {
1071
+ const idx = this.buffer.indexOf(this.openTag);
1072
+ if (idx === -1) {
1073
+ const hold = this._partialPrefixLen(this.buffer, this.openTag);
1074
+ output += this.buffer.slice(0, this.buffer.length - hold);
1075
+ this.buffer = hold > 0 ? this.buffer.slice(this.buffer.length - hold) : "";
1076
+ break;
1077
+ }
1078
+ output += this.buffer.slice(0, idx);
1079
+ this.buffer = this.buffer.slice(idx + this.openTag.length);
1080
+ this.suppressing = true;
1081
+ } else {
1082
+ const idx = this.buffer.indexOf(this.closeTag);
1083
+ if (idx === -1) break;
1084
+ this.buffer = this.buffer.slice(idx + this.closeTag.length);
1085
+ this.suppressing = false;
1086
+ this.matchSeen = true;
1087
+ if (this.suppressAfterMatch) {
1088
+ this.buffer = "";
1089
+ break;
1090
+ }
1091
+ }
1092
+ }
1093
+ return output;
1094
+ }
1095
+ /** Call once after the stream ends to flush any held-back text. */
1096
+ flush() {
1097
+ if (!this.openTag) return "";
1098
+ if (this.suppressing || this.suppressAfterMatch && this.matchSeen) {
1099
+ this.buffer = "";
1100
+ this.suppressing = false;
1101
+ return "";
1102
+ }
1103
+ const out = this.buffer;
1104
+ this.buffer = "";
1105
+ return out;
1106
+ }
1107
+ /** Returns the length of the longest suffix of `text` that is a prefix of `tag`. */
1108
+ _partialPrefixLen(text, tag) {
1109
+ for (let len = Math.min(tag.length - 1, text.length); len > 0; len--) {
1110
+ if (text.endsWith(tag.slice(0, len))) return len;
1111
+ }
1112
+ return 0;
1113
+ }
1114
+ };
1115
+ function buildStreamingFilter(formatter) {
1116
+ return new StreamingMarkupFilter(formatter);
949
1117
  }
950
1118
 
951
1119
  // src/core/agent.ts
@@ -970,7 +1138,7 @@ var Agent = class {
970
1138
  this.renderer = new Renderer(options.bridge);
971
1139
  this.options = options;
972
1140
  this.formatter = resolveFormatter(provider.name, model, options.toolCallFormat);
973
- this.textFilter = buildTextFilter(this.formatter);
1141
+ this.textFilter = buildStreamingFilter(this.formatter);
974
1142
  }
975
1143
  get model() {
976
1144
  return this._model;
@@ -1018,21 +1186,30 @@ ${summary}`
1018
1186
  this._model = newModel;
1019
1187
  this.contextWindow = new ContextWindowManager(newProvider.maxContextWindow);
1020
1188
  this.formatter = resolveFormatter(newProvider.name, newModel, this.options.toolCallFormat);
1021
- this.textFilter = buildTextFilter(this.formatter);
1189
+ this.textFilter = buildStreamingFilter(this.formatter);
1022
1190
  }
1023
1191
  async handleMessage(userInput) {
1024
1192
  this.conversation.appendText("user", userInput);
1025
1193
  let totalUsage = null;
1026
1194
  let lastInputTokens = 0;
1195
+ let agentWebSearchFailed = false;
1027
1196
  while (true) {
1028
1197
  const messages = await this.contextWindow.checkAndTruncate(
1029
1198
  this.conversation.getHistory(),
1030
1199
  this.provider
1031
1200
  );
1032
1201
  const allTools = this.toolRegistry.getAllDefinitions();
1033
- const tools = this.provider.supportsToolCalling ? allTools : [];
1202
+ let tools = this.provider.supportsToolCalling ? allTools : [];
1203
+ if (agentWebSearchFailed && this.provider.supportsNativeSearch) {
1204
+ logger.info("web_search", "Falling back to provider native search (agent search unavailable)");
1205
+ tools = tools.map(
1206
+ (t) => t.name === "web_search" ? { name: NATIVE_SEARCH_MARKER, description: t.description, inputSchema: t.inputSchema } : t
1207
+ );
1208
+ agentWebSearchFailed = false;
1209
+ }
1034
1210
  const toolSystemPrompt = !this.provider.supportsToolCalling && allTools.length > 0 ? this.formatter.buildSystemPrompt(allTools) : void 0;
1035
- const systemPrompt = toolSystemPrompt ? [this.options.systemPrompt, toolSystemPrompt].filter(Boolean).join("\n\n") : this.options.systemPrompt;
1211
+ const webSearchHint = allTools.some((t) => t.name === "web_search") ? "When the user asks you to search the web, or requests current/up-to-date information, you MUST call the web_search tool. Never answer such queries from training knowledge alone \u2014 always invoke the tool and base your response on its results." : void 0;
1212
+ const systemPrompt = [this.options.systemPrompt, toolSystemPrompt, webSearchHint].filter(Boolean).join("\n\n") || void 0;
1036
1213
  const stream = this.provider.chat(messages, tools, {
1037
1214
  model: this._model,
1038
1215
  stream: true,
@@ -1044,18 +1221,21 @@ ${summary}`
1044
1221
  stream,
1045
1222
  this.textFilter
1046
1223
  );
1047
- let toolCalls = nativeToolCalls;
1224
+ const nonNativeToolCalls = nativeToolCalls.filter(
1225
+ (tc) => tc.name !== NATIVE_SEARCH_MARKER
1226
+ );
1227
+ let toolCalls = nonNativeToolCalls;
1048
1228
  let cleanedText = fullText;
1049
1229
  if (fullText) {
1050
1230
  const parsed = this.formatter.parse(fullText);
1051
1231
  if (parsed.toolCalls.length > 0) {
1052
1232
  const nativeKeys = new Set(
1053
- nativeToolCalls.map((tc) => `${tc.name}:${tc.arguments}`)
1233
+ nonNativeToolCalls.map((tc) => `${tc.name}:${tc.arguments}`)
1054
1234
  );
1055
1235
  const uniqueParsed = parsed.toolCalls.filter(
1056
1236
  (tc) => !nativeKeys.has(`${tc.name}:${tc.arguments}`)
1057
1237
  );
1058
- toolCalls = [...nativeToolCalls, ...uniqueParsed];
1238
+ toolCalls = [...nonNativeToolCalls, ...uniqueParsed];
1059
1239
  cleanedText = parsed.remainingText;
1060
1240
  }
1061
1241
  }
@@ -1132,6 +1312,14 @@ ${summary}`
1132
1312
  }
1133
1313
  }
1134
1314
  }
1315
+ if (tc.name === "web_search" && result.isError) {
1316
+ if (this.provider.supportsNativeSearch) {
1317
+ agentWebSearchFailed = true;
1318
+ logger.info("web_search", "Agent web search failed \u2014 will fall back to provider native search on next turn");
1319
+ }
1320
+ } else if (tc.name === "web_search" && !result.isError) {
1321
+ agentWebSearchFailed = false;
1322
+ }
1135
1323
  toolResults.push({
1136
1324
  type: "tool_result",
1137
1325
  toolUseId: tc.id,
@@ -1196,6 +1384,10 @@ var ContextConfigSchema = z.object({
1196
1384
  max_sessions: z.number().int().positive().default(1),
1197
1385
  knowledge_max_size: z.number().int().positive().default(8192)
1198
1386
  });
1387
+ var KnowledgeConfigSchema = z.object({
1388
+ warn_size_kb: z.number().int().positive().default(8),
1389
+ max_size_kb: z.number().int().positive().default(16)
1390
+ });
1199
1391
  var UIConfigSchema = z.object({
1200
1392
  bordered_input: z.boolean().default(true),
1201
1393
  status_bar: z.boolean().default(true),
@@ -1215,6 +1407,7 @@ var CopairConfigSchema = z.object({
1215
1407
  web_search: WebSearchConfigSchema.optional(),
1216
1408
  identity: IdentityConfigSchema.default({ name: "Copair", email: "copair[bot]@noreply.dugleelabs.io" }),
1217
1409
  context: ContextConfigSchema.default(() => ContextConfigSchema.parse({})),
1410
+ knowledge: KnowledgeConfigSchema.default(() => KnowledgeConfigSchema.parse({})),
1218
1411
  ui: UIConfigSchema.default(() => UIConfigSchema.parse({}))
1219
1412
  });
1220
1413
 
@@ -1274,7 +1467,7 @@ function loadYamlFile(filePath) {
1274
1467
  }
1275
1468
  function loadConfig(projectDir) {
1276
1469
  const globalPath = resolve2(homedir(), ".copair", "config.yaml");
1277
- const projectPath = projectDir ? resolve2(projectDir, ".copair.yaml") : resolve2(process.cwd(), ".copair.yaml");
1470
+ const projectPath = projectDir ? resolve2(projectDir, ".copair", "config.yaml") : resolve2(process.cwd(), ".copair", "config.yaml");
1278
1471
  const globalConfig = loadYamlFile(globalPath);
1279
1472
  const projectConfig = loadYamlFile(projectPath);
1280
1473
  if (!globalConfig && !projectConfig) {
@@ -1286,6 +1479,9 @@ function loadConfig(projectDir) {
1286
1479
  } else {
1287
1480
  merged = globalConfig ?? projectConfig;
1288
1481
  }
1482
+ if (merged.version === void 0) {
1483
+ merged = { ...merged, version: CURRENT_CONFIG_VERSION };
1484
+ }
1289
1485
  const version = merged.version;
1290
1486
  if (typeof version === "number" && version > CURRENT_CONFIG_VERSION) {
1291
1487
  throw new Error(
@@ -1360,7 +1556,7 @@ var ProviderRegistry = class {
1360
1556
 
1361
1557
  // src/providers/openai.ts
1362
1558
  import OpenAI from "openai";
1363
- function toOpenAIMessages(messages, systemPrompt) {
1559
+ function toOpenAIMessages(messages, systemPrompt, supportsToolCalling = true) {
1364
1560
  const result = [];
1365
1561
  if (systemPrompt) {
1366
1562
  result.push({ role: "system", content: systemPrompt });
@@ -1374,6 +1570,22 @@ function toOpenAIMessages(messages, systemPrompt) {
1374
1570
  continue;
1375
1571
  }
1376
1572
  if (msg.role === "user") {
1573
+ if (!supportsToolCalling) {
1574
+ const parts = [];
1575
+ for (const b of msg.content) {
1576
+ if (b.type === "tool_result") {
1577
+ const label = b.isError ? "Tool error" : "Tool result";
1578
+ parts.push(`[${label}: ${b.toolUseId}]
1579
+ ${b.content ?? ""}`);
1580
+ } else if (b.type === "text" && b.text) {
1581
+ parts.push(b.text);
1582
+ }
1583
+ }
1584
+ if (parts.length > 0) {
1585
+ result.push({ role: "user", content: parts.join("\n\n") });
1586
+ }
1587
+ continue;
1588
+ }
1377
1589
  const textParts = msg.content.filter((b) => b.type === "text");
1378
1590
  const toolResults = msg.content.filter((b) => b.type === "tool_result");
1379
1591
  for (const tr of toolResults) {
@@ -1393,6 +1605,14 @@ function toOpenAIMessages(messages, systemPrompt) {
1393
1605
  }
1394
1606
  if (msg.role === "assistant") {
1395
1607
  const text = msg.content.filter((b) => b.type === "text").map((b) => b.text).join("");
1608
+ if (!supportsToolCalling) {
1609
+ const toolCallTexts = msg.content.filter((b) => b.type === "tool_use").map((b) => `<tool_call>
1610
+ ${JSON.stringify({ name: b.name, arguments: b.input })}
1611
+ </tool_call>`);
1612
+ const combined = [text, ...toolCallTexts].filter(Boolean).join("\n");
1613
+ result.push({ role: "assistant", content: combined || null });
1614
+ continue;
1615
+ }
1396
1616
  const toolCalls = msg.content.filter((b) => b.type === "tool_use").map((b) => ({
1397
1617
  id: b.id,
1398
1618
  type: "function",
@@ -1439,7 +1659,7 @@ function createOpenAIProvider(config, modelAlias) {
1439
1659
  supportsStreaming,
1440
1660
  maxContextWindow,
1441
1661
  async *chat(messages, tools, options) {
1442
- const openaiMessages = toOpenAIMessages(messages, options.systemPrompt);
1662
+ const openaiMessages = toOpenAIMessages(messages, options.systemPrompt, supportsToolCalling);
1443
1663
  const openaiTools = supportsToolCalling ? toOpenAITools(tools) : void 0;
1444
1664
  if (options.stream && supportsStreaming) {
1445
1665
  const stream = await client.chat.completions.create({
@@ -1574,10 +1794,15 @@ function toAnthropicMessages(messages) {
1574
1794
  return result;
1575
1795
  }
1576
1796
  function toAnthropicTools(tools) {
1577
- if (tools.length === 0) return void 0;
1578
- return tools.map((t) => {
1579
- if (t.name === "web_search") {
1580
- return { type: "web_search_20250305", name: "web_search" };
1797
+ if (tools.length === 0) return { tools: void 0, builtInToolNames: /* @__PURE__ */ new Set() };
1798
+ const builtInToolNames = /* @__PURE__ */ new Set();
1799
+ const converted = tools.map((t) => {
1800
+ if (t.name === NATIVE_SEARCH_MARKER) {
1801
+ builtInToolNames.add("web_search");
1802
+ return {
1803
+ type: "web_search_20250305",
1804
+ name: "web_search"
1805
+ };
1581
1806
  }
1582
1807
  return {
1583
1808
  name: t.name,
@@ -1585,6 +1810,7 @@ function toAnthropicTools(tools) {
1585
1810
  input_schema: t.inputSchema
1586
1811
  };
1587
1812
  });
1813
+ return { tools: converted, builtInToolNames };
1588
1814
  }
1589
1815
  function createAnthropicProvider(config, modelAlias) {
1590
1816
  const modelConfig = config.models[modelAlias];
@@ -1600,10 +1826,11 @@ function createAnthropicProvider(config, modelAlias) {
1600
1826
  name: "anthropic",
1601
1827
  supportsToolCalling: true,
1602
1828
  supportsStreaming: true,
1829
+ supportsNativeSearch: true,
1603
1830
  maxContextWindow,
1604
1831
  async *chat(messages, tools, options) {
1605
1832
  const anthropicMessages = toAnthropicMessages(messages);
1606
- const anthropicTools = toAnthropicTools(tools);
1833
+ const { tools: anthropicTools, builtInToolNames } = toAnthropicTools(tools);
1607
1834
  const systemPrompt = options.systemPrompt ?? messages.filter((m) => m.role === "system").flatMap((m) => m.content.filter((b) => b.type === "text")).map((b) => b.text).join("\n");
1608
1835
  if (options.stream) {
1609
1836
  const stream = client.messages.stream({
@@ -1628,25 +1855,39 @@ function createAnthropicProvider(config, modelAlias) {
1628
1855
  yield { type: "text", text: event.delta.text };
1629
1856
  } else if (event.delta.type === "input_json_delta") {
1630
1857
  currentToolArgs += event.delta.partial_json;
1858
+ if (!builtInToolNames.has(currentToolName)) {
1859
+ yield {
1860
+ type: "tool_call_delta",
1861
+ toolCall: {
1862
+ id: currentToolId,
1863
+ name: currentToolName,
1864
+ arguments: event.delta.partial_json
1865
+ }
1866
+ };
1867
+ }
1868
+ }
1869
+ }
1870
+ if (event.type === "content_block_stop" && currentToolId && currentToolName) {
1871
+ if (builtInToolNames.has(currentToolName)) {
1631
1872
  yield {
1632
- type: "tool_call_delta",
1873
+ type: "tool_call",
1874
+ toolCall: {
1875
+ id: currentToolId,
1876
+ name: NATIVE_SEARCH_MARKER,
1877
+ arguments: currentToolArgs,
1878
+ metadata: { builtIn: true }
1879
+ }
1880
+ };
1881
+ } else {
1882
+ yield {
1883
+ type: "tool_call",
1633
1884
  toolCall: {
1634
1885
  id: currentToolId,
1635
1886
  name: currentToolName,
1636
- arguments: event.delta.partial_json
1887
+ arguments: currentToolArgs
1637
1888
  }
1638
1889
  };
1639
1890
  }
1640
- }
1641
- if (event.type === "content_block_stop" && currentToolId && currentToolName) {
1642
- yield {
1643
- type: "tool_call",
1644
- toolCall: {
1645
- id: currentToolId,
1646
- name: currentToolName,
1647
- arguments: currentToolArgs
1648
- }
1649
- };
1650
1891
  currentToolId = "";
1651
1892
  currentToolName = "";
1652
1893
  currentToolArgs = "";
@@ -2228,6 +2469,11 @@ async function searchSearxng(query, baseUrl, maxResults) {
2228
2469
  url.searchParams.set("format", "json");
2229
2470
  const response = await fetch(url.toString());
2230
2471
  if (!response.ok) {
2472
+ if (response.status === 403) {
2473
+ throw new Error(
2474
+ `SearXNG returned 403 Forbidden. The JSON format is likely disabled on this instance. Enable it in settings.yml under search.formats by adding "json" to the list.`
2475
+ );
2476
+ }
2231
2477
  throw new Error(`SearXNG error: ${response.status} ${response.statusText}`);
2232
2478
  }
2233
2479
  const data = await response.json();
@@ -2256,12 +2502,13 @@ function createWebSearchTool(config) {
2256
2502
  required: ["query"]
2257
2503
  }
2258
2504
  },
2259
- requiresPermission: false,
2505
+ requiresPermission: true,
2260
2506
  async execute(input) {
2261
2507
  const query = String(input["query"] ?? "");
2262
2508
  if (!query) {
2263
2509
  return { content: "Error: query is required", isError: true };
2264
2510
  }
2511
+ logger.info("web_search", `Agent web search via ${webSearchConfig.provider}: "${query}"`);
2265
2512
  try {
2266
2513
  let results;
2267
2514
  switch (webSearchConfig.provider) {
@@ -3791,55 +4038,10 @@ async function resolveSummarizationModel(configModel, activeModel) {
3791
4038
  return null;
3792
4039
  }
3793
4040
 
3794
- // src/core/init.ts
3795
- import { existsSync as existsSync8, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3, readFileSync as readFileSync5 } from "fs";
3796
- import { join as join5 } from "path";
3797
- var PROJECT_CONFIG_TEMPLATE = `version: 1
3798
- # Project-level overrides (merged with ~/.copair/config.yaml)
3799
- # Uncomment and customize as needed:
3800
- #
3801
- # default_model: claude-sonnet
3802
- #
3803
- # context:
3804
- # summarization_model: qwen-7b
3805
- # max_sessions: 20
3806
- # knowledge_max_size: 8192
3807
- #
3808
- # permissions:
3809
- # mode: ask
3810
- # allow_commands:
3811
- # - git status
3812
- # - git diff
3813
- `;
3814
- function ensureProjectInit(cwd) {
3815
- const copairDir = join5(cwd, ".copair");
3816
- const alreadyInit = existsSync8(copairDir);
3817
- try {
3818
- mkdirSync3(join5(copairDir, "commands"), { recursive: true });
3819
- const innerGitignore = join5(copairDir, ".gitignore");
3820
- if (!existsSync8(innerGitignore)) {
3821
- writeFileSync3(innerGitignore, "sessions/\n", { mode: 420 });
3822
- }
3823
- const projectConfig = join5(cwd, ".copair.yaml");
3824
- if (!existsSync8(projectConfig)) {
3825
- writeFileSync3(projectConfig, PROJECT_CONFIG_TEMPLATE, { mode: 420 });
3826
- }
3827
- const rootGitignore = join5(cwd, ".gitignore");
3828
- if (existsSync8(rootGitignore)) {
3829
- const content = readFileSync5(rootGitignore, "utf8");
3830
- if (!content.includes(".copair/sessions")) {
3831
- writeFileSync3(rootGitignore, content.trimEnd() + "\n.copair/sessions/\n");
3832
- }
3833
- }
3834
- } catch {
3835
- }
3836
- return !alreadyInit;
3837
- }
3838
-
3839
4041
  // src/core/version-check.ts
3840
4042
  import { readFile as readFile5, writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
3841
- import { existsSync as existsSync9 } from "fs";
3842
- import { join as join6, resolve as resolve7, dirname as dirname3 } from "path";
4043
+ import { existsSync as existsSync8 } from "fs";
4044
+ import { join as join5, resolve as resolve7, dirname as dirname3 } from "path";
3843
4045
  import { createRequire as createRequire2 } from "module";
3844
4046
  import { fileURLToPath as fileURLToPath2 } from "url";
3845
4047
  var _dir2 = dirname3(fileURLToPath2(import.meta.url));
@@ -3854,7 +4056,7 @@ var pkg2 = (() => {
3854
4056
  return { name: "copair", version: "0.1.0" };
3855
4057
  })();
3856
4058
  var CACHE_DIR = resolve7(process.env["HOME"] ?? "~", ".copair");
3857
- var CACHE_FILE = join6(CACHE_DIR, "version-check.json");
4059
+ var CACHE_FILE = join5(CACHE_DIR, "version-check.json");
3858
4060
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
3859
4061
  async function fetchLatestVersion() {
3860
4062
  try {
@@ -3869,7 +4071,7 @@ async function fetchLatestVersion() {
3869
4071
  }
3870
4072
  }
3871
4073
  async function readCache() {
3872
- if (!existsSync9(CACHE_FILE)) return null;
4074
+ if (!existsSync8(CACHE_FILE)) return null;
3873
4075
  try {
3874
4076
  const raw = await readFile5(CACHE_FILE, "utf8");
3875
4077
  return JSON.parse(raw);
@@ -3924,6 +4126,7 @@ Update available: ${pkg2.version} \u2192 ${latest} (npm i -g ${pkg2.name})
3924
4126
  // src/core/approval-gate.ts
3925
4127
  import { resolve as resolvePath } from "path";
3926
4128
  import chalk5 from "chalk";
4129
+ var PERMISSION_SENSITIVE_FILES = ["config.yaml", "allow.yaml"];
3927
4130
  var RISK_TABLE = {
3928
4131
  // ── Read-only: never need approval ──────────────────────────────────────
3929
4132
  read: () => "safe",
@@ -3934,6 +4137,8 @@ var RISK_TABLE = {
3934
4137
  edit: () => "needs-approval",
3935
4138
  // ── Arbitrary shell: always needs approval ──────────────────────────────
3936
4139
  bash: () => "needs-approval",
4140
+ // ── Web search: always prompt even in auto-approve (network + token cost) ──
4141
+ web_search: () => "always-ask",
3937
4142
  // ── Git: split by subcommand ────────────────────────────────────────────
3938
4143
  git: (input) => {
3939
4144
  const args = (typeof input.args === "string" ? input.args : "").trim();
@@ -3992,7 +4197,12 @@ var ApprovalGate = class {
3992
4197
  if (typeof filePath !== "string") return false;
3993
4198
  const abs = resolvePath(filePath);
3994
4199
  for (const trusted of this.trustedPaths) {
3995
- if (abs === trusted || abs.startsWith(trusted + "/")) return true;
4200
+ if (abs === trusted || abs.startsWith(trusted + "/")) {
4201
+ if (PERMISSION_SENSITIVE_FILES.some((name) => abs.endsWith("/" + name))) {
4202
+ return false;
4203
+ }
4204
+ return true;
4205
+ }
3996
4206
  }
3997
4207
  return false;
3998
4208
  }
@@ -4010,16 +4220,18 @@ var ApprovalGate = class {
4010
4220
  async allow(toolName, input) {
4011
4221
  if (this.isTrustedPath(toolName, input)) return true;
4012
4222
  if (this.mode === "deny") return false;
4013
- if (this.classify(toolName, input) === "safe") return true;
4014
- if (this.mode === "auto-approve") return true;
4223
+ const risk = this.classify(toolName, input);
4224
+ if (risk === "safe") return true;
4225
+ if (this.mode === "auto-approve" && risk !== "always-ask") return true;
4015
4226
  if (this.allowList?.matches(toolName, input)) return true;
4016
4227
  const key = sessionKey(toolName, input);
4017
4228
  if (this.alwaysAllow.has(key)) return true;
4018
4229
  if (this.bridge?.approveAllForTurn) return true;
4230
+ const defaultAllow = risk === "always-ask";
4019
4231
  if (this.bridge) {
4020
4232
  return this.bridgePrompt(toolName, input, key);
4021
4233
  }
4022
- return this.legacyPrompt(toolName, input, key);
4234
+ return this.legacyPrompt(toolName, input, key, defaultAllow);
4023
4235
  }
4024
4236
  /** Bridge-based approval: emit event and await response from ink UI. */
4025
4237
  bridgePrompt(toolName, input, key) {
@@ -4058,12 +4270,18 @@ var ApprovalGate = class {
4058
4270
  });
4059
4271
  });
4060
4272
  }
4061
- /** Legacy approval prompt: direct stdin (kept for backward compatibility). */
4062
- async legacyPrompt(toolName, input, key) {
4273
+ /** Legacy approval prompt: direct stdin (kept for backward compatibility).
4274
+ *
4275
+ * @param defaultAllow When true (used for `always-ask` tools like web_search),
4276
+ * pressing Enter without typing confirms the action. For all other tools the
4277
+ * safe default is to deny on empty input.
4278
+ */
4279
+ async legacyPrompt(toolName, input, key, defaultAllow = false) {
4063
4280
  const summary = formatSummary(toolName, input);
4064
4281
  const boxWidth = Math.max(summary.length + 6, 56);
4065
4282
  const topBar = "\u2500".repeat(boxWidth);
4066
4283
  const pad = " ".repeat(Math.max(0, boxWidth - summary.length - 2));
4284
+ const allowLabel = defaultAllow ? chalk5.green("[y/\u23CE]") : chalk5.green("[y]");
4067
4285
  process.stdout.write("\n");
4068
4286
  process.stdout.write(chalk5.yellow(` \u250C\u2500 \u26A0 Approval required ${"\u2500".repeat(Math.max(0, boxWidth - 23))}\u2510
4069
4287
  `));
@@ -4072,7 +4290,7 @@ var ApprovalGate = class {
4072
4290
  process.stdout.write(chalk5.yellow(` \u2514${topBar}\u2518
4073
4291
  `));
4074
4292
  process.stdout.write(
4075
- ` ${chalk5.green("[y]")} allow ${chalk5.cyan("[a]")} always ${chalk5.red("[n]")} deny ${chalk5.yellow("\u203A")} `
4293
+ ` ${allowLabel} allow ${chalk5.cyan("[a]")} always ${chalk5.red("[n]")} deny ${chalk5.yellow("\u203A")} `
4076
4294
  );
4077
4295
  const answer = await ask();
4078
4296
  if (answer === null) {
@@ -4085,7 +4303,7 @@ var ApprovalGate = class {
4085
4303
  process.stdout.write(chalk5.green(" \u2713 Always allowed.\n\n"));
4086
4304
  return true;
4087
4305
  }
4088
- if (trimmed === "y" || trimmed === "yes") {
4306
+ if (trimmed === "y" || trimmed === "yes" || trimmed === "" && defaultAllow) {
4089
4307
  process.stdout.write(chalk5.green(" \u2713 Allowed.\n\n"));
4090
4308
  return true;
4091
4309
  }
@@ -4127,6 +4345,9 @@ function formatSummary(toolName, input) {
4127
4345
  case "edit":
4128
4346
  raw = `edit ${input.file_path}`;
4129
4347
  break;
4348
+ case "web_search":
4349
+ raw = `Copair web search "${input.query}"`;
4350
+ break;
4130
4351
  default:
4131
4352
  raw = `${toolName} ${JSON.stringify(input)}`;
4132
4353
  break;
@@ -4656,7 +4877,7 @@ function ApprovalHandler({ bridge }) {
4656
4877
  }
4657
4878
 
4658
4879
  // src/cli/ui/app.tsx
4659
- import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
4880
+ import { Fragment as Fragment2, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
4660
4881
  var DEFAULT_UI_CONFIG = {
4661
4882
  bordered_input: true,
4662
4883
  status_bar: true,
@@ -4689,6 +4910,139 @@ function useSpinner(active) {
4689
4910
  const elapsedStr = secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, "0")}s`;
4690
4911
  return { frame: SPINNER_FRAMES[frameIdx], elapsed: elapsedStr };
4691
4912
  }
4913
+ function renderInline(text) {
4914
+ const parts = [];
4915
+ let remaining = text;
4916
+ let key = 0;
4917
+ while (remaining.length > 0) {
4918
+ const boldMatch = remaining.match(/^\*\*(.+?)\*\*/);
4919
+ if (boldMatch) {
4920
+ parts.push(/* @__PURE__ */ jsx6(Text6, { bold: true, children: boldMatch[1] }, key++));
4921
+ remaining = remaining.slice(boldMatch[0].length);
4922
+ continue;
4923
+ }
4924
+ const italicMatch = remaining.match(/^\*(.+?)\*/);
4925
+ if (italicMatch) {
4926
+ parts.push(/* @__PURE__ */ jsx6(Text6, { italic: true, children: italicMatch[1] }, key++));
4927
+ remaining = remaining.slice(italicMatch[0].length);
4928
+ continue;
4929
+ }
4930
+ const codeMatch = remaining.match(/^`([^`]+)`/);
4931
+ if (codeMatch) {
4932
+ parts.push(/* @__PURE__ */ jsx6(Text6, { color: "cyan", bold: true, children: codeMatch[1] }, key++));
4933
+ remaining = remaining.slice(codeMatch[0].length);
4934
+ continue;
4935
+ }
4936
+ const nextSpecial = remaining.search(/[*`]/);
4937
+ if (nextSpecial === -1) {
4938
+ parts.push(remaining);
4939
+ break;
4940
+ }
4941
+ if (nextSpecial === 0) {
4942
+ parts.push(remaining[0]);
4943
+ remaining = remaining.slice(1);
4944
+ } else {
4945
+ parts.push(remaining.slice(0, nextSpecial));
4946
+ remaining = remaining.slice(nextSpecial);
4947
+ }
4948
+ }
4949
+ return parts.length === 1 ? parts[0] : /* @__PURE__ */ jsx6(Fragment2, { children: parts });
4950
+ }
4951
+ function renderMarkdownBlocks(text) {
4952
+ const lines = text.split("\n");
4953
+ const elements = [];
4954
+ let key = 0;
4955
+ let i = 0;
4956
+ while (i < lines.length) {
4957
+ const line = lines[i];
4958
+ const trimmed = line.trim();
4959
+ if (trimmed.startsWith("```")) {
4960
+ const lang = trimmed.slice(3).trim();
4961
+ const codeLines = [];
4962
+ i++;
4963
+ while (i < lines.length && !lines[i].trim().startsWith("```")) {
4964
+ codeLines.push(lines[i]);
4965
+ i++;
4966
+ }
4967
+ if (i < lines.length) i++;
4968
+ elements.push(
4969
+ /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", marginY: 1, children: [
4970
+ lang && /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: lang }),
4971
+ /* @__PURE__ */ jsx6(Box6, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "column", children: codeLines.map((cl, ci) => /* @__PURE__ */ jsx6(Text6, { color: "white", children: cl }, ci)) })
4972
+ ] }, key++)
4973
+ );
4974
+ continue;
4975
+ }
4976
+ const headerMatch = trimmed.match(/^(#{1,6})\s+(.+)/);
4977
+ if (headerMatch) {
4978
+ const level = headerMatch[1].length;
4979
+ const content = headerMatch[2];
4980
+ elements.push(
4981
+ /* @__PURE__ */ jsxs6(Text6, { bold: true, color: level <= 2 ? "white" : void 0, children: [
4982
+ level <= 2 ? "\n" : "",
4983
+ content
4984
+ ] }, key++)
4985
+ );
4986
+ i++;
4987
+ continue;
4988
+ }
4989
+ if (/^[-*_]{3,}$/.test(trimmed)) {
4990
+ elements.push(
4991
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "\u2500".repeat(40) }, key++)
4992
+ );
4993
+ i++;
4994
+ continue;
4995
+ }
4996
+ const ulMatch = trimmed.match(/^[-*+]\s+(.*)/);
4997
+ if (ulMatch) {
4998
+ elements.push(
4999
+ /* @__PURE__ */ jsxs6(Text6, { wrap: "wrap", children: [
5000
+ " ",
5001
+ "\u2022",
5002
+ " ",
5003
+ renderInline(ulMatch[1])
5004
+ ] }, key++)
5005
+ );
5006
+ i++;
5007
+ continue;
5008
+ }
5009
+ const olMatch = trimmed.match(/^(\d+)[.)]\s+(.*)/);
5010
+ if (olMatch) {
5011
+ elements.push(
5012
+ /* @__PURE__ */ jsxs6(Text6, { wrap: "wrap", children: [
5013
+ " ",
5014
+ olMatch[1],
5015
+ ". ",
5016
+ renderInline(olMatch[2])
5017
+ ] }, key++)
5018
+ );
5019
+ i++;
5020
+ continue;
5021
+ }
5022
+ if (trimmed.startsWith(">")) {
5023
+ const content = trimmed.replace(/^>\s?/, "");
5024
+ elements.push(
5025
+ /* @__PURE__ */ jsxs6(Text6, { dimColor: true, wrap: "wrap", children: [
5026
+ " ",
5027
+ "\u2502",
5028
+ " ",
5029
+ renderInline(content)
5030
+ ] }, key++)
5031
+ );
5032
+ i++;
5033
+ continue;
5034
+ }
5035
+ if (trimmed === "") {
5036
+ i++;
5037
+ continue;
5038
+ }
5039
+ elements.push(
5040
+ /* @__PURE__ */ jsx6(Text6, { wrap: "wrap", children: renderInline(line) }, key++)
5041
+ );
5042
+ i++;
5043
+ }
5044
+ return elements;
5045
+ }
4692
5046
  var CopairApp = forwardRef(function CopairApp2({
4693
5047
  bridge,
4694
5048
  model,
@@ -4786,6 +5140,12 @@ var CopairApp = forwardRef(function CopairApp2({
4786
5140
  { id: nextId.current++, type: "error", content: `\u2717 ${tool.label} denied` }
4787
5141
  ]);
4788
5142
  };
5143
+ const onDiff = (diff) => {
5144
+ setStaticItems((items) => [
5145
+ ...items,
5146
+ { id: nextId.current++, type: "diff", content: "", diff }
5147
+ ]);
5148
+ };
4789
5149
  const onError = (message) => {
4790
5150
  setStaticItems((items) => [
4791
5151
  ...items,
@@ -4816,6 +5176,7 @@ var CopairApp = forwardRef(function CopairApp2({
4816
5176
  bridge.on("tool-start", onToolStart);
4817
5177
  bridge.on("tool-complete", onToolComplete);
4818
5178
  bridge.on("tool-denied", onToolDenied);
5179
+ bridge.on("diff", onDiff);
4819
5180
  bridge.on("error", onError);
4820
5181
  bridge.on("usage", onUsage);
4821
5182
  bridge.on("turn-complete", onTurnComplete);
@@ -4825,6 +5186,7 @@ var CopairApp = forwardRef(function CopairApp2({
4825
5186
  bridge.off("tool-start", onToolStart);
4826
5187
  bridge.off("tool-complete", onToolComplete);
4827
5188
  bridge.off("tool-denied", onToolDenied);
5189
+ bridge.off("diff", onDiff);
4828
5190
  bridge.off("error", onError);
4829
5191
  bridge.off("usage", onUsage);
4830
5192
  bridge.off("turn-complete", onTurnComplete);
@@ -4860,9 +5222,11 @@ var CopairApp = forwardRef(function CopairApp2({
4860
5222
  " ",
4861
5223
  item.content
4862
5224
  ] }, item.id);
5225
+ case "diff":
5226
+ return item.diff ? /* @__PURE__ */ jsx6(DiffView, { diff: item.diff }, item.id) : null;
4863
5227
  case "text":
4864
5228
  default:
4865
- return /* @__PURE__ */ jsx6(Text6, { wrap: "wrap", children: item.content }, item.id);
5229
+ return /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", children: renderMarkdownBlocks(item.content) }, item.id);
4866
5230
  }
4867
5231
  } }),
4868
5232
  state.phase === "thinking" && /* @__PURE__ */ jsxs6(Text6, { children: [
@@ -4874,7 +5238,7 @@ var CopairApp = forwardRef(function CopairApp2({
4874
5238
  /* @__PURE__ */ jsx6(Text6, { color: "gray", children: spinner.elapsed })
4875
5239
  ] })
4876
5240
  ] }),
4877
- liveText && /* @__PURE__ */ jsx6(Text6, { wrap: "wrap", children: liveText }),
5241
+ liveText && /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", children: renderMarkdownBlocks(liveText) }),
4878
5242
  liveTool && /* @__PURE__ */ jsxs6(Text6, { color: "green", children: [
4879
5243
  " ",
4880
5244
  "\u25CF",
@@ -4967,7 +5331,7 @@ var ToolExecutor = class {
4967
5331
  };
4968
5332
 
4969
5333
  // src/core/allow-list.ts
4970
- import { readFileSync as readFileSync6, existsSync as existsSync10 } from "fs";
5334
+ import { readFileSync as readFileSync5, existsSync as existsSync9 } from "fs";
4971
5335
  import { resolve as resolve8 } from "path";
4972
5336
  import { homedir as homedir2 } from "os";
4973
5337
  import { parse as parseYaml3 } from "yaml";
@@ -5036,9 +5400,9 @@ function loadAllowList(projectDir) {
5036
5400
  });
5037
5401
  }
5038
5402
  function readAllowFile(filePath) {
5039
- if (!existsSync10(filePath)) return {};
5403
+ if (!existsSync9(filePath)) return {};
5040
5404
  try {
5041
- const raw = parseYaml3(readFileSync6(filePath, "utf-8"));
5405
+ const raw = parseYaml3(readFileSync5(filePath, "utf-8"));
5042
5406
  return {
5043
5407
  bash: toStringArray(raw.bash),
5044
5408
  git: toStringArray(raw.git),
@@ -5084,7 +5448,7 @@ import chalk6 from "chalk";
5084
5448
  // package.json
5085
5449
  var package_default = {
5086
5450
  name: "@dugleelabs/copair",
5087
- version: "1.0.1",
5451
+ version: "1.1.0",
5088
5452
  description: "Model-agnostic AI coding agent for the terminal",
5089
5453
  type: "module",
5090
5454
  main: "dist/index.js",
@@ -5241,20 +5605,20 @@ var DEFAULT_PRICING = /* @__PURE__ */ new Map([
5241
5605
  ]);
5242
5606
 
5243
5607
  // src/cli/ui/input-history.ts
5244
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, existsSync as existsSync11 } from "fs";
5245
- import { join as join7, dirname as dirname4 } from "path";
5608
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, existsSync as existsSync10 } from "fs";
5609
+ import { join as join6, dirname as dirname4 } from "path";
5246
5610
  import { homedir as homedir3 } from "os";
5247
5611
  var MAX_HISTORY = 500;
5248
5612
  function resolveHistoryPath(cwd) {
5249
- const projectPath = join7(cwd, ".copair", "history");
5250
- if (existsSync11(join7(cwd, ".copair"))) {
5613
+ const projectPath = join6(cwd, ".copair", "history");
5614
+ if (existsSync10(join6(cwd, ".copair"))) {
5251
5615
  return projectPath;
5252
5616
  }
5253
- return join7(homedir3(), ".copair", "history");
5617
+ return join6(homedir3(), ".copair", "history");
5254
5618
  }
5255
5619
  function loadHistory(historyPath) {
5256
5620
  try {
5257
- const content = readFileSync7(historyPath, "utf-8");
5621
+ const content = readFileSync6(historyPath, "utf-8");
5258
5622
  return content.split("\n").filter(Boolean);
5259
5623
  } catch {
5260
5624
  return [];
@@ -5263,10 +5627,10 @@ function loadHistory(historyPath) {
5263
5627
  function saveHistory(historyPath, entries) {
5264
5628
  const trimmed = entries.slice(-MAX_HISTORY);
5265
5629
  const dir = dirname4(historyPath);
5266
- if (!existsSync11(dir)) {
5267
- mkdirSync4(dir, { recursive: true });
5630
+ if (!existsSync10(dir)) {
5631
+ mkdirSync3(dir, { recursive: true });
5268
5632
  }
5269
- writeFileSync4(historyPath, trimmed.join("\n") + "\n", "utf-8");
5633
+ writeFileSync3(historyPath, trimmed.join("\n") + "\n", "utf-8");
5270
5634
  }
5271
5635
  function appendHistory(historyPath, entry) {
5272
5636
  const entries = loadHistory(historyPath);
@@ -5278,7 +5642,7 @@ function appendHistory(historyPath, entry) {
5278
5642
 
5279
5643
  // src/cli/ui/completion-providers.ts
5280
5644
  import { readdirSync } from "fs";
5281
- import { join as join8, dirname as dirname5, basename } from "path";
5645
+ import { join as join7, dirname as dirname5, basename } from "path";
5282
5646
  var SlashCommandProvider = class {
5283
5647
  id = "slash-commands";
5284
5648
  commands;
@@ -5316,7 +5680,7 @@ var FilePathProvider = class {
5316
5680
  complete(input) {
5317
5681
  const lastToken = input.split(/\s+/).pop() ?? "";
5318
5682
  try {
5319
- const dir = lastToken.endsWith("/") ? join8(this.cwd, lastToken) : join8(this.cwd, dirname5(lastToken));
5683
+ const dir = lastToken.endsWith("/") ? join7(this.cwd, lastToken) : join7(this.cwd, dirname5(lastToken));
5320
5684
  const prefix = lastToken.endsWith("/") ? "" : basename(lastToken);
5321
5685
  const beforeToken = input.slice(0, input.length - lastToken.length);
5322
5686
  const entries = readdirSync(dir, { withFileTypes: true });
@@ -5368,6 +5732,447 @@ var CompletionEngine = class {
5368
5732
  }
5369
5733
  };
5370
5734
 
5735
+ // src/init/GlobalInitManager.ts
5736
+ import { existsSync as existsSync11, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
5737
+ import { join as join8 } from "path";
5738
+ import { homedir as homedir4 } from "os";
5739
+ import * as readline from "readline";
5740
+ var GLOBAL_CONFIG_TEMPLATE = `# Copair global configuration
5741
+ # Generated by Copair on first run \u2014 edit as needed
5742
+
5743
+ # provider:
5744
+ # name: anthropic # anthropic | openai | google | ollama
5745
+ # model: claude-sonnet-4-6
5746
+ # api_key: your-api-key-here
5747
+ # endpoint: ~ # optional override (e.g. for local Ollama)
5748
+
5749
+ # identity:
5750
+ # name: ~ # used in git co-author trailers
5751
+ # email: ~
5752
+
5753
+ # ui:
5754
+ # status_bar: true
5755
+ # syntax_highlight: true
5756
+ # vi_mode: false
5757
+ # bordered_input: true
5758
+
5759
+ # permissions:
5760
+ # mode: ask # ask | auto | deny
5761
+
5762
+ # context:
5763
+ # summarization_model: ~ # model used for session summarisation
5764
+ # max_sessions: 50
5765
+ `;
5766
+ function prompt(question) {
5767
+ const rl = readline.createInterface({
5768
+ input: process.stdin,
5769
+ output: process.stdout
5770
+ });
5771
+ return new Promise((resolve9) => {
5772
+ rl.question(question, (answer) => {
5773
+ rl.close();
5774
+ resolve9(answer.trim().toLowerCase());
5775
+ });
5776
+ });
5777
+ }
5778
+ var GlobalInitManager = class {
5779
+ globalDir;
5780
+ constructor(homeDir) {
5781
+ this.globalDir = join8(homeDir ?? homedir4(), ".copair");
5782
+ }
5783
+ async check(options = { ci: false }) {
5784
+ if (existsSync11(this.globalDir)) {
5785
+ return { skipped: true, declined: false, created: false };
5786
+ }
5787
+ if (options.ci) {
5788
+ return { skipped: false, declined: true, created: false };
5789
+ }
5790
+ const answer = await prompt("Set up global Copair config at ~/.copair/? (Y/n) ");
5791
+ const declined = answer === "n" || answer === "no";
5792
+ if (declined) {
5793
+ return { skipped: false, declined: true, created: false };
5794
+ }
5795
+ await this.scaffold();
5796
+ return { skipped: false, declined: false, created: true };
5797
+ }
5798
+ async scaffold() {
5799
+ mkdirSync4(this.globalDir, { recursive: true });
5800
+ const configPath = join8(this.globalDir, "config.yaml");
5801
+ if (!existsSync11(configPath)) {
5802
+ writeFileSync4(configPath, GLOBAL_CONFIG_TEMPLATE, { mode: 384 });
5803
+ }
5804
+ }
5805
+ };
5806
+
5807
+ // src/init/ProjectInitManager.ts
5808
+ import { existsSync as existsSync12, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
5809
+ import { join as join9 } from "path";
5810
+ import * as readline2 from "readline";
5811
+ var PROJECT_CONFIG_TEMPLATE = `# Copair project configuration
5812
+ # Overrides ~/.copair/config.yaml for this project
5813
+ # This file is gitignored \u2014 do not commit
5814
+
5815
+ # provider:
5816
+ # model: ~ # override model for this project
5817
+
5818
+ # permissions:
5819
+ # mode: ask
5820
+ `;
5821
+ function prompt2(question) {
5822
+ const rl = readline2.createInterface({
5823
+ input: process.stdin,
5824
+ output: process.stdout
5825
+ });
5826
+ return new Promise((resolve9) => {
5827
+ rl.question(question, (answer) => {
5828
+ rl.close();
5829
+ resolve9(answer.trim().toLowerCase());
5830
+ });
5831
+ });
5832
+ }
5833
+ var ProjectInitManager = class {
5834
+ async check(cwd, options) {
5835
+ const copairDir = join9(cwd, ".copair");
5836
+ if (existsSync12(copairDir)) {
5837
+ return { alreadyInitialised: true, declined: false, created: false };
5838
+ }
5839
+ if (options.ci) {
5840
+ process.stderr.write(
5841
+ "Copair: .copair/ not found. In CI mode, automatic init is skipped.\nRun copair interactively once to initialise this project.\n"
5842
+ );
5843
+ return { alreadyInitialised: false, declined: true, created: false };
5844
+ }
5845
+ const answer = await prompt2("Trust this folder and allow Copair to run here? (y/N) ");
5846
+ const accepted = answer === "y" || answer === "yes";
5847
+ if (!accepted) {
5848
+ return { alreadyInitialised: false, declined: true, created: false };
5849
+ }
5850
+ await this.scaffold(cwd);
5851
+ return { alreadyInitialised: false, declined: false, created: true };
5852
+ }
5853
+ async scaffold(cwd) {
5854
+ const copairDir = join9(cwd, ".copair");
5855
+ mkdirSync5(join9(copairDir, "commands"), { recursive: true });
5856
+ const configPath = join9(copairDir, "config.yaml");
5857
+ if (!existsSync12(configPath)) {
5858
+ writeFileSync5(configPath, PROJECT_CONFIG_TEMPLATE, { mode: 420 });
5859
+ }
5860
+ }
5861
+ };
5862
+ var DECLINED_MESSAGE = "Copair not initialised. Run copair again in a trusted folder.";
5863
+
5864
+ // src/init/GitignoreManager.ts
5865
+ import { existsSync as existsSync13, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
5866
+ import { join as join10 } from "path";
5867
+ import * as readline3 from "readline";
5868
+ var FULL_PATTERNS = [".copair/", ".copair"];
5869
+ function prompt3(question) {
5870
+ const rl = readline3.createInterface({
5871
+ input: process.stdin,
5872
+ output: process.stdout
5873
+ });
5874
+ return new Promise((resolve9) => {
5875
+ rl.question(question, (answer) => {
5876
+ rl.close();
5877
+ resolve9(answer.trim().toLowerCase());
5878
+ });
5879
+ });
5880
+ }
5881
+ var GitignoreManager = class {
5882
+ /**
5883
+ * Owns the full classify → prompt → consolidate flow.
5884
+ * Runs on every startup. Skips silently if already fully covered.
5885
+ * In CI mode applies consolidation silently without prompting.
5886
+ */
5887
+ async ensureCovered(cwd, options) {
5888
+ const coverage = await this.classify(cwd);
5889
+ if (coverage === "full") return;
5890
+ if (options.ci) {
5891
+ await this.consolidate(cwd);
5892
+ return;
5893
+ }
5894
+ const answer = await prompt3("Add .copair/ to .gitignore? (Y/n) ");
5895
+ const declined = answer === "n" || answer === "no";
5896
+ if (!declined) {
5897
+ await this.consolidate(cwd);
5898
+ }
5899
+ }
5900
+ async classify(cwd) {
5901
+ const gitignorePath = join10(cwd, ".gitignore");
5902
+ if (!existsSync13(gitignorePath)) return "none";
5903
+ const lines = readFileSync7(gitignorePath, "utf8").split(/\r?\n/).map((l) => l.trim());
5904
+ for (const line of lines) {
5905
+ if (FULL_PATTERNS.includes(line)) return "full";
5906
+ }
5907
+ const hasPartial = lines.some(
5908
+ (l) => l.startsWith(".copair/") && !FULL_PATTERNS.includes(l)
5909
+ );
5910
+ return hasPartial ? "partial" : "none";
5911
+ }
5912
+ async consolidate(cwd) {
5913
+ const gitignorePath = join10(cwd, ".gitignore");
5914
+ let lines = [];
5915
+ if (existsSync13(gitignorePath)) {
5916
+ lines = readFileSync7(gitignorePath, "utf8").split(/\r?\n/);
5917
+ }
5918
+ const filtered = lines.filter((l) => {
5919
+ const trimmed = l.trim();
5920
+ return !trimmed.startsWith(".copair/") || FULL_PATTERNS.includes(trimmed);
5921
+ });
5922
+ while (filtered.length > 0 && filtered[filtered.length - 1].trim() === "") {
5923
+ filtered.pop();
5924
+ }
5925
+ filtered.push("", "# Copair runtime state", ".copair/", "");
5926
+ writeFileSync6(gitignorePath, filtered.join("\n"), { encoding: "utf8" });
5927
+ }
5928
+ };
5929
+
5930
+ // src/knowledge/KnowledgeManager.ts
5931
+ import { existsSync as existsSync14, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
5932
+ import { join as join11 } from "path";
5933
+ import * as readline4 from "readline";
5934
+ var KB_FILENAME2 = "COPAIR_KNOWLEDGE.md";
5935
+ var DEFAULT_CONFIG = {
5936
+ warn_size_kb: 8,
5937
+ max_size_kb: 16
5938
+ };
5939
+ var TRIGGER_PATTERNS = [
5940
+ /^[^/]+\/$/,
5941
+ // new top-level directory
5942
+ /(?:^|\/)(?:index|main|app|server|bin\/)\.[jt]sx?$/,
5943
+ // entry points
5944
+ /(?:^|\/)(?:package\.json|tsconfig.*\.json|\.env\.example|Dockerfile|docker-compose\.ya?ml)$/
5945
+ // config files
5946
+ ];
5947
+ var SKIP_PATTERNS = [
5948
+ /(?:^|\/)tests?\//,
5949
+ // test files
5950
+ /\.test\.[jt]sx?$/,
5951
+ /\.spec\.[jt]sx?$/
5952
+ ];
5953
+ function promptUser(question) {
5954
+ const rl = readline4.createInterface({
5955
+ input: process.stdin,
5956
+ output: process.stdout
5957
+ });
5958
+ return new Promise((resolve9) => {
5959
+ rl.question(question, (answer) => {
5960
+ rl.close();
5961
+ resolve9(answer.trim().toLowerCase());
5962
+ });
5963
+ });
5964
+ }
5965
+ var KnowledgeManager = class {
5966
+ config;
5967
+ constructor(config = {}) {
5968
+ this.config = { ...DEFAULT_CONFIG, ...config };
5969
+ }
5970
+ load(cwd) {
5971
+ const filePath = join11(cwd, KB_FILENAME2);
5972
+ if (!existsSync14(filePath)) {
5973
+ return { found: false, content: null, sizeBytes: 0 };
5974
+ }
5975
+ try {
5976
+ const content = readFileSync8(filePath, "utf8");
5977
+ const sizeBytes = Buffer.byteLength(content, "utf8");
5978
+ return { found: true, content, sizeBytes };
5979
+ } catch {
5980
+ return { found: false, content: null, sizeBytes: 0 };
5981
+ }
5982
+ }
5983
+ injectIntoSystemPrompt(content) {
5984
+ return `<knowledge>
5985
+ ${content.trim()}
5986
+ </knowledge>
5987
+
5988
+ `;
5989
+ }
5990
+ checkSizeBudget(sizeBytes) {
5991
+ const warnBytes = this.config.warn_size_kb * 1024;
5992
+ const maxBytes = this.config.max_size_kb * 1024;
5993
+ if (sizeBytes > maxBytes) {
5994
+ throw new Error(
5995
+ `COPAIR_KNOWLEDGE.md exceeds the ${this.config.max_size_kb} KB hard cap (${Math.round(sizeBytes / 1024)} KB). Reduce the file size before starting a session.`
5996
+ );
5997
+ }
5998
+ if (sizeBytes > warnBytes) {
5999
+ process.stderr.write(
6000
+ `[knowledge] Warning: COPAIR_KNOWLEDGE.md is ${Math.round(sizeBytes / 1024)} KB (recommended max: ${this.config.warn_size_kb} KB). Consider trimming it to keep prompts efficient.
6001
+ `
6002
+ );
6003
+ }
6004
+ }
6005
+ /**
6006
+ * Evaluate whether the knowledge file needs updating after a task.
6007
+ * Returns a proposed update description if an update is warranted, null otherwise.
6008
+ */
6009
+ evaluateForUpdate(filesChanged, _diff) {
6010
+ if (filesChanged.length === 0) return null;
6011
+ const nonTestFiles = filesChanged.filter(
6012
+ (f) => !SKIP_PATTERNS.some((p) => p.test(f))
6013
+ );
6014
+ if (nonTestFiles.length === 0) return null;
6015
+ const triggers = nonTestFiles.filter(
6016
+ (f) => TRIGGER_PATTERNS.some((p) => p.test(f))
6017
+ );
6018
+ if (triggers.length === 0) return null;
6019
+ return `The following changes may affect the knowledge file:
6020
+ ` + triggers.map((f) => ` - ${f}`).join("\n") + "\nConsider updating COPAIR_KNOWLEDGE.md to reflect these changes.";
6021
+ }
6022
+ async proposeUpdate(cwd, proposedDiff) {
6023
+ process.stdout.write(
6024
+ "\n[knowledge] Proposed update to COPAIR_KNOWLEDGE.md:\n\n" + proposedDiff + "\n"
6025
+ );
6026
+ const answer = await promptUser("Apply this update to COPAIR_KNOWLEDGE.md? (Y/n) ");
6027
+ const declined = answer === "n" || answer === "no";
6028
+ if (declined) return false;
6029
+ await this.applyUpdate(cwd, proposedDiff);
6030
+ return true;
6031
+ }
6032
+ applyUpdate(cwd, content) {
6033
+ const filePath = join11(cwd, KB_FILENAME2);
6034
+ const sizeBytes = Buffer.byteLength(content, "utf8");
6035
+ const maxBytes = this.config.max_size_kb * 1024;
6036
+ if (sizeBytes > maxBytes) {
6037
+ throw new Error(
6038
+ `Cannot apply update: result would be ${Math.round(sizeBytes / 1024)} KB, exceeding the ${this.config.max_size_kb} KB cap.`
6039
+ );
6040
+ }
6041
+ writeFileSync7(filePath, content, { encoding: "utf8", mode: 420 });
6042
+ }
6043
+ };
6044
+
6045
+ // src/knowledge/KnowledgeSetupFlow.ts
6046
+ import { writeFileSync as writeFileSync8 } from "fs";
6047
+ import { join as join12 } from "path";
6048
+ import * as readline5 from "readline";
6049
+ var SECTIONS = [
6050
+ {
6051
+ key: "directory-map",
6052
+ heading: "## Directory Map",
6053
+ question: 'What are the key directories in this project and what does each own?\n(e.g. "src/ \u2014 all TypeScript source", "bin/ \u2014 CLI entry point")',
6054
+ skippable: false
6055
+ },
6056
+ {
6057
+ key: "tech-stack",
6058
+ heading: "## Tech Stack",
6059
+ question: 'What language, runtime, and key frameworks are in use?\n(e.g. "TypeScript / Node.js 20+, pnpm, vitest")',
6060
+ skippable: false
6061
+ },
6062
+ {
6063
+ key: "naming-conventions",
6064
+ heading: "## Naming Conventions",
6065
+ question: 'Any naming conventions for files, components, variables, or API routes?\n(Type "skip" to omit this section)',
6066
+ skippable: true
6067
+ },
6068
+ {
6069
+ key: "entry-points",
6070
+ heading: "## Entry Points",
6071
+ question: 'What are the key entry points \u2014 main file, config files, bootstrap?\n(e.g. "bin/copair.ts \u2014 CLI entry", "src/session/SessionBootstrap.ts \u2014 startup")',
6072
+ skippable: false
6073
+ },
6074
+ {
6075
+ key: "off-limits",
6076
+ heading: "## Off-Limits",
6077
+ question: 'Any files or directories Copair must not touch without explicit instruction?\n(Type "skip" to omit this section)',
6078
+ skippable: true
6079
+ }
6080
+ ];
6081
+ function createRl() {
6082
+ return readline5.createInterface({
6083
+ input: process.stdin,
6084
+ output: process.stdout
6085
+ });
6086
+ }
6087
+ async function ask2(question) {
6088
+ const rl = createRl();
6089
+ return new Promise((resolve9) => {
6090
+ rl.question(question + "\n> ", (answer) => {
6091
+ rl.close();
6092
+ resolve9(answer.trim());
6093
+ });
6094
+ });
6095
+ }
6096
+ async function confirm(question) {
6097
+ const rl = createRl();
6098
+ return new Promise((resolve9) => {
6099
+ rl.question(question, (answer) => {
6100
+ rl.close();
6101
+ const lower = answer.trim().toLowerCase();
6102
+ resolve9(lower !== "n" && lower !== "no");
6103
+ });
6104
+ });
6105
+ }
6106
+ var KnowledgeSetupFlow = class {
6107
+ /**
6108
+ * Prompts the user to set up a COPAIR_KNOWLEDGE.md.
6109
+ * Returns true if a file was written, false if the user declined.
6110
+ */
6111
+ async run(cwd) {
6112
+ const shouldSetup = await confirm(
6113
+ "No knowledge file found. Set one up now? (Y/n) "
6114
+ );
6115
+ if (!shouldSetup) return false;
6116
+ process.stdout.write(
6117
+ "\nLet's build your COPAIR_KNOWLEDGE.md \u2014 a navigation map for Copair.\nAnswer each section (press Enter to confirm).\n\n"
6118
+ );
6119
+ const sections = [];
6120
+ for (const section of SECTIONS) {
6121
+ process.stdout.write(`--- ${section.heading.replace("## ", "")} ---
6122
+ `);
6123
+ const answer = await ask2(section.question);
6124
+ if (section.skippable && answer.toLowerCase() === "skip") {
6125
+ process.stdout.write("Skipped.\n\n");
6126
+ continue;
6127
+ }
6128
+ if (!answer.trim()) {
6129
+ process.stdout.write("Skipped (empty).\n\n");
6130
+ continue;
6131
+ }
6132
+ sections.push({ heading: section.heading, content: answer });
6133
+ process.stdout.write("\n");
6134
+ }
6135
+ if (sections.length === 0) {
6136
+ process.stdout.write("No sections provided \u2014 skipping knowledge file creation.\n");
6137
+ return false;
6138
+ }
6139
+ const lines = ["# Copair Knowledge Base", ""];
6140
+ for (const { heading, content } of sections) {
6141
+ lines.push(heading);
6142
+ const contentLines = content.split("\n").map((l) => l.trim()).filter(Boolean);
6143
+ for (const line of contentLines) {
6144
+ lines.push(line.startsWith("-") ? line : `- ${line}`);
6145
+ }
6146
+ lines.push("");
6147
+ }
6148
+ const fileContent = lines.join("\n");
6149
+ process.stdout.write("\n--- Draft COPAIR_KNOWLEDGE.md ---\n\n");
6150
+ process.stdout.write(fileContent);
6151
+ process.stdout.write("\n--- End of draft ---\n\n");
6152
+ const write = await confirm("Write COPAIR_KNOWLEDGE.md? (Y/n) ");
6153
+ if (!write) {
6154
+ process.stdout.write("Skipped \u2014 will prompt again next session start.\n");
6155
+ return false;
6156
+ }
6157
+ writeFileSync8(join12(cwd, KB_FILENAME2), fileContent, {
6158
+ encoding: "utf8",
6159
+ mode: 420
6160
+ });
6161
+ process.stdout.write(
6162
+ `
6163
+ Wrote ${KB_FILENAME2}. Commit it to version control like README.md.
6164
+
6165
+ `
6166
+ );
6167
+ return true;
6168
+ }
6169
+ };
6170
+
6171
+ // src/utils/environmentUtils.ts
6172
+ function isCI() {
6173
+ return !process.stdin.isTTY || !!process.env["CI"] || process.env["COPAIR_CI"] === "1";
6174
+ }
6175
+
5371
6176
  // src/index.ts
5372
6177
  function resolveModel(config, modelOverride) {
5373
6178
  const modelAlias = modelOverride ?? config.default_model;
@@ -5421,6 +6226,18 @@ Continue from where we left off.`
5421
6226
  async function main() {
5422
6227
  const cliOpts = parseArgs();
5423
6228
  checkForUpdates();
6229
+ const ci = isCI();
6230
+ const cwd = process.cwd();
6231
+ const globalInitManager = new GlobalInitManager();
6232
+ await globalInitManager.check({ ci });
6233
+ const projectInitManager = new ProjectInitManager();
6234
+ const projectInit = await projectInitManager.check(cwd, { ci });
6235
+ if (projectInit.declined) {
6236
+ console.log(DECLINED_MESSAGE);
6237
+ process.exit(0);
6238
+ }
6239
+ const gitignoreManager = new GitignoreManager();
6240
+ await gitignoreManager.ensureCovered(cwd, { ci });
5424
6241
  const config = loadConfig();
5425
6242
  const { providerName, modelAlias, providerConfig } = resolveModel(
5426
6243
  config,
@@ -5453,50 +6270,45 @@ async function main() {
5453
6270
  }
5454
6271
  });
5455
6272
  }
5456
- const firstInit = ensureProjectInit(process.cwd());
5457
- if (firstInit) {
5458
- console.log("Initialized .copair/ for this project. Config: .copair.yaml");
6273
+ gate.addTrustedPath(join13(cwd, ".copair"));
6274
+ const gitCtx = detectGitContext(cwd);
6275
+ const knowledgeManager = new KnowledgeManager({
6276
+ warn_size_kb: config.knowledge.warn_size_kb,
6277
+ max_size_kb: config.knowledge.max_size_kb
6278
+ });
6279
+ const knowledgeResult = knowledgeManager.load(cwd);
6280
+ let knowledgePrefix = "";
6281
+ if (knowledgeResult.found && knowledgeResult.content) {
6282
+ knowledgeManager.checkSizeBudget(knowledgeResult.sizeBytes);
6283
+ knowledgePrefix = knowledgeManager.injectIntoSystemPrompt(knowledgeResult.content);
6284
+ } else if (!ci) {
6285
+ const setupFlow = new KnowledgeSetupFlow();
6286
+ const written = await setupFlow.run(cwd);
6287
+ if (written) {
6288
+ const refreshed = knowledgeManager.load(cwd);
6289
+ if (refreshed.found && refreshed.content) {
6290
+ knowledgeManager.checkSizeBudget(refreshed.sizeBytes);
6291
+ knowledgePrefix = knowledgeManager.injectIntoSystemPrompt(refreshed.content);
6292
+ }
6293
+ }
5459
6294
  }
5460
- gate.addTrustedPath(join9(process.cwd(), ".copair"));
5461
- gate.addTrustedPath(join9(process.cwd(), ".copair.yaml"));
5462
- const gitCtx = detectGitContext(process.cwd());
5463
- const knowledgeBase = new KnowledgeBase(process.cwd(), config.context.knowledge_max_size);
6295
+ const knowledgeBase = new KnowledgeBase(cwd, config.context.knowledge_max_size);
5464
6296
  setKnowledgeBase(knowledgeBase);
5465
- const kbSection = knowledgeBase.getSystemPromptSection();
5466
6297
  const agent = new Agent(provider, modelAlias, toolRegistry, executor, {
5467
6298
  bridge: agentBridge,
5468
6299
  systemPrompt: `You are Copair, an AI coding assistant.
5469
6300
 
5470
6301
  Environment:
5471
- - Working directory: ${process.cwd()}
5472
- - All file paths MUST be absolute (start with ${process.cwd()}/)
5473
-
5474
- Context awareness:
5475
- - Your context includes this system prompt, the full conversation history (all prior messages in this session), and any project knowledge shown below.
5476
- - When asked about context, awareness, or what you know \u2014 answer from the conversation history and the knowledge section below. Do NOT read COPAIR_KNOWLEDGE.md or any other file to answer meta-questions about your own state.
5477
- - The knowledge base below (if present) was already loaded from COPAIR_KNOWLEDGE.md at startup. Use the update_knowledge tool only to ADD new entries, not to read existing ones.
5478
-
5479
- Rules:
5480
- - You MUST use tools to perform actions. NEVER describe or narrate actions \u2014 execute them.
5481
- - NEVER simulate, roleplay, or pretend to run commands. If you need to do something, call the tool.
5482
- - Be brief. No preamble, no filler. No summaries between steps.
5483
- - If a tool returns an error, adjust your approach \u2014 do NOT repeat the same call.
5484
-
5485
- Work habits:
5486
- - Read before editing. Keep changes minimal.
5487
- - Auto-commit each discrete feature, fix, or refactor. Do not batch unrelated changes.
5488
- - When you learn something project-specific (conventions, patterns, architectural decisions), use the update_knowledge tool to record it.
6302
+ - Working directory: ${cwd}
6303
+ - All file paths MUST be absolute (start with ${cwd}/)
5489
6304
 
5490
- Git:
5491
- - Branches: <type>/<kebab-desc> (feat, fix, chore, docs, refactor, test, perf)
5492
- - Commits: <type>(<scope>): <imperative subject, max 72 chars>
5493
- Body: 2-3 concise bullets. Co-authored-by is auto-appended.
5494
- - NEVER use --no-verify, --force, or --no-gpg-sign.` + kbSection
6305
+ ` + // [2] Knowledge block — injected before file context
6306
+ knowledgePrefix + "Context awareness:\n- Your context includes this system prompt, the full conversation history (all prior messages in this session), and any project knowledge shown above in <knowledge> tags.\n- When asked about context, awareness, or what you know \u2014 answer from the conversation history and the knowledge section. Do NOT read COPAIR_KNOWLEDGE.md to answer meta-questions about your own state.\n- COPAIR_KNOWLEDGE.md is a navigation map, not a context dump. Never write ephemeral notes or session context into it. Propose targeted diffs only when structure, conventions, or entry points change.\n\nRules:\n- You MUST use tools to perform actions. NEVER describe or narrate actions \u2014 execute them.\n- NEVER simulate, roleplay, or pretend to run commands. If you need to do something, call the tool.\n- Be brief. No preamble, no filler. No summaries between steps.\n- If a tool returns an error, adjust your approach \u2014 do NOT repeat the same call.\n\nWork habits:\n- Read before editing. Keep changes minimal.\n- Auto-commit each discrete feature, fix, or refactor. Do not batch unrelated changes.\n\nGit:\n- Branches: <type>/<kebab-desc> (feat, fix, chore, docs, refactor, test, perf)\n- Commits: <type>(<scope>): <imperative subject, max 72 chars>\n Body: 2-3 concise bullets. Co-authored-by is auto-appended.\n- NEVER use --no-verify, --force, or --no-gpg-sign."
5495
6307
  });
5496
- const sessionManager = new SessionManager(process.cwd());
5497
- const sessionsDir = resolveSessionsDir(process.cwd());
5498
- warnIfSessionsTracked(process.cwd());
5499
- await SessionManager.migrateGlobalRecovery(sessionsDir, process.cwd());
6308
+ const sessionManager = new SessionManager(cwd);
6309
+ const sessionsDir = resolveSessionsDir(cwd);
6310
+ warnIfSessionsTracked(cwd);
6311
+ await SessionManager.migrateGlobalRecovery(sessionsDir, cwd);
5500
6312
  await SessionManager.cleanup(sessionsDir, config.context.max_sessions);
5501
6313
  let sessionResumed = false;
5502
6314
  const sessions = await SessionManager.listSessions(sessionsDir);
@@ -5531,14 +6343,14 @@ Git:
5531
6343
  let identifierDerived = sessionResumed;
5532
6344
  setSessionManagerRef(sessionManager);
5533
6345
  const agentContext = {
5534
- cwd: process.cwd(),
6346
+ cwd,
5535
6347
  model: modelAlias,
5536
6348
  branch: gitCtx.branch
5537
6349
  };
5538
6350
  const cmdRegistry = new CommandRegistry();
5539
6351
  const workflowCmd = createWorkflowCommand(
5540
- async (prompt) => {
5541
- await agent.handleMessage(prompt);
6352
+ async (prompt4) => {
6353
+ await agent.handleMessage(prompt4);
5542
6354
  },
5543
6355
  async (input) => {
5544
6356
  const result = await cmdRegistry.execute(input, { ...agentContext, model: agent.model });
@@ -5554,7 +6366,7 @@ Git:
5554
6366
  workflowCmd
5555
6367
  );
5556
6368
  const tokenTracker = new TokenTracker(DEFAULT_PRICING);
5557
- const historyPath = resolveHistoryPath(process.cwd());
6369
+ const historyPath = resolveHistoryPath(cwd);
5558
6370
  const inputHistory = loadHistory(historyPath);
5559
6371
  const completionEngine = new CompletionEngine();
5560
6372
  const cmdNames = /* @__PURE__ */ new Map();
@@ -5567,7 +6379,7 @@ Git:
5567
6379
  cmdNames.set("clear", "Clear conversation");
5568
6380
  cmdNames.set("model", "Switch model");
5569
6381
  completionEngine.addProvider(new SlashCommandProvider(cmdNames));
5570
- completionEngine.addProvider(new FilePathProvider(process.cwd()));
6382
+ completionEngine.addProvider(new FilePathProvider(cwd));
5571
6383
  printBanner(modelAlias);
5572
6384
  await new Promise((r) => setTimeout(r, 50));
5573
6385
  let appHandle = null;