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,95 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { LLMProvider } from "../providers/base.js";
4
+
5
+ const HEARTBEAT_TOOL = [{
6
+ type: "function",
7
+ function: {
8
+ name: "heartbeat",
9
+ description: "Report heartbeat decision after reviewing tasks.",
10
+ parameters: {
11
+ type: "object",
12
+ properties: {
13
+ action: { type: "string", enum: ["skip", "run"] },
14
+ tasks: { type: "string" },
15
+ },
16
+ required: ["action"],
17
+ },
18
+ },
19
+ }];
20
+
21
+ export class HeartbeatService {
22
+ private running = false;
23
+ private timer: NodeJS.Timeout | null = null;
24
+
25
+ constructor(
26
+ private readonly workspace: string,
27
+ private readonly provider: LLMProvider,
28
+ private readonly model: string,
29
+ private readonly onExecute?: (tasks: string) => Promise<string>,
30
+ private readonly onNotify?: (response: string) => Promise<void>,
31
+ private readonly intervalS = 1800,
32
+ private readonly enabled = true,
33
+ ) {}
34
+
35
+ private get heartbeatFile(): string {
36
+ return path.join(this.workspace, "HEARTBEAT.md");
37
+ }
38
+
39
+ private readHeartbeat(): string | null {
40
+ if (!fs.existsSync(this.heartbeatFile)) return null;
41
+ const text = fs.readFileSync(this.heartbeatFile, "utf8");
42
+ return text.trim() ? text : null;
43
+ }
44
+
45
+ private async decide(content: string): Promise<{ action: "skip" | "run"; tasks: string }> {
46
+ const response = await this.provider.chat({
47
+ model: this.model,
48
+ messages: [
49
+ { role: "system", content: "You are a heartbeat agent. Call the heartbeat tool to report your decision." },
50
+ { role: "user", content: `Review the following HEARTBEAT.md and decide whether there are active tasks.\n\n${content}` },
51
+ ],
52
+ tools: HEARTBEAT_TOOL,
53
+ });
54
+ if (!response.toolCalls.length) return { action: "skip", tasks: "" };
55
+ const args = response.toolCalls[0].arguments;
56
+ const action = args.action === "run" ? "run" : "skip";
57
+ const tasks = typeof args.tasks === "string" ? args.tasks : "";
58
+ return { action, tasks };
59
+ }
60
+
61
+ async start(): Promise<void> {
62
+ if (!this.enabled || this.running) return;
63
+ this.running = true;
64
+ this.schedule();
65
+ }
66
+
67
+ stop(): void {
68
+ this.running = false;
69
+ if (this.timer) clearTimeout(this.timer);
70
+ this.timer = null;
71
+ }
72
+
73
+ private schedule(): void {
74
+ if (!this.running) return;
75
+ this.timer = setTimeout(() => void this.tick().finally(() => this.schedule()), this.intervalS * 1000);
76
+ }
77
+
78
+ private async tick(): Promise<void> {
79
+ const content = this.readHeartbeat();
80
+ if (!content) return;
81
+ const { action, tasks } = await this.decide(content);
82
+ if (action !== "run") return;
83
+ if (!this.onExecute) return;
84
+ const response = await this.onExecute(tasks);
85
+ if (response && this.onNotify) await this.onNotify(response);
86
+ }
87
+
88
+ async triggerNow(): Promise<string | null> {
89
+ const content = this.readHeartbeat();
90
+ if (!content) return null;
91
+ const { action, tasks } = await this.decide(content);
92
+ if (action !== "run" || !this.onExecute) return null;
93
+ return this.onExecute(tasks);
94
+ }
95
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
3
+ import { buildProgram } from "./cli/commands.js";
4
+
5
+ const program = buildProgram();
6
+ program.parseAsync(process.argv);
@@ -0,0 +1,43 @@
1
+ export interface ToolCallRequest {
2
+ id: string;
3
+ name: string;
4
+ arguments: Record<string, unknown>;
5
+ }
6
+
7
+ export interface LLMResponse {
8
+ content: string | null;
9
+ toolCalls: ToolCallRequest[];
10
+ finishReason: string;
11
+ usage: Record<string, number>;
12
+ reasoningContent: string | null;
13
+ }
14
+
15
+ export interface LLMProvider {
16
+ chat(input: {
17
+ messages: Array<Record<string, unknown>>;
18
+ tools?: Array<Record<string, unknown>>;
19
+ model?: string;
20
+ maxTokens?: number;
21
+ temperature?: number;
22
+ }): Promise<LLMResponse>;
23
+ getDefaultModel(): string;
24
+ }
25
+
26
+ export function sanitizeEmptyContent(messages: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
27
+ return messages.map((msg) => {
28
+ const content = msg.content;
29
+ if (typeof content === "string" && content.length === 0) {
30
+ if (msg.role === "assistant" && msg.tool_calls) return { ...msg, content: null };
31
+ return { ...msg, content: "(empty)" };
32
+ }
33
+ if (Array.isArray(content)) {
34
+ const filtered = content.filter((item) => !(typeof item === "object" && item && ["text", "input_text", "output_text"].includes((item as any).type) && !(item as any).text));
35
+ if (filtered.length !== content.length) {
36
+ if (filtered.length > 0) return { ...msg, content: filtered };
37
+ if (msg.role === "assistant" && msg.tool_calls) return { ...msg, content: null };
38
+ return { ...msg, content: "(empty)" };
39
+ }
40
+ }
41
+ return msg;
42
+ });
43
+ }
@@ -0,0 +1,46 @@
1
+ import OpenAI from "openai";
2
+ import type { LLMProvider, LLMResponse, ToolCallRequest } from "./base.js";
3
+ import { sanitizeEmptyContent } from "./base.js";
4
+
5
+ export class CustomProvider implements LLMProvider {
6
+ private client: OpenAI;
7
+ constructor(private readonly apiKey: string, private readonly apiBase: string, private readonly defaultModel: string) {
8
+ this.client = new OpenAI({ apiKey, baseURL: apiBase });
9
+ }
10
+
11
+ getDefaultModel(): string {
12
+ return this.defaultModel;
13
+ }
14
+
15
+ async chat(input: { messages: Array<Record<string, unknown>>; tools?: Array<Record<string, unknown>>; model?: string; maxTokens?: number; temperature?: number; }): Promise<LLMResponse> {
16
+ try {
17
+ const res = await this.client.chat.completions.create({
18
+ model: input.model ?? this.defaultModel,
19
+ messages: sanitizeEmptyContent(input.messages) as any,
20
+ tools: input.tools as any,
21
+ tool_choice: input.tools ? "auto" : undefined,
22
+ max_tokens: Math.max(1, input.maxTokens ?? 4096),
23
+ temperature: input.temperature ?? 0.7,
24
+ });
25
+ const choice = res.choices[0];
26
+ const toolCalls: ToolCallRequest[] = (choice.message.tool_calls ?? []).map((tc) => ({
27
+ id: tc.id,
28
+ name: tc.function.name,
29
+ arguments: JSON.parse(tc.function.arguments || "{}"),
30
+ }));
31
+ return {
32
+ content: choice.message.content,
33
+ toolCalls,
34
+ finishReason: choice.finish_reason ?? "stop",
35
+ usage: {
36
+ prompt_tokens: res.usage?.prompt_tokens ?? 0,
37
+ completion_tokens: res.usage?.completion_tokens ?? 0,
38
+ total_tokens: res.usage?.total_tokens ?? 0,
39
+ },
40
+ reasoningContent: null,
41
+ };
42
+ } catch (err) {
43
+ return { content: `Error: ${String(err)}`, toolCalls: [], finishReason: "error", usage: {}, reasoningContent: null };
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,131 @@
1
+ import { nanoid } from "nanoid";
2
+ import type { LLMProvider, LLMResponse, ToolCallRequest } from "./base.js";
3
+ import { sanitizeEmptyContent } from "./base.js";
4
+
5
+ export class LiteLLMProvider implements LLMProvider {
6
+ private static readonly DEFAULT_BASE_BY_PROVIDER: Record<string, string> = {
7
+ openrouter: "https://openrouter.ai/api/v1",
8
+ openai: "https://api.openai.com/v1",
9
+ deepseek: "https://api.deepseek.com/v1",
10
+ groq: "https://api.groq.com/openai/v1",
11
+ moonshot: "https://api.moonshot.ai/v1",
12
+ minimax: "https://api.minimax.io/v1",
13
+ dashscope: "https://dashscope.aliyuncs.com/compatible-mode/v1",
14
+ zhipu: "https://open.bigmodel.cn/api/paas/v4",
15
+ siliconflow: "https://api.siliconflow.cn/v1",
16
+ volcengine: "https://ark.cn-beijing.volces.com/api/v3",
17
+ vllm: "http://localhost:8000/v1",
18
+ custom: "http://localhost:8000/v1",
19
+ };
20
+
21
+ private static readonly UNSUPPORTED_PROVIDERS = new Set(["anthropic", "gemini", "openai_codex", "github_copilot"]);
22
+
23
+ constructor(
24
+ private readonly apiKey: string | null,
25
+ private readonly apiBase: string | null,
26
+ private readonly defaultModel: string,
27
+ private readonly providerName: string | null,
28
+ ) {}
29
+
30
+ getDefaultModel(): string {
31
+ return this.defaultModel;
32
+ }
33
+
34
+ resolveModel(model: string): string {
35
+ const canonicalize = (s: string) => s.toLowerCase().replace(/-/g, "_");
36
+ if (model.includes("/")) {
37
+ const [prefix, rest] = model.split("/", 2);
38
+ if (canonicalize(prefix) === "github_copilot") return `github_copilot/${rest}`;
39
+ if (canonicalize(prefix) === "openai_codex") 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") return `groq/${rest}`;
46
+ return rest;
47
+ }
48
+ }
49
+ return model;
50
+ }
51
+
52
+ async chat(input: { messages: Array<Record<string, unknown>>; tools?: Array<Record<string, unknown>>; model?: string; maxTokens?: number; temperature?: number; }): Promise<LLMResponse> {
53
+ const model = this.resolveModel(input.model ?? this.defaultModel);
54
+ const body: Record<string, unknown> = {
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
+
65
+ try {
66
+ const providerGuess = (this.providerName ?? "").trim();
67
+ if (providerGuess && LiteLLMProvider.UNSUPPORTED_PROVIDERS.has(providerGuess)) {
68
+ return {
69
+ content: `Error calling LLM: provider '${providerGuess}' is not supported in this TypeScript port yet. Use openrouter/openai/deepseek/groq/custom.`,
70
+ toolCalls: [],
71
+ finishReason: "error",
72
+ usage: {},
73
+ reasoningContent: null,
74
+ };
75
+ }
76
+
77
+ const apiBase =
78
+ this.apiBase ??
79
+ (providerGuess ? LiteLLMProvider.DEFAULT_BASE_BY_PROVIDER[providerGuess] : undefined) ??
80
+ (this.apiKey?.startsWith("sk-or-") ? LiteLLMProvider.DEFAULT_BASE_BY_PROVIDER.openrouter : undefined);
81
+
82
+ if (!apiBase) {
83
+ return {
84
+ content: "Error calling LLM: api_base not configured. Set provider/api_base in ~/.pretticlaw/config.json or run pretticlaw onboard.",
85
+ toolCalls: [],
86
+ finishReason: "error",
87
+ usage: {},
88
+ reasoningContent: null,
89
+ };
90
+ }
91
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
92
+ if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
93
+
94
+ const res = await fetch(`${apiBase.replace(/\/$/, "")}/chat/completions`, {
95
+ method: "POST",
96
+ headers,
97
+ body: JSON.stringify(body),
98
+ });
99
+ const json: any = await res.json();
100
+ if (!res.ok) {
101
+ const code = json?.error?.code ?? "";
102
+ const message = json?.error?.message ?? JSON.stringify(json);
103
+ if (code === "model_not_found") {
104
+ const hint = providerGuess === "groq"
105
+ ? "Try a Groq-supported model like llama-3.3-70b-versatile or openai/gpt-oss-120b. You can run `pretticlaw doctor`."
106
+ : "Check your model id and provider access. You can run `pretticlaw doctor`.";
107
+ return { content: `Error calling LLM: ${message}\n${hint}`, toolCalls: [], finishReason: "error", usage: {}, reasoningContent: null };
108
+ }
109
+ return { content: `Error calling LLM: ${message}`, toolCalls: [], finishReason: "error", usage: {}, reasoningContent: null };
110
+ }
111
+ const choice = json.choices?.[0]?.message ? json.choices[0] : null;
112
+ if (!choice) {
113
+ return { content: `Error calling LLM: ${JSON.stringify(json)}`, toolCalls: [], finishReason: "error", usage: {}, reasoningContent: null };
114
+ }
115
+ const toolCalls: ToolCallRequest[] = (choice.message.tool_calls ?? []).map((tc: any) => ({
116
+ id: nanoid(9),
117
+ name: tc.function.name,
118
+ arguments: typeof tc.function.arguments === "string" ? JSON.parse(tc.function.arguments || "{}") : tc.function.arguments,
119
+ }));
120
+ return {
121
+ content: choice.message.content ?? null,
122
+ toolCalls,
123
+ finishReason: choice.finish_reason ?? "stop",
124
+ usage: json.usage ?? {},
125
+ reasoningContent: choice.message.reasoning_content ?? null,
126
+ };
127
+ } catch (err) {
128
+ return { content: `Error calling LLM: ${String(err)}`, toolCalls: [], finishReason: "error", usage: {}, reasoningContent: null };
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,48 @@
1
+ import { PROVIDERS, type Config, getApiBase, getProvider, getProviderName } from "../config/schema.js";
2
+ import type { LLMProvider } from "./base.js";
3
+ import { CustomProvider } from "./custom-provider.js";
4
+ import { LiteLLMProvider } from "./litellm-provider.js";
5
+
6
+ export function findByModel(model: string): (typeof PROVIDERS)[number] | null {
7
+ const lower = model.toLowerCase();
8
+ const norm = lower.replace(/-/g, "_");
9
+ const prefix = lower.includes("/") ? lower.split("/", 1)[0].replace(/-/g, "_") : "";
10
+ const standard = PROVIDERS.filter((p) => !p.isGateway && !p.isLocal);
11
+ for (const spec of standard) {
12
+ if (prefix && prefix === spec.name) return spec;
13
+ }
14
+ for (const spec of standard) {
15
+ if (spec.keywords.some((k) => lower.includes(k) || norm.includes(k.replace(/-/g, "_")))) return spec;
16
+ }
17
+ return null;
18
+ }
19
+
20
+ export function stripModelPrefix(model: string): string {
21
+ if (model.startsWith("openai-codex/")) return model.slice("openai-codex/".length);
22
+ if (model.startsWith("openai_codex/")) return model.slice("openai_codex/".length);
23
+ return model;
24
+ }
25
+
26
+ export function makeProvider(config: Config): LLMProvider {
27
+ const model = config.agents.defaults.model;
28
+ const providerName = getProviderName(config, model);
29
+ const p = getProvider(config, model);
30
+
31
+ const oauthProviders = new Set(["openai_codex", "github_copilot"]);
32
+ const unsupportedInTs = new Set(["anthropic", "gemini"]);
33
+ if (!providerName) {
34
+ throw new Error("No provider could be resolved from config. Run `pretticlaw onboard` and set provider/model/API key.");
35
+ }
36
+ if (unsupportedInTs.has(providerName)) {
37
+ throw new Error(`Provider '${providerName}' is not supported in this TypeScript port yet. Use openrouter/openai/deepseek/groq/custom.`);
38
+ }
39
+ if (!oauthProviders.has(providerName) && providerName !== "vllm" && providerName !== "custom" && !(p?.apiKey || "").trim()) {
40
+ throw new Error(`No API key configured for provider '${providerName}'. Run 'pretticlaw onboard' or edit ~/.pretticlaw/config.json.`);
41
+ }
42
+
43
+ if (providerName === "custom") {
44
+ return new CustomProvider(p?.apiKey || "no-key", getApiBase(config, model) || "http://localhost:8000/v1", model);
45
+ }
46
+
47
+ return new LiteLLMProvider(p?.apiKey ?? null, getApiBase(config, model), model, providerName);
48
+ }
@@ -0,0 +1,129 @@
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
+
6
+ export interface SessionMessage {
7
+ role: string;
8
+ content: unknown;
9
+ timestamp?: string;
10
+ tool_calls?: unknown;
11
+ tool_call_id?: string;
12
+ name?: string;
13
+ [k: string]: unknown;
14
+ }
15
+
16
+ export class Session {
17
+ key: string;
18
+ messages: SessionMessage[] = [];
19
+ createdAt: string = nowIso();
20
+ updatedAt: string = nowIso();
21
+ metadata: Record<string, unknown> = {};
22
+ lastConsolidated = 0;
23
+
24
+ constructor(key: string) {
25
+ this.key = key;
26
+ }
27
+
28
+ getHistory(maxMessages = 500): Array<Record<string, unknown>> {
29
+ const unconsolidated = this.messages.slice(this.lastConsolidated);
30
+ let sliced = unconsolidated.slice(-maxMessages);
31
+ const firstUser = sliced.findIndex((m) => m.role === "user");
32
+ if (firstUser > 0) sliced = sliced.slice(firstUser);
33
+ return sliced.map((m) => {
34
+ const entry: Record<string, unknown> = { role: m.role, content: m.content ?? "" };
35
+ for (const k of ["tool_calls", "tool_call_id", "name"]) {
36
+ if (k in m) entry[k] = (m as any)[k];
37
+ }
38
+ return entry;
39
+ });
40
+ }
41
+
42
+ clear(): void {
43
+ this.messages = [];
44
+ this.lastConsolidated = 0;
45
+ this.updatedAt = nowIso();
46
+ }
47
+ }
48
+
49
+ export class SessionManager {
50
+ private sessionsDir: string;
51
+ private cache = new Map<string, Session>();
52
+
53
+ constructor(workspace: string) {
54
+ this.sessionsDir = ensureDir(path.join(workspace, "sessions"));
55
+ }
56
+
57
+ private getSessionPath(key: string): string {
58
+ return path.join(this.sessionsDir, `${safeFilename(key.replace(":", "_"))}.jsonl`);
59
+ }
60
+
61
+ getOrCreate(key: string): Session {
62
+ const cached = this.cache.get(key);
63
+ if (cached) return cached;
64
+ const loaded = this.load(key) ?? new Session(key);
65
+ this.cache.set(key, loaded);
66
+ return loaded;
67
+ }
68
+
69
+ save(session: Session): void {
70
+ const p = this.getSessionPath(session.key);
71
+ const meta = {
72
+ _type: "metadata",
73
+ key: session.key,
74
+ created_at: session.createdAt,
75
+ updated_at: session.updatedAt,
76
+ metadata: session.metadata,
77
+ last_consolidated: session.lastConsolidated,
78
+ };
79
+ const lines = [JSON.stringify(meta), ...session.messages.map((m) => JSON.stringify(m))];
80
+ fs.writeFileSync(p, `${lines.join("\n")}\n`, "utf8");
81
+ this.cache.set(session.key, session);
82
+ }
83
+
84
+ invalidate(key: string): void {
85
+ this.cache.delete(key);
86
+ }
87
+
88
+ listSessions(): Array<Record<string, string>> {
89
+ if (!fs.existsSync(this.sessionsDir)) return [];
90
+ const out: Array<Record<string, string>> = [];
91
+ for (const file of fs.readdirSync(this.sessionsDir)) {
92
+ if (!file.endsWith(".jsonl")) continue;
93
+ const p = path.join(this.sessionsDir, file);
94
+ try {
95
+ const first = fs.readFileSync(p, "utf8").split(/\r?\n/, 1)[0];
96
+ const meta = JSON.parse(first);
97
+ if (meta._type === "metadata") {
98
+ out.push({ key: meta.key ?? file.replace(".jsonl", ""), created_at: meta.created_at, updated_at: meta.updated_at, path: p });
99
+ }
100
+ } catch {
101
+ // ignore
102
+ }
103
+ }
104
+ return out.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""));
105
+ }
106
+
107
+ private load(key: string): Session | null {
108
+ const p = this.getSessionPath(key);
109
+ if (!fs.existsSync(p)) return null;
110
+ try {
111
+ const lines = fs.readFileSync(p, "utf8").split(/\r?\n/).filter(Boolean);
112
+ const session = new Session(key);
113
+ for (const line of lines) {
114
+ const obj = JSON.parse(line);
115
+ if (obj._type === "metadata") {
116
+ session.createdAt = obj.created_at ?? session.createdAt;
117
+ session.updatedAt = obj.updated_at ?? session.updatedAt;
118
+ session.metadata = obj.metadata ?? {};
119
+ session.lastConsolidated = obj.last_consolidated ?? 0;
120
+ } else {
121
+ session.messages.push(obj);
122
+ }
123
+ }
124
+ return session;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+ }
@@ -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.
@@ -0,0 +1,57 @@
1
+ ---
2
+ name: cron
3
+ description: Schedule reminders and recurring tasks.
4
+ ---
5
+
6
+ # Cron
7
+
8
+ Use the `cron` tool to schedule reminders or recurring tasks.
9
+
10
+ ## Three Modes
11
+
12
+ 1. **Reminder** - message is sent directly to user
13
+ 2. **Task** - message is a task description, agent executes and sends result
14
+ 3. **One-time** - runs once at a specific time, then auto-deletes
15
+
16
+ ## Examples
17
+
18
+ Fixed reminder:
19
+ ```
20
+ cron(action="add", message="Time to take a break!", every_seconds=1200)
21
+ ```
22
+
23
+ Dynamic task (agent executes each time):
24
+ ```
25
+ cron(action="add", message="Check HKUDS/pretticlaw GitHub stars and report", every_seconds=600)
26
+ ```
27
+
28
+ One-time scheduled task (compute ISO datetime from current time):
29
+ ```
30
+ cron(action="add", message="Remind me about the meeting", at="<ISO datetime>")
31
+ ```
32
+
33
+ Timezone-aware cron:
34
+ ```
35
+ cron(action="add", message="Morning standup", cron_expr="0 9 * * 1-5", tz="America/Vancouver")
36
+ ```
37
+
38
+ List/remove:
39
+ ```
40
+ cron(action="list")
41
+ cron(action="remove", job_id="abc123")
42
+ ```
43
+
44
+ ## Time Expressions
45
+
46
+ | User says | Parameters |
47
+ |-----------|------------|
48
+ | every 20 minutes | every_seconds: 1200 |
49
+ | every hour | every_seconds: 3600 |
50
+ | every day at 8am | cron_expr: "0 8 * * *" |
51
+ | weekdays at 5pm | cron_expr: "0 17 * * 1-5" |
52
+ | 9am Vancouver time daily | cron_expr: "0 9 * * *", tz: "America/Vancouver" |
53
+ | at a specific time | at: ISO datetime string (compute from current time) |
54
+
55
+ ## Timezone
56
+
57
+ Use `tz` with `cron_expr` to schedule in a specific IANA timezone. Without `tz`, the server's local timezone is used.
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: github
3
+ description: "Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries."
4
+ metadata: {"pretticlaw":{"emoji":"🐙","requires":{"bins":["gh"]},"install":[{"id":"brew","kind":"brew","formula":"gh","bins":["gh"],"label":"Install GitHub CLI (brew)"},{"id":"apt","kind":"apt","package":"gh","bins":["gh"],"label":"Install GitHub CLI (apt)"}]}}
5
+ ---
6
+
7
+ # GitHub Skill
8
+
9
+ Use the `gh` CLI to interact with GitHub. Always specify `--repo owner/repo` when not in a git directory, or use URLs directly.
10
+
11
+ ## Pull Requests
12
+
13
+ Check CI status on a PR:
14
+ ```bash
15
+ gh pr checks 55 --repo owner/repo
16
+ ```
17
+
18
+ List recent workflow runs:
19
+ ```bash
20
+ gh run list --repo owner/repo --limit 10
21
+ ```
22
+
23
+ View a run and see which steps failed:
24
+ ```bash
25
+ gh run view <run-id> --repo owner/repo
26
+ ```
27
+
28
+ View logs for failed steps only:
29
+ ```bash
30
+ gh run view <run-id> --repo owner/repo --log-failed
31
+ ```
32
+
33
+ ## API for Advanced Queries
34
+
35
+ The `gh api` command is useful for accessing data not available through other subcommands.
36
+
37
+ Get PR with specific fields:
38
+ ```bash
39
+ gh api repos/owner/repo/pulls/55 --jq '.title, .state, .user.login'
40
+ ```
41
+
42
+ ## JSON Output
43
+
44
+ Most commands support `--json` for structured output. You can use `--jq` to filter:
45
+
46
+ ```bash
47
+ gh issue list --repo owner/repo --json number,title --jq '.[] | "\(.number): \(.title)"'
48
+ ```