@vectorize-io/self-driving-agents 0.0.23 → 0.0.26

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 CHANGED
@@ -50,6 +50,17 @@ function isLocalPath(input) {
50
50
  input.startsWith("/") ||
51
51
  input.startsWith("~"));
52
52
  }
53
+ /**
54
+ * Whether a string is a valid agent name. Used by --empty mode where the
55
+ * first positional arg becomes the agent name (no path, no GitHub fetch).
56
+ *
57
+ * Rules: starts with [a-z0-9], then lowercase alphanumerics or hyphens, max
58
+ * 64 chars. Matches the lowercase-with-hyphens convention the create-agent
59
+ * skill expects.
60
+ */
61
+ export function isValidAgentName(name) {
62
+ return /^[a-z0-9][a-z0-9-]*$/.test(name) && name.length <= 64;
63
+ }
53
64
  /**
54
65
  * Resolve the agent specifier to a local directory.
55
66
  *
@@ -649,7 +660,9 @@ curl -s -X POST ${apiUrl}/v1/default/banks/${bankId}/memories/retain \\
649
660
  execSync(`cd "${outDir}" && zip -j "${zipPath}" SKILL.md`, { stdio: "pipe" });
650
661
  return zipPath;
651
662
  }
652
- async function promptClaudeConfig(agentId, opts = { askBankId: true }) {
663
+ async function promptClaudeConfig(agentId, opts = {}) {
664
+ const askBankId = opts.askBankId ?? true;
665
+ const allowLocalhost = opts.allowLocalhost ?? false;
653
666
  const deploymentType = await p.select({
654
667
  message: "Hindsight deployment:",
655
668
  options: [
@@ -668,11 +681,14 @@ async function promptClaudeConfig(agentId, opts = { askBankId: true }) {
668
681
  else {
669
682
  const urlInput = await p.text({
670
683
  message: "Hindsight API URL:",
671
- placeholder: "https://your-hindsight.example.com",
684
+ placeholder: allowLocalhost
685
+ ? "http://localhost:9077 or https://your-hindsight.example.com"
686
+ : "https://your-hindsight.example.com",
672
687
  validate: (val) => {
673
688
  if (!val)
674
689
  return "URL is required";
675
- if (val.startsWith("http://localhost") || val.startsWith("http://127.0.0.1")) {
690
+ if (!allowLocalhost &&
691
+ (val.startsWith("http://localhost") || val.startsWith("http://127.0.0.1"))) {
676
692
  return "Claude cannot reach localhost. Use a publicly accessible URL.";
677
693
  }
678
694
  return undefined;
@@ -683,7 +699,9 @@ async function promptClaudeConfig(agentId, opts = { askBankId: true }) {
683
699
  process.exit(0);
684
700
  }
685
701
  apiUrl = urlInput;
686
- p.log.warn("Make sure your Hindsight instance is publicly accessible from Claude's servers.");
702
+ if (!allowLocalhost) {
703
+ p.log.warn("Make sure your Hindsight instance is publicly accessible from Claude's servers.");
704
+ }
687
705
  }
688
706
  const tokenInput = await p.password({ message: "Hindsight API token:" });
689
707
  if (p.isCancel(tokenInput)) {
@@ -692,7 +710,7 @@ async function promptClaudeConfig(agentId, opts = { askBankId: true }) {
692
710
  }
693
711
  const apiToken = tokenInput || undefined;
694
712
  let bankId = agentId;
695
- if (opts.askBankId) {
713
+ if (askBankId) {
696
714
  const bankInput = await p.text({
697
715
  message: "Bank ID:",
698
716
  initialValue: agentId,
@@ -713,16 +731,19 @@ async function main() {
713
731
  ${color.bold("self-driving-agents")} — install a self-driving agent
714
732
 
715
733
  ${color.dim("Usage:")}
716
- npx @vectorize-io/self-driving-agents install <agent> --harness <harness> [--agent <name>]
734
+ npx @vectorize-io/self-driving-agents install <agent> --harness <h> ${color.dim("# from path or GitHub")}
735
+ npx @vectorize-io/self-driving-agents install <name> --harness <h> --empty ${color.dim("# blank agent")}
717
736
 
718
- ${color.dim("Agent sources:")}
737
+ ${color.dim("Agent sources (without --empty):")}
719
738
  ${color.cyan("marketing-agent")} → ${DEFAULT_REPO}/marketing-agent
720
739
  ${color.cyan("org/repo/my-agent")} → org/repo/my-agent on GitHub
721
740
  ${color.cyan("./local-dir")} → local directory
722
741
 
723
742
  ${color.dim("Options:")}
724
743
  ${color.cyan("--harness <h>")} Required. openclaw | nemoclaw | hermes | claude | claude-code
725
- ${color.cyan("--agent <name>")} Agent name (defaults to directory name)
744
+ ${color.cyan("--empty")} Create a blank agent. First positional becomes the agent name;
745
+ no content fetched, no bank-template imported from disk.
746
+ ${color.cyan("--agent <name>")} Override the agent name (defaults to directory name)
726
747
  ${color.cyan("--sandbox <name>")} NemoClaw sandbox (auto-detected if only one exists)
727
748
  `);
728
749
  process.exit(0);
@@ -736,6 +757,7 @@ async function main() {
736
757
  let harness;
737
758
  let agentName;
738
759
  let sandbox;
760
+ let isEmpty = false;
739
761
  for (let i = 0; i < restArgs.length; i++) {
740
762
  if (restArgs[i] === "--harness" && restArgs[i + 1])
741
763
  harness = restArgs[++i];
@@ -743,6 +765,8 @@ async function main() {
743
765
  agentName = restArgs[++i];
744
766
  else if (restArgs[i] === "--sandbox" && restArgs[i + 1])
745
767
  sandbox = restArgs[++i];
768
+ else if (restArgs[i] === "--empty")
769
+ isEmpty = true;
746
770
  }
747
771
  if (!harness) {
748
772
  p.cancel("--harness required (openclaw | nemoclaw | hermes | claude | claude-code)");
@@ -752,9 +776,30 @@ async function main() {
752
776
  sandbox = await detectNemoClawSandbox();
753
777
  }
754
778
  p.intro(color.bgCyan(color.black(` self-driving-agents `)));
755
- // Step 0: Resolve agent directory (local or GitHub)
779
+ // Step 0: Resolve agent directory (local or GitHub) — skipped when --empty
780
+ // is set; in that case the first positional becomes the agent name and
781
+ // there's no source content to ingest.
756
782
  const spin = p.spinner();
757
- const { dir, source, defaultName, cleanup } = await resolveAgentDir(dirArg, spin);
783
+ let dir = "";
784
+ let source;
785
+ let defaultName;
786
+ let cleanup;
787
+ if (isEmpty) {
788
+ if (!dirArg || dirArg.startsWith("-")) {
789
+ p.cancel("--empty needs an agent name as the first positional argument.\n" +
790
+ " e.g. install my-agent --harness claude-code --empty");
791
+ process.exit(1);
792
+ }
793
+ if (!isValidAgentName(dirArg)) {
794
+ p.cancel(`Invalid agent name '${dirArg}'. Use lowercase letters, digits, and hyphens (max 64 chars), e.g. my-agent.`);
795
+ process.exit(1);
796
+ }
797
+ source = "<empty>";
798
+ defaultName = dirArg;
799
+ }
800
+ else {
801
+ ({ dir, source, defaultName, cleanup } = await resolveAgentDir(dirArg, spin));
802
+ }
758
803
  try {
759
804
  let agentId;
760
805
  if (agentName) {
@@ -954,9 +999,29 @@ async function main() {
954
999
  catch { /* ignore */ }
