pretticlaw 0.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.
Files changed (158) hide show
  1. package/CONTRIBUTING.md +123 -0
  2. package/README.md +150 -0
  3. package/assets/logo.png +0 -0
  4. package/dist/agent/context.d.ts +22 -0
  5. package/dist/agent/context.js +85 -0
  6. package/dist/agent/loop.d.ts +63 -0
  7. package/dist/agent/loop.js +244 -0
  8. package/dist/agent/memory.d.ts +16 -0
  9. package/dist/agent/memory.js +98 -0
  10. package/dist/agent/skills.d.ts +18 -0
  11. package/dist/agent/skills.js +121 -0
  12. package/dist/agent/subagent.d.ts +30 -0
  13. package/dist/agent/subagent.js +92 -0
  14. package/dist/agent/tools/base.d.ts +10 -0
  15. package/dist/agent/tools/base.js +58 -0
  16. package/dist/agent/tools/cron.d.ts +43 -0
  17. package/dist/agent/tools/cron.js +83 -0
  18. package/dist/agent/tools/filesystem.d.ts +79 -0
  19. package/dist/agent/tools/filesystem.js +125 -0
  20. package/dist/agent/tools/message.d.ts +41 -0
  21. package/dist/agent/tools/message.js +55 -0
  22. package/dist/agent/tools/registry.d.ts +9 -0
  23. package/dist/agent/tools/registry.js +33 -0
  24. package/dist/agent/tools/shell.d.ts +26 -0
  25. package/dist/agent/tools/shell.js +78 -0
  26. package/dist/agent/tools/spawn.d.ts +27 -0
  27. package/dist/agent/tools/spawn.js +35 -0
  28. package/dist/agent/tools/web.d.ts +50 -0
  29. package/dist/agent/tools/web.js +119 -0
  30. package/dist/bus/async-queue.d.ts +7 -0
  31. package/dist/bus/async-queue.js +20 -0
  32. package/dist/bus/events.d.ts +19 -0
  33. package/dist/bus/events.js +3 -0
  34. package/dist/bus/queue.d.ts +12 -0
  35. package/dist/bus/queue.js +23 -0
  36. package/dist/channels/base.d.ts +22 -0
  37. package/dist/channels/base.js +35 -0
  38. package/dist/channels/discord.d.ts +24 -0
  39. package/dist/channels/discord.js +133 -0
  40. package/dist/channels/manager.d.ts +17 -0
  41. package/dist/channels/manager.js +67 -0
  42. package/dist/channels/stub.d.ts +10 -0
  43. package/dist/channels/stub.js +18 -0
  44. package/dist/channels/telegram.d.ts +20 -0
  45. package/dist/channels/telegram.js +93 -0
  46. package/dist/cli/commands.d.ts +2 -0
  47. package/dist/cli/commands.js +552 -0
  48. package/dist/config/loader.d.ts +5 -0
  49. package/dist/config/loader.js +55 -0
  50. package/dist/config/schema.d.ts +246 -0
  51. package/dist/config/schema.js +94 -0
  52. package/dist/cron/service.d.ts +33 -0
  53. package/dist/cron/service.js +195 -0
  54. package/dist/cron/types.d.ts +47 -0
  55. package/dist/cron/types.js +1 -0
  56. package/dist/dashboard/index.html +1567 -0
  57. package/dist/heartbeat/service.d.ts +21 -0
  58. package/dist/heartbeat/service.js +101 -0
  59. package/dist/index.d.ts +2 -0
  60. package/dist/index.js +5 -0
  61. package/dist/providers/base.d.ts +23 -0
  62. package/dist/providers/base.js +21 -0
  63. package/dist/providers/custom-provider.d.ts +16 -0
  64. package/dist/providers/custom-provider.js +49 -0
  65. package/dist/providers/litellm-provider.d.ts +19 -0
  66. package/dist/providers/litellm-provider.js +128 -0
  67. package/dist/providers/registry.d.ts +5 -0
  68. package/dist/providers/registry.js +45 -0
  69. package/dist/session/manager.d.ts +31 -0
  70. package/dist/session/manager.js +116 -0
  71. package/dist/skills/README.md +25 -0
  72. package/dist/skills/clawhub/SKILL.md +53 -0
  73. package/dist/skills/cron/SKILL.md +57 -0
  74. package/dist/skills/github/SKILL.md +48 -0
  75. package/dist/skills/memory/SKILL.md +31 -0
  76. package/dist/skills/skill-creator/SKILL.md +371 -0
  77. package/dist/skills/summarize/SKILL.md +67 -0
  78. package/dist/skills/tmux/SKILL.md +121 -0
  79. package/dist/skills/tmux/scripts/find-sessions.sh +112 -0
  80. package/dist/skills/tmux/scripts/wait-for-text.sh +83 -0
  81. package/dist/skills/weather/SKILL.md +49 -0
  82. package/dist/templates/AGENTS.md +23 -0
  83. package/dist/templates/HEARTBEAT.md +16 -0
  84. package/dist/templates/SOUL.md +21 -0
  85. package/dist/templates/TOOLS.md +15 -0
  86. package/dist/templates/USER.md +49 -0
  87. package/dist/templates/memory/MEMORY.md +23 -0
  88. package/dist/types.d.ts +4 -0
  89. package/dist/types.js +3 -0
  90. package/dist/utils/helpers.d.ts +5 -0
  91. package/dist/utils/helpers.js +53 -0
  92. package/dist/web/server.d.ts +15 -0
  93. package/dist/web/server.js +169 -0
  94. package/package.json +37 -0
  95. package/scripts/copy-assets.mjs +21 -0
  96. package/src/agent/context.ts +90 -0
  97. package/src/agent/loop.ts +291 -0
  98. package/src/agent/memory.ts +104 -0
  99. package/src/agent/skills.ts +121 -0
  100. package/src/agent/subagent.ts +96 -0
  101. package/src/agent/tools/base.ts +59 -0
  102. package/src/agent/tools/cron.ts +79 -0
  103. package/src/agent/tools/filesystem.ts +93 -0
  104. package/src/agent/tools/message.ts +57 -0
  105. package/src/agent/tools/registry.ts +36 -0
  106. package/src/agent/tools/shell.ts +69 -0
  107. package/src/agent/tools/spawn.ts +37 -0
  108. package/src/agent/tools/web.ts +108 -0
  109. package/src/bus/async-queue.ts +20 -0
  110. package/src/bus/events.ts +23 -0
  111. package/src/bus/queue.ts +31 -0
  112. package/src/channels/base.ts +36 -0
  113. package/src/channels/discord.ts +156 -0
  114. package/src/channels/manager.ts +70 -0
  115. package/src/channels/stub.ts +20 -0
  116. package/src/channels/telegram.ts +120 -0
  117. package/src/cli/commands.ts +581 -0
  118. package/src/config/loader.ts +58 -0
  119. package/src/config/schema.ts +144 -0
  120. package/src/cron/service.ts +190 -0
  121. package/src/cron/types.ts +36 -0
  122. package/src/dashboard/index.html +1567 -0
  123. package/src/heartbeat/service.ts +95 -0
  124. package/src/index.ts +6 -0
  125. package/src/providers/base.ts +43 -0
  126. package/src/providers/custom-provider.ts +46 -0
  127. package/src/providers/litellm-provider.ts +131 -0
  128. package/src/providers/registry.ts +48 -0
  129. package/src/session/manager.ts +129 -0
  130. package/src/skills/README.md +25 -0
  131. package/src/skills/clawhub/SKILL.md +53 -0
  132. package/src/skills/cron/SKILL.md +57 -0
  133. package/src/skills/github/SKILL.md +48 -0
  134. package/src/skills/memory/SKILL.md +31 -0
  135. package/src/skills/skill-creator/SKILL.md +371 -0
  136. package/src/skills/summarize/SKILL.md +67 -0
  137. package/src/skills/tmux/SKILL.md +121 -0
  138. package/src/skills/tmux/scripts/find-sessions.sh +112 -0
  139. package/src/skills/tmux/scripts/wait-for-text.sh +83 -0
  140. package/src/skills/weather/SKILL.md +49 -0
  141. package/src/templates/AGENTS.md +23 -0
  142. package/src/templates/HEARTBEAT.md +16 -0
  143. package/src/templates/SOUL.md +21 -0
  144. package/src/templates/TOOLS.md +15 -0
  145. package/src/templates/USER.md +49 -0
  146. package/src/templates/memory/MEMORY.md +23 -0
  147. package/src/types/prompts.d.ts +14 -0
  148. package/src/types/ws.d.ts +15 -0
  149. package/src/types.ts +5 -0
  150. package/src/utils/helpers.ts +55 -0
  151. package/src/web/server.ts +198 -0
  152. package/test/context.test.ts +27 -0
  153. package/test/cron-service.test.ts +31 -0
  154. package/test/message-tool.test.ts +10 -0
  155. package/test/providers.test.ts +43 -0
  156. package/test/tool-validation.test.ts +61 -0
  157. package/tsconfig.json +16 -0
  158. package/vitest.config.ts +8 -0
@@ -0,0 +1,21 @@
1
+ import type { LLMProvider } from "../providers/base.js";
2
+ export declare class HeartbeatService {
3
+ private readonly workspace;
4
+ private readonly provider;
5
+ private readonly model;
6
+ private readonly onExecute?;
7
+ private readonly onNotify?;
8
+ private readonly intervalS;
9
+ private readonly enabled;
10
+ private running;
11
+ private timer;
12
+ constructor(workspace: string, provider: LLMProvider, model: string, onExecute?: ((tasks: string) => Promise<string>) | undefined, onNotify?: ((response: string) => Promise<void>) | undefined, intervalS?: number, enabled?: boolean);
13
+ private get heartbeatFile();
14
+ private readHeartbeat;
15
+ private decide;
16
+ start(): Promise<void>;
17
+ stop(): void;
18
+ private schedule;
19
+ private tick;
20
+ triggerNow(): Promise<string | null>;
21
+ }
@@ -0,0 +1,101 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const HEARTBEAT_TOOL = [{
4
+ type: "function",
5
+ function: {
6
+ name: "heartbeat",
7
+ description: "Report heartbeat decision after reviewing tasks.",
8
+ parameters: {
9
+ type: "object",
10
+ properties: {
11
+ action: { type: "string", enum: ["skip", "run"] },
12
+ tasks: { type: "string" },
13
+ },
14
+ required: ["action"],
15
+ },
16
+ },
17
+ }];
18
+ export class HeartbeatService {
19
+ workspace;
20
+ provider;
21
+ model;
22
+ onExecute;
23
+ onNotify;
24
+ intervalS;
25
+ enabled;
26
+ running = false;
27
+ timer = null;
28
+ constructor(workspace, provider, model, onExecute, onNotify, intervalS = 1800, enabled = true) {
29
+ this.workspace = workspace;
30
+ this.provider = provider;
31
+ this.model = model;
32
+ this.onExecute = onExecute;
33
+ this.onNotify = onNotify;
34
+ this.intervalS = intervalS;
35
+ this.enabled = enabled;
36
+ }
37
+ get heartbeatFile() {
38
+ return path.join(this.workspace, "HEARTBEAT.md");
39
+ }
40
+ readHeartbeat() {
41
+ if (!fs.existsSync(this.heartbeatFile))
42
+ return null;
43
+ const text = fs.readFileSync(this.heartbeatFile, "utf8");
44
+ return text.trim() ? text : null;
45
+ }
46
+ async decide(content) {
47
+ const response = await this.provider.chat({
48
+ model: this.model,
49
+ messages: [
50
+ { role: "system", content: "You are a heartbeat agent. Call the heartbeat tool to report your decision." },
51
+ { role: "user", content: `Review the following HEARTBEAT.md and decide whether there are active tasks.\n\n${content}` },
52
+ ],
53
+ tools: HEARTBEAT_TOOL,
54
+ });
55
+ if (!response.toolCalls.length)
56
+ return { action: "skip", tasks: "" };
57
+ const args = response.toolCalls[0].arguments;
58
+ const action = args.action === "run" ? "run" : "skip";
59
+ const tasks = typeof args.tasks === "string" ? args.tasks : "";
60
+ return { action, tasks };
61
+ }
62
+ async start() {
63
+ if (!this.enabled || this.running)
64
+ return;
65
+ this.running = true;
66
+ this.schedule();
67
+ }
68
+ stop() {
69
+ this.running = false;
70
+ if (this.timer)
71
+ clearTimeout(this.timer);
72
+ this.timer = null;
73
+ }
74
+ schedule() {
75
+ if (!this.running)
76
+ return;
77
+ this.timer = setTimeout(() => void this.tick().finally(() => this.schedule()), this.intervalS * 1000);
78
+ }
79
+ async tick() {
80
+ const content = this.readHeartbeat();
81
+ if (!content)
82
+ return;
83
+ const { action, tasks } = await this.decide(content);
84
+ if (action !== "run")
85
+ return;
86
+ if (!this.onExecute)
87
+ return;
88
+ const response = await this.onExecute(tasks);
89
+ if (response && this.onNotify)
90
+ await this.onNotify(response);
91
+ }
92
+ async triggerNow() {
93
+ const content = this.readHeartbeat();
94
+ if (!content)
95
+ return null;
96
+ const { action, tasks } = await this.decide(content);
97
+ if (action !== "run" || !this.onExecute)
98
+ return null;
99
+ return this.onExecute(tasks);
100
+ }
101
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
3
+ import { buildProgram } from "./cli/commands.js";
4
+ const program = buildProgram();
5
+ program.parseAsync(process.argv);
@@ -0,0 +1,23 @@
1
+ export interface ToolCallRequest {
2
+ id: string;
3
+ name: string;
4
+ arguments: Record<string, unknown>;
5
+ }
6
+ export interface LLMResponse {
7
+ content: string | null;
8
+ toolCalls: ToolCallRequest[];
9
+ finishReason: string;
10
+ usage: Record<string, number>;
11
+ reasoningContent: string | null;
12
+ }
13
+ export interface LLMProvider {
14
+ chat(input: {
15
+ messages: Array<Record<string, unknown>>;
16
+ tools?: Array<Record<string, unknown>>;
17
+ model?: string;
18
+ maxTokens?: number;
19
+ temperature?: number;
20
+ }): Promise<LLMResponse>;
21
+ getDefaultModel(): string;
22
+ }
23
+ export declare function sanitizeEmptyContent(messages: Array<Record<string, unknown>>): Array<Record<string, unknown>>;
@@ -0,0 +1,21 @@
1
+ export function sanitizeEmptyContent(messages) {
2
+ return messages.map((msg) => {
3
+ const content = msg.content;
4
+ if (typeof content === "string" && content.length === 0) {
5
+ if (msg.role === "assistant" && msg.tool_calls)
6
+ return { ...msg, content: null };
7
+ return { ...msg, content: "(empty)" };
8
+ }
9
+ if (Array.isArray(content)) {
10
+ const filtered = content.filter((item) => !(typeof item === "object" && item && ["text", "input_text", "output_text"].includes(item.type) && !item.text));
11
+ if (filtered.length !== content.length) {
12
+ if (filtered.length > 0)
13
+ return { ...msg, content: filtered };
14
+ if (msg.role === "assistant" && msg.tool_calls)
15
+ return { ...msg, content: null };
16
+ return { ...msg, content: "(empty)" };
17
+ }
18
+ }
19
+ return msg;
20
+ });
21
+ }
@@ -0,0 +1,16 @@
1
+ import type { LLMProvider, LLMResponse } from "./base.js";
2
+ export declare class CustomProvider implements LLMProvider {
3
+ private readonly apiKey;
4
+ private readonly apiBase;
5
+ private readonly defaultModel;
6
+ private client;
7
+ constructor(apiKey: string, apiBase: string, defaultModel: string);
8
+ getDefaultModel(): string;
9
+ chat(input: {
10
+ messages: Array<Record<string, unknown>>;
11
+ tools?: Array<Record<string, unknown>>;
12
+ model?: string;
13
+ maxTokens?: number;
14
+ temperature?: number;
15
+ }): Promise<LLMResponse>;
16
+ }
@@ -0,0 +1,49 @@
1
+ import OpenAI from "openai";
2
+ import { sanitizeEmptyContent } from "./base.js";
3
+ export class CustomProvider {
4
+ apiKey;
5
+ apiBase;
6
+ defaultModel;
7
+ client;
8
+ constructor(apiKey, apiBase, defaultModel) {
9
+ this.apiKey = apiKey;
10
+ this.apiBase = apiBase;
11
+ this.defaultModel = defaultModel;
12
+ this.client = new OpenAI({ apiKey, baseURL: apiBase });
13
+ }
14
+ getDefaultModel() {
15
+ return this.defaultModel;
16
+ }
17
+ async chat(input) {
18
+ try {
19
+ const res = await this.client.chat.completions.create({
20
+ model: input.model ?? this.defaultModel,
21
+ messages: sanitizeEmptyContent(input.messages),
22
+ tools: input.tools,
23
+ tool_choice: input.tools ? "auto" : undefined,
24
+ max_tokens: Math.max(1, input.maxTokens ?? 4096),
25
+ temperature: input.temperature ?? 0.7,
26
+ });
27
+ const choice = res.choices[0];
28
+ const toolCalls = (choice.message.tool_calls ?? []).map((tc) => ({
29
+ id: tc.id,
30
+ name: tc.function.name,
31
+ arguments: JSON.parse(tc.function.arguments || "{}"),
32
+ }));
33
+ return {
34
+ content: choice.message.content,
35
+ toolCalls,
36
+ finishReason: choice.finish_reason ?? "stop",
37
+ usage: {
38
+ prompt_tokens: res.usage?.prompt_tokens ?? 0,
39
+ completion_tokens: res.usage?.completion_tokens ?? 0,
40
+ total_tokens: res.usage?.total_tokens ?? 0,
41
+ },
42
+ reasoningContent: null,
43
+ };
44
+ }
45
+ catch (err) {
46
+ return { content: `Error: ${String(err)}`, toolCalls: [], finishReason: "error", usage: {}, reasoningContent: null };
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,19 @@
1
+ import type { LLMProvider, LLMResponse } from "./base.js";
2
+ export declare class LiteLLMProvider implements LLMProvider {
3
+ private readonly apiKey;
4
+ private readonly apiBase;
5
+ private readonly defaultModel;
6
+ private readonly providerName;
7
+ private static readonly DEFAULT_BASE_BY_PROVIDER;
8
+ private static readonly UNSUPPORTED_PROVIDERS;
9
+ constructor(apiKey: string | null, apiBase: string | null, defaultModel: string, providerName: string | null);
10
+ getDefaultModel(): string;
11
+ resolveModel(model: string): string;
12
+ chat(input: {
13
+ messages: Array<Record<string, unknown>>;
14
+ tools?: Array<Record<string, unknown>>;
15
+ model?: string;
16
+ maxTokens?: number;
17
+ temperature?: number;
18
+ }): Promise<LLMResponse>;
19
+ }
@@ -0,0 +1,128 @@
1
+ import { nanoid } from "nanoid";
2
+ import { sanitizeEmptyContent } from "./base.js";
3
+ export class LiteLLMProvider {
4
+ apiKey;
5
+ apiBase;
6
+ defaultModel;
7
+ providerName;
8
+ static DEFAULT_BASE_BY_PROVIDER = {
9
+ openrouter: "https://openrouter.ai/api/v1",
10
+ openai: "https://api.openai.com/v1",
11
+ deepseek: "https://api.deepseek.com/v1",
12
+ groq: "https://api.groq.com/openai/v1",
13
+ moonshot: "https://api.moonshot.ai/v1",
14
+ minimax: "https://api.minimax.io/v1",
15
+ dashscope: "https://dashscope.aliyuncs.com/compatible-mode/v1",
16
+ zhipu: "https://open.bigmodel.cn/api/paas/v4",
17
+ siliconflow: "https://api.siliconflow.cn/v1",
18
+ volcengine: "https://ark.cn-beijing.volces.com/api/v3",
19
+ vllm: "http://localhost:8000/v1",
20
+ custom: "http://localhost:8000/v1",
21
+ };
22
+ static UNSUPPORTED_PROVIDERS = new Set(["anthropic", "gemini", "openai_codex", "github_copilot"]);
23
+ constructor(apiKey, apiBase, defaultModel, providerName) {
24
+ this.apiKey = apiKey;
25
+ this.apiBase = apiBase;
26
+ this.defaultModel = defaultModel;
27
+ this.providerName = providerName;
28
+ }
29
+ getDefaultModel() {
30
+ return this.defaultModel;
31
+ }
32
+ resolveModel(model) {
33
+ const canonicalize = (s) => s.toLowerCase().replace(/-/g, "_");
34
+ if (model.includes("/")) {
35
+ const [prefix, rest] = model.split("/", 2);
36
+ if (canonicalize(prefix) === "github_copilot")
37
+ return `github_copilot/${rest}`;
38
+ if (canonicalize(prefix) === "openai_codex")
39
+ return `openai_codex/${rest}`;
40
+ // Groq OpenAI-compatible endpoint expects model ids like:
41
+ // llama-3.3-70b-versatile
42
+ // openai/gpt-oss-120b
43
+ // so a "groq/" prefix should be stripped.
44
+ if (canonicalize(prefix) === "groq") {
45
+ if (rest === "compound" || rest === "compound-mini")
46
+ return `groq/${rest}`;
47
+ return rest;
48
+ }
49
+ }
50
+ return model;
51
+ }
52
+ async chat(input) {
53
+ const model = this.resolveModel(input.model ?? this.defaultModel);
54
+ const body = {
55
+ model,
56
+ messages: sanitizeEmptyContent(input.messages),
57
+ max_tokens: Math.max(1, input.maxTokens ?? 4096),
58
+ temperature: input.temperature ?? 0.7,
59
+ };
60
+ if (input.tools?.length) {
61
+ body.tools = input.tools;
62
+ body.tool_choice = "auto";
63
+ }
64
+ try {
65
+ const providerGuess = (this.providerName ?? "").trim();
66
+ if (providerGuess && LiteLLMProvider.UNSUPPORTED_PROVIDERS.has(providerGuess)) {
67
+ return {
68
+ content: `Error calling LLM: provider '${providerGuess}' is not supported in this TypeScript port yet. Use openrouter/openai/deepseek/groq/custom.`,
69
+ toolCalls: [],
70
+ finishReason: "error",
71
+ usage: {},
72
+ reasoningContent: null,
73
+ };
74
+ }
75
+ const apiBase = this.apiBase ??
76
+ (providerGuess ? LiteLLMProvider.DEFAULT_BASE_BY_PROVIDER[providerGuess] : undefined) ??
77
+ (this.apiKey?.startsWith("sk-or-") ? LiteLLMProvider.DEFAULT_BASE_BY_PROVIDER.openrouter : undefined);
78
+ if (!apiBase) {
79
+ return {
80
+ content: "Error calling LLM: api_base not configured. Set provider/api_base in ~/.pretticlaw/config.json or run pretticlaw onboard.",
81
+ toolCalls: [],
82
+ finishReason: "error",
83
+ usage: {},
84
+ reasoningContent: null,
85
+ };
86
+ }
87
+ const headers = { "Content-Type": "application/json" };
88
+ if (this.apiKey)
89
+ headers.Authorization = `Bearer ${this.apiKey}`;
90
+ const res = await fetch(`${apiBase.replace(/\/$/, "")}/chat/completions`, {
91
+ method: "POST",
92
+ headers,
93
+ body: JSON.stringify(body),
94
+ });
95
+ const json = await res.json();
96
+ if (!res.ok) {
97
+ const code = json?.error?.code ?? "";
98
+ const message = json?.error?.message ?? JSON.stringify(json);
99
+ if (code === "model_not_found") {
100
+ const hint = providerGuess === "groq"
101
+ ? "Try a Groq-supported model like llama-3.3-70b-versatile or openai/gpt-oss-120b. You can run `pretticlaw doctor`."
102
+ : "Check your model id and provider access. You can run `pretticlaw doctor`.";
103
+ return { content: `Error calling LLM: ${message}\n${hint}`, toolCalls: [], finishReason: "error", usage: {}, reasoningContent: null };
104
+ }
105
+ return { content: `Error calling LLM: ${message}`, toolCalls: [], finishReason: "error", usage: {}, reasoningContent: null };
106
+ }
107
+ const choice = json.choices?.[0]?.message ? json.choices[0] : null;
108
+ if (!choice) {
109
+ return { content: `Error calling LLM: ${JSON.stringify(json)}`, toolCalls: [], finishReason: "error", usage: {}, reasoningContent: null };
110
+ }
111
+ const toolCalls = (choice.message.tool_calls ?? []).map((tc) => ({
112
+ id: nanoid(9),
113
+ name: tc.function.name,
114
+ arguments: typeof tc.function.arguments === "string" ? JSON.parse(tc.function.arguments || "{}") : tc.function.arguments,
115
+ }));
116
+ return {
117
+ content: choice.message.content ?? null,
118
+ toolCalls,
119
+ finishReason: choice.finish_reason ?? "stop",
120
+ usage: json.usage ?? {},
121
+ reasoningContent: choice.message.reasoning_content ?? null,
122
+ };
123
+ }
124
+ catch (err) {
125
+ return { content: `Error calling LLM: ${String(err)}`, toolCalls: [], finishReason: "error", usage: {}, reasoningContent: null };
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,5 @@
1
+ import { PROVIDERS, type Config } from "../config/schema.js";
2
+ import type { LLMProvider } from "./base.js";
3
+ export declare function findByModel(model: string): (typeof PROVIDERS)[number] | null;
4
+ export declare function stripModelPrefix(model: string): string;
5
+ export declare function makeProvider(config: Config): LLMProvider;
@@ -0,0 +1,45 @@
1
+ import { PROVIDERS, getApiBase, getProvider, getProviderName } from "../config/schema.js";
2
+ import { CustomProvider } from "./custom-provider.js";
3
+ import { LiteLLMProvider } from "./litellm-provider.js";
4
+ export function findByModel(model) {
5
+ const lower = model.toLowerCase();
6
+ const norm = lower.replace(/-/g, "_");
7
+ const prefix = lower.includes("/") ? lower.split("/", 1)[0].replace(/-/g, "_") : "";
8
+ const standard = PROVIDERS.filter((p) => !p.isGateway && !p.isLocal);
9
+ for (const spec of standard) {
10
+ if (prefix && prefix === spec.name)
11
+ return spec;
12
+ }
13
+ for (const spec of standard) {
14
+ if (spec.keywords.some((k) => lower.includes(k) || norm.includes(k.replace(/-/g, "_"))))
15
+ return spec;
16
+ }
17
+ return null;
18
+ }
19
+ export function stripModelPrefix(model) {
20
+ if (model.startsWith("openai-codex/"))
21
+ return model.slice("openai-codex/".length);
22
+ if (model.startsWith("openai_codex/"))
23
+ return model.slice("openai_codex/".length);
24
+ return model;
25
+ }
26
+ export function makeProvider(config) {
27
+ const model = config.agents.defaults.model;
28
+ const providerName = getProviderName(config, model);
29
+ const p = getProvider(config, model);
30
+ const oauthProviders = new Set(["openai_codex", "github_copilot"]);
31
+ const unsupportedInTs = new Set(["anthropic", "gemini"]);
32
+ if (!providerName) {
33
+ throw new Error("No provider could be resolved from config. Run `pretticlaw onboard` and set provider/model/API key.");
34
+ }
35
+ if (unsupportedInTs.has(providerName)) {
36
+ throw new Error(`Provider '${providerName}' is not supported in this TypeScript port yet. Use openrouter/openai/deepseek/groq/custom.`);
37
+ }
38
+ if (!oauthProviders.has(providerName) && providerName !== "vllm" && providerName !== "custom" && !(p?.apiKey || "").trim()) {
39
+ throw new Error(`No API key configured for provider '${providerName}'. Run 'pretticlaw onboard' or edit ~/.pretticlaw/config.json.`);
40
+ }
41
+ if (providerName === "custom") {
42
+ return new CustomProvider(p?.apiKey || "no-key", getApiBase(config, model) || "http://localhost:8000/v1", model);
43
+ }
44
+ return new LiteLLMProvider(p?.apiKey ?? null, getApiBase(config, model), model, providerName);
45
+ }
@@ -0,0 +1,31 @@
1
+ export interface SessionMessage {
2
+ role: string;
3
+ content: unknown;
4
+ timestamp?: string;
5
+ tool_calls?: unknown;
6
+ tool_call_id?: string;
7
+ name?: string;
8
+ [k: string]: unknown;
9
+ }
10
+ export declare class Session {
11
+ key: string;
12
+ messages: SessionMessage[];
13
+ createdAt: string;
14
+ updatedAt: string;
15
+ metadata: Record<string, unknown>;
16
+ lastConsolidated: number;
17
+ constructor(key: string);
18
+ getHistory(maxMessages?: number): Array<Record<string, unknown>>;
19
+ clear(): void;
20
+ }
21
+ export declare class SessionManager {
22
+ private sessionsDir;
23
+ private cache;
24
+ constructor(workspace: string);
25
+ private getSessionPath;
26
+ getOrCreate(key: string): Session;
27
+ save(session: Session): void;
28
+ invalidate(key: string): void;
29
+ listSessions(): Array<Record<string, string>>;
30
+ private load;
31
+ }
@@ -0,0 +1,116 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { nowIso } from "../types.js";
4
+ import { ensureDir, safeFilename } from "../utils/helpers.js";
5
+ export class Session {
6
+ key;
7
+ messages = [];
8
+ createdAt = nowIso();
9
+ updatedAt = nowIso();
10
+ metadata = {};
11
+ lastConsolidated = 0;
12
+ constructor(key) {
13
+ this.key = key;
14
+ }
15
+ getHistory(maxMessages = 500) {
16
+ const unconsolidated = this.messages.slice(this.lastConsolidated);
17
+ let sliced = unconsolidated.slice(-maxMessages);
18
+ const firstUser = sliced.findIndex((m) => m.role === "user");
19
+ if (firstUser > 0)
20
+ sliced = sliced.slice(firstUser);
21
+ return sliced.map((m) => {
22
+ const entry = { role: m.role, content: m.content ?? "" };
23
+ for (const k of ["tool_calls", "tool_call_id", "name"]) {
24
+ if (k in m)
25
+ entry[k] = m[k];
26
+ }
27
+ return entry;
28
+ });
29
+ }
30
+ clear() {
31
+ this.messages = [];
32
+ this.lastConsolidated = 0;
33
+ this.updatedAt = nowIso();
34
+ }
35
+ }
36
+ export class SessionManager {
37
+ sessionsDir;
38
+ cache = new Map();
39
+ constructor(workspace) {
40
+ this.sessionsDir = ensureDir(path.join(workspace, "sessions"));
41
+ }
42
+ getSessionPath(key) {
43
+ return path.join(this.sessionsDir, `${safeFilename(key.replace(":", "_"))}.jsonl`);
44
+ }
45
+ getOrCreate(key) {
46
+ const cached = this.cache.get(key);
47
+ if (cached)
48
+ return cached;
49
+ const loaded = this.load(key) ?? new Session(key);
50
+ this.cache.set(key, loaded);
51
+ return loaded;
52
+ }
53
+ save(session) {
54
+ const p = this.getSessionPath(session.key);
55
+ const meta = {
56
+ _type: "metadata",
57
+ key: session.key,
58
+ created_at: session.createdAt,
59
+ updated_at: session.updatedAt,
60
+ metadata: session.metadata,
61
+ last_consolidated: session.lastConsolidated,
62
+ };
63
+ const lines = [JSON.stringify(meta), ...session.messages.map((m) => JSON.stringify(m))];
64
+ fs.writeFileSync(p, `${lines.join("\n")}\n`, "utf8");
65
+ this.cache.set(session.key, session);
66
+ }
67
+ invalidate(key) {
68
+ this.cache.delete(key);
69
+ }
70
+ listSessions() {
71
+ if (!fs.existsSync(this.sessionsDir))
72
+ return [];
73
+ const out = [];
74
+ for (const file of fs.readdirSync(this.sessionsDir)) {
75
+ if (!file.endsWith(".jsonl"))
76
+ continue;
77
+ const p = path.join(this.sessionsDir, file);
78
+ try {
79
+ const first = fs.readFileSync(p, "utf8").split(/\r?\n/, 1)[0];
80
+ const meta = JSON.parse(first);
81
+ if (meta._type === "metadata") {
82
+ out.push({ key: meta.key ?? file.replace(".jsonl", ""), created_at: meta.created_at, updated_at: meta.updated_at, path: p });
83
+ }
84
+ }
85
+ catch {
86
+ // ignore
87
+ }
88
+ }
89
+ return out.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""));
90
+ }
91
+ load(key) {
92
+ const p = this.getSessionPath(key);
93
+ if (!fs.existsSync(p))
94
+ return null;
95
+ try {
96
+ const lines = fs.readFileSync(p, "utf8").split(/\r?\n/).filter(Boolean);
97
+ const session = new Session(key);
98
+ for (const line of lines) {
99
+ const obj = JSON.parse(line);
100
+ if (obj._type === "metadata") {
101
+ session.createdAt = obj.created_at ?? session.createdAt;
102
+ session.updatedAt = obj.updated_at ?? session.updatedAt;
103
+ session.metadata = obj.metadata ?? {};
104
+ session.lastConsolidated = obj.last_consolidated ?? 0;
105
+ }
106
+ else {
107
+ session.messages.push(obj);
108
+ }
109
+ }
110
+ return session;
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,25 @@
1
+ # Pretticlaw Skills
2
+
3
+ This directory contains built-in skills that extend pretticlaw's capabilities.
4
+
5
+ ## Skill Format
6
+
7
+ Each skill is a directory containing a `SKILL.md` file with:
8
+ - YAML frontmatter (name, description, metadata)
9
+ - Markdown instructions for the agent
10
+
11
+ ## Attribution
12
+
13
+ These skills are adapted from [OpenClaw](https://github.com/openclaw/openclaw)'s skill system.
14
+ The skill format and metadata structure follow OpenClaw's conventions to maintain compatibility.
15
+
16
+ ## Available Skills
17
+
18
+ | Skill | Description |
19
+ |-------|-------------|
20
+ | `github` | Interact with GitHub using the `gh` CLI |
21
+ | `weather` | Get weather info using wttr.in and Open-Meteo |
22
+ | `summarize` | Summarize URLs, files, and YouTube videos |
23
+ | `tmux` | Remote-control tmux sessions |
24
+ | `clawhub` | Search and install skills from ClawHub registry |
25
+ | `skill-creator` | Create new skills |
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: clawhub
3
+ description: Search and install agent skills from ClawHub, the public skill registry.
4
+ homepage: https://clawhub.ai
5
+ metadata: {"pretticlaw":{"emoji":"🦞"}}
6
+ ---
7
+
8
+ # ClawHub
9
+
10
+ Public skill registry for AI agents. Search by natural language (vector search).
11
+
12
+ ## When to use
13
+
14
+ Use this skill when the user asks any of:
15
+ - "find a skill for …"
16
+ - "search for skills"
17
+ - "install a skill"
18
+ - "what skills are available?"
19
+ - "update my skills"
20
+
21
+ ## Search
22
+
23
+ ```bash
24
+ npx --yes clawhub@latest search "web scraping" --limit 5
25
+ ```
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ npx --yes clawhub@latest install <slug> --workdir ~/.pretticlaw/workspace
31
+ ```
32
+
33
+ Replace `<slug>` with the skill name from search results. This places the skill into `~/.pretticlaw/workspace/skills/`, where pretticlaw loads workspace skills from. Always include `--workdir`.
34
+
35
+ ## Update
36
+
37
+ ```bash
38
+ npx --yes clawhub@latest update --all --workdir ~/.pretticlaw/workspace
39
+ ```
40
+
41
+ ## List installed
42
+
43
+ ```bash
44
+ npx --yes clawhub@latest list --workdir ~/.pretticlaw/workspace
45
+ ```
46
+
47
+ ## Notes
48
+
49
+ - Requires Node.js (`npx` comes with it).
50
+ - No API key needed for search and install.
51
+ - Login (`npx --yes clawhub@latest login`) is only required for publishing.
52
+ - `--workdir ~/.pretticlaw/workspace` is critical — without it, skills install to the current directory instead of the pretticlaw workspace.
53
+ - After install, remind the user to start a new session to load the skill.