955
1000
  }
956
1001
  const hasConnection = ccConfig.hindsightApiUrl || ccConfig.llmProvider;
957
- if (!hasConnection && process.stdin.isTTY) {
1002
+ let reconfigure = !hasConnection;
1003
+ if (hasConnection && process.stdin.isTTY) {
1004
+ const summary = ccConfig.hindsightApiUrl === HINDSIGHT_CLOUD_API_URL
1005
+ ? "Cloud (api.hindsight.vectorize.io)"
1006
+ : ccConfig.hindsightApiUrl
1007
+ ? `External: ${ccConfig.hindsightApiUrl}`
1008
+ : `LLM provider: ${ccConfig.llmProvider}`;
1009
+ const ok = await p.confirm({
1010
+ message: `Hindsight: ${color.cyan(summary)}. Use this?\n${color.dim(" Changing this will affect all existing Claude Code agents — one Claude Code install shares a single Hindsight connection.")}`,
1011
+ });
1012
+ if (p.isCancel(ok)) {
1013
+ p.cancel("Cancelled.");
1014
+ process.exit(0);
1015
+ }
1016
+ if (!ok)
1017
+ reconfigure = true;
1018
+ }
1019
+ if (reconfigure && process.stdin.isTTY) {
958
1020
  // Bank ID is derived from agent + cwd at runtime — don't ask for it
959
- const claudeConfig = await promptClaudeConfig(agentId, { askBankId: false });
1021
+ const claudeConfig = await promptClaudeConfig(agentId, {
1022
+ askBankId: false,
1023
+ allowLocalhost: true,
1024
+ });
960
1025
  ccConfig.hindsightApiUrl = claudeConfig.apiUrl;
961
1026
  ccConfig.hindsightApiToken = claudeConfig.apiToken;
962
1027
  }
@@ -993,22 +1058,24 @@ async function main() {
993
1058
  mkdirSync(ccConfigDir, { recursive: true });
994
1059
  writeFileSync(ccConfigPath, JSON.stringify(ccConfig, null, 2) + "\n");
995
1060
  p.log.success(`Plugin config: ${color.dim(ccConfigPath)}`);
996
- // Step 4: Save content locally for the agent
997
- const contentDir = join(homedir(), ".self-driving-agents", "claude-code", agentId);
998
- mkdirSync(contentDir, { recursive: true });
999
- // Copy content files to the local dir
1000
- const contentFiles = findContentFiles(dir);
1001
- for (const relPath of contentFiles) {
1002
- const destPath = join(contentDir, relPath);
1003
- mkdirSync(join(destPath, ".."), { recursive: true });
1004
- writeFileSync(destPath, readFileSync(join(dir, relPath), "utf-8"));
1005
- }
1006
- // Copy bank-template.json if present (has mental model definitions)
1007
- const templateSrc = join(dir, "bank-template.json");
1008
- if (existsSync(templateSrc)) {
1009
- writeFileSync(join(contentDir, "bank-template.json"), readFileSync(templateSrc, "utf-8"));
1061
+ // Step 4: Save content locally for the agent (skipped when --empty —
1062
+ // the create-agent skill goes interactive without a `from <path>`).
1063
+ let contentDir = null;
1064
+ if (!isEmpty) {
1065
+ contentDir = join(homedir(), ".self-driving-agents", "claude-code", agentId);
1066
+ mkdirSync(contentDir, { recursive: true });
1067
+ const contentFiles = findContentFiles(dir);
1068
+ for (const relPath of contentFiles) {
1069
+ const destPath = join(contentDir, relPath);
1070
+ mkdirSync(join(destPath, ".."), { recursive: true });
1071
+ writeFileSync(destPath, readFileSync(join(dir, relPath), "utf-8"));
1072
+ }
1073
+ const templateSrc = join(dir, "bank-template.json");
1074
+ if (existsSync(templateSrc)) {
1075
+ writeFileSync(join(contentDir, "bank-template.json"), readFileSync(templateSrc, "utf-8"));
1076
+ }
1077
+ p.log.success(`Content saved to ${color.dim(contentDir)} (${contentFiles.length} files)`);
1010
1078
  }
1011
- p.log.success(`Content saved to ${color.dim(contentDir)} (${contentFiles.length} files)`);
1012
1079
  // Auto-approve hindsight MCP tools and skills in user settings
1013
1080
  const userSettingsPath = join(homedir(), ".claude", "settings.json");
1014
1081
  let userSettings = {};
@@ -1041,7 +1108,11 @@ async function main() {
1041
1108
  writeFileSync(userSettingsPath, JSON.stringify(userSettings, null, 2) + "\n");
1042
1109
  p.log.success("Auto-approved hindsight tools in Claude Code");
1043
1110
  }
1044
- const prompt = `/hindsight-memory:create-agent ${agentId} from ${contentDir}`;
1111
+ // With --empty the skill runs interactively (Mode B); otherwise it
1112
+ // ingests from the staged content directory (Mode A).
1113
+ const prompt = contentDir
1114
+ ? `/hindsight-memory:create-agent ${agentId} from ${contentDir}`
1115
+ : `/hindsight-memory:create-agent ${agentId}`;
1045
1116
  p.note([
1046
1117
  `${color.yellow("⚠")} ${color.bold(`Important:`)} the agent's memory is scoped to the directory where you start ${color.cyan("claude")}.`,
1047
1118
  ` Always start your Claude Code sessions from the same project directory.`,
@@ -1089,20 +1160,24 @@ async function main() {
1089
1160
  p.cancel(`Cannot reach Hindsight at ${apiUrl}\nStart the server or reconfigure the plugin.`);
1090
1161
  process.exit(1);
1091
1162
  }
1092
- // Step 4: Import bank template
1093
- const templatePath = join(dir, "bank-template.json");
1094
- if (existsSync(templatePath)) {
1095
- spin.start("Importing bank template...");
1096
- const template = JSON.parse(readFileSync(templatePath, "utf-8"));
1163
+ // Step 4: Import bank template — or, with --empty, provision a blank
1164
+ // bank so later writes from the harness have somewhere to land.
1165
+ const templatePath = isEmpty ? "" : join(dir, "bank-template.json");
1166
+ const hasTemplate = !isEmpty && existsSync(templatePath);
1167
+ if (isEmpty || hasTemplate) {
1168
+ spin.start(isEmpty ? "Provisioning bank..." : "Importing bank template...");
1169
+ const template = hasTemplate
1170
+ ? JSON.parse(readFileSync(templatePath, "utf-8"))
1171
+ : { version: "1" };
1097
1172
  await sdk.importBankTemplate({
1098
1173
  client: lowLevel,
1099
1174
  path: { bank_id: bankId },
1100
1175
  body: template,
1101
1176
  });
1102
- spin.stop("Bank template imported");
1177
+ spin.stop(isEmpty ? "Bank provisioned" : "Bank template imported");
1103
1178
  }
1104
1179
  // Step 5: Ingest content (recursive — all text files except bank-template.json)
1105
- const contentFiles = findContentFiles(dir);
1180
+ const contentFiles = isEmpty ? [] : findContentFiles(dir);
1106
1181
  if (contentFiles.length > 0) {
1107
1182
  spin.start(`Ingesting ${contentFiles.length} file(s)...`);
1108
1183
  for (const relPath of contentFiles) {
@@ -41,7 +41,9 @@ Use when pages don't cover what you need.
41
41
 
42
42
  ## Ingesting documents
43
43
 
44
- `agent_knowledge_ingest(title, content)` — upload raw content into memory. Never summarize before ingesting. Save large content to a file first, read it, then pass the full text.
44
+ `agent_knowledge_ingest(title, content)` — upload raw content into memory. Never summarize before ingesting. Pass the full text inline.
45
+
46
+ `agent_knowledge_ingest_files(paths)` — ingest one or more files straight from disk. `paths` is a list of file paths or glob patterns (e.g. `["docs/**/*.md", "/abs/path/notes.txt"]`). Each file's content is read and stored under a document ID derived from its path. Prefer this over `agent_knowledge_ingest` when the content already lives in files — no need to read them first. Use absolute paths when in doubt; relative paths resolve against the working directory.
45
47
 
46
48
  ## Updating and deleting
47
49
 
@@ -129,6 +129,94 @@ describe("isLocalPath", () => {
129
129
  expect(isLocalPath("marketing/seo")).toBe(false);
130
130
  });
131
131
  });
132
+ describe("isValidAgentName", () => {
133
+ // Mirrors the regex in cli.ts. --empty mode validates the first positional
134
+ // arg (the agent name) against this since there's no path/GitHub fetch to
135
+ // implicitly sanitize.
136
+ function isValidAgentName(name) {
137
+ return /^[a-z0-9][a-z0-9-]*$/.test(name) && name.length <= 64;
138
+ }
139
+ it("accepts lowercase with hyphens", () => {
140
+ expect(isValidAgentName("my-agent")).toBe(true);
141
+ expect(isValidAgentName("marketing-seo")).toBe(true);
142
+ expect(isValidAgentName("agent")).toBe(true);
143
+ expect(isValidAgentName("a1b2c3")).toBe(true);
144
+ });
145
+ it("accepts a single character", () => {
146
+ expect(isValidAgentName("a")).toBe(true);
147
+ expect(isValidAgentName("0")).toBe(true);
148
+ });
149
+ it("rejects uppercase", () => {
150
+ expect(isValidAgentName("MyAgent")).toBe(false);
151
+ expect(isValidAgentName("AGENT")).toBe(false);
152
+ });
153
+ it("rejects names starting with hyphen", () => {
154
+ expect(isValidAgentName("-agent")).toBe(false);
155
+ expect(isValidAgentName("--empty")).toBe(false);
156
+ });
157
+ it("rejects empty string", () => {
158
+ expect(isValidAgentName("")).toBe(false);
159
+ });
160
+ it("rejects whitespace", () => {
161
+ expect(isValidAgentName("my agent")).toBe(false);
162
+ expect(isValidAgentName(" my-agent")).toBe(false);
163
+ });
164
+ it("rejects underscores and other punctuation", () => {
165
+ expect(isValidAgentName("my_agent")).toBe(false);
166
+ expect(isValidAgentName("my.agent")).toBe(false);
167
+ expect(isValidAgentName("my/agent")).toBe(false);
168
+ });
169
+ it("rejects names longer than 64 characters", () => {
170
+ expect(isValidAgentName("a".repeat(64))).toBe(true);
171
+ expect(isValidAgentName("a".repeat(65))).toBe(false);
172
+ });
173
+ });
174
+ describe("--empty arg parsing", () => {
175
+ // Mirrors the loop in main() that walks restArgs to pick out flag values.
176
+ function parseRestArgs(restArgs) {
177
+ let harness;
178
+ let agentName;
179
+ let sandbox;
180
+ let isEmpty = false;
181
+ for (let i = 0; i < restArgs.length; i++) {
182
+ if (restArgs[i] === "--harness" && restArgs[i + 1])
183
+ harness = restArgs[++i];
184
+ else if (restArgs[i] === "--agent" && restArgs[i + 1])
185
+ agentName = restArgs[++i];
186
+ else if (restArgs[i] === "--sandbox" && restArgs[i + 1])
187
+ sandbox = restArgs[++i];
188
+ else if (restArgs[i] === "--empty")
189
+ isEmpty = true;
190
+ }
191
+ return { harness, agentName, sandbox, isEmpty };
192
+ }
193
+ it("picks up --empty as a boolean", () => {
194
+ expect(parseRestArgs(["--harness", "claude-code", "--empty"]).isEmpty).toBe(true);
195
+ expect(parseRestArgs(["--harness", "claude-code"]).isEmpty).toBe(false);
196
+ });
197
+ it("--empty does not consume the next argument", () => {
198
+ const r = parseRestArgs(["--empty", "--harness", "claude-code"]);
199
+ expect(r.isEmpty).toBe(true);
200
+ expect(r.harness).toBe("claude-code");
201
+ });
202
+ it("works alongside --harness, --agent, --sandbox", () => {
203
+ const r = parseRestArgs([
204
+ "--harness",
205
+ "nemoclaw",
206
+ "--empty",
207
+ "--agent",
208
+ "my-agent",
209
+ "--sandbox",
210
+ "default",
211
+ ]);
212
+ expect(r).toEqual({
213
+ harness: "nemoclaw",
214
+ agentName: "my-agent",
215
+ sandbox: "default",
216
+ isEmpty: true,
217
+ });
218
+ });
219
+ });
132
220
  describe("deriveDefaultName", () => {
133
221
  // Mirrors the logic in resolveAgentDir:
134
222
  // - GitHub refs: subpath with / → hyphens (marketing/seo → marketing-seo)
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join, resolve } from "path";
5
+ // @ts-expect-error - .mjs has no type declarations; runtime import is fine
6
+ import { findBankTemplates, lintBankTemplate, lintAll } from "../scripts/lint-bank-templates.mjs";
7
+ const REPO_ROOT = resolve(__dirname, "..", "..");
8
+ describe("lint-bank-templates", () => {
9
+ let dir;
10
+ beforeEach(() => {
11
+ dir = mkdtempSync(join(tmpdir(), "sda-lint-"));
12
+ });
13
+ afterEach(() => {
14
+ rmSync(dir, { recursive: true, force: true });
15
+ });
16
+ it("accepts a valid template (observations_mission + >=1 mental_models, no reflect_mission)", () => {
17
+ const file = join(dir, "bank-template.json");
18
+ writeFileSync(file, JSON.stringify({
19
+ version: "1",
20
+ bank: { observations_mission: "watch X" },
21
+ mental_models: [{ id: "a", name: "A" }],
22
+ }));
23
+ expect(lintBankTemplate(file)).toEqual([]);
24
+ });
25
+ it("rejects missing observations_mission", () => {
26
+ const file = join(dir, "bank-template.json");
27
+ writeFileSync(file, JSON.stringify({ bank: {}, mental_models: [{ id: "a", name: "A" }] }));
28
+ const errs = lintBankTemplate(file);
29
+ expect(errs.some((e) => e.includes("observations_mission"))).toBe(true);
30
+ });
31
+ it("rejects empty observations_mission", () => {
32
+ const file = join(dir, "bank-template.json");
33
+ writeFileSync(file, JSON.stringify({
34
+ bank: { observations_mission: " " },
35
+ mental_models: [{ id: "a", name: "A" }],
36
+ }));
37
+ const errs = lintBankTemplate(file);
38
+ expect(errs.some((e) => e.includes("observations_mission"))).toBe(true);
39
+ });
40
+ it("rejects empty mental_models", () => {
41
+ const file = join(dir, "bank-template.json");
42
+ writeFileSync(file, JSON.stringify({
43
+ bank: { observations_mission: "watch X" },
44
+ mental_models: [],
45
+ }));
46
+ const errs = lintBankTemplate(file);
47
+ expect(errs.some((e) => e.includes("mental_models"))).toBe(true);
48
+ });
49
+ it("rejects missing mental_models entirely", () => {
50
+ const file = join(dir, "bank-template.json");
51
+ writeFileSync(file, JSON.stringify({ bank: { observations_mission: "watch X" } }));
52
+ const errs = lintBankTemplate(file);
53
+ expect(errs.some((e) => e.includes("mental_models"))).toBe(true);
54
+ });
55
+ it("rejects deprecated reflect_mission", () => {
56
+ const file = join(dir, "bank-template.json");
57
+ writeFileSync(file, JSON.stringify({
58
+ bank: { observations_mission: "watch X", reflect_mission: "old" },
59
+ mental_models: [{ id: "a", name: "A" }],
60
+ }));
61
+ const errs = lintBankTemplate(file);
62
+ expect(errs.some((e) => e.includes("reflect_mission"))).toBe(true);
63
+ });
64
+ it("rejects invalid JSON", () => {
65
+ const file = join(dir, "bank-template.json");
66
+ writeFileSync(file, "{ not json");
67
+ const errs = lintBankTemplate(file);
68
+ expect(errs.some((e) => e.toLowerCase().includes("json"))).toBe(true);
69
+ });
70
+ it("findBankTemplates walks recursively and skips node_modules", () => {
71
+ mkdirSync(join(dir, "a", "b"), { recursive: true });
72
+ mkdirSync(join(dir, "node_modules", "x"), { recursive: true });
73
+ writeFileSync(join(dir, "bank-template.json"), "{}");
74
+ writeFileSync(join(dir, "a", "bank-template.json"), "{}");
75
+ writeFileSync(join(dir, "a", "b", "bank-template.json"), "{}");
76
+ writeFileSync(join(dir, "node_modules", "x", "bank-template.json"), "{}");
77
+ const found = findBankTemplates(dir);
78
+ expect(found).toHaveLength(3);
79
+ expect(found.every((f) => !f.includes("node_modules"))).toBe(true);
80
+ });
81
+ it("the actual repo passes lint", () => {
82
+ const issues = lintAll(REPO_ROOT);
83
+ expect(issues).toEqual([]);
84
+ });
85
+ });