@oyasmi/pipiclaw 0.6.0 → 0.6.2

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.
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, watch, writeF
3
3
  import { readFile } from "fs/promises";
4
4
  import { join } from "path";
5
5
  import * as log from "../log.js";
6
+ import { guardCommand } from "../security/command-guard.js";
6
7
  // ============================================================================
7
8
  // EventsWatcher
8
9
  // ============================================================================
@@ -11,9 +12,11 @@ const MAX_RETRIES = 3;
11
12
  const RETRY_BASE_MS = 100;
12
13
  const MAX_TIMEOUT_MS = 2_147_483_647;
13
14
  export class EventsWatcher {
14
- constructor(eventsDir, bot) {
15
+ constructor(eventsDir, bot, executor, commandGuardConfig) {
15
16
  this.eventsDir = eventsDir;
16
17
  this.bot = bot;
18
+ this.executor = executor;
19
+ this.commandGuardConfig = commandGuardConfig;
17
20
  this.timers = new Map();
18
21
  this.crons = new Map();
19
22
  this.debounceTimers = new Map();
@@ -145,19 +148,39 @@ export class EventsWatcher {
145
148
  break;
146
149
  }
147
150
  }
151
+ parsePreAction(data, filename) {
152
+ if (!data.preAction)
153
+ return undefined;
154
+ if (typeof data.preAction !== "object" || data.preAction === null) {
155
+ throw new Error(`Invalid 'preAction' field in ${filename}, expected an object`);
156
+ }
157
+ const action = data.preAction;
158
+ if (action.type !== "bash") {
159
+ throw new Error(`Unsupported preAction type '${String(action.type)}' in ${filename}, only 'bash' is supported`);
160
+ }
161
+ if (typeof action.command !== "string" || action.command.trim().length === 0) {
162
+ throw new Error(`Missing or empty 'preAction.command' in ${filename}`);
163
+ }
164
+ return {
165
+ type: "bash",
166
+ command: action.command,
167
+ ...(typeof action.timeout === "number" ? { timeout: action.timeout } : {}),
168
+ };
169
+ }
148
170
  parseEvent(content, filename) {
149
171
  const data = JSON.parse(content);
150
172
  if (!data.type || !data.channelId || !data.text) {
151
173
  throw new Error(`Missing required fields (type, channelId, text) in ${filename}`);
152
174
  }
175
+ const preAction = this.parsePreAction(data, filename);
153
176
  switch (data.type) {
154
177
  case "immediate":
155
- return { type: "immediate", channelId: data.channelId, text: data.text };
178
+ return { type: "immediate", channelId: data.channelId, text: data.text, preAction };
156
179
  case "one-shot":
157
180
  if (!data.at) {
158
181
  throw new Error(`Missing 'at' field for one-shot event in ${filename}`);
159
182
  }
160
- return { type: "one-shot", channelId: data.channelId, text: data.text, at: data.at };
183
+ return { type: "one-shot", channelId: data.channelId, text: data.text, at: data.at, preAction };
161
184
  case "periodic":
162
185
  if (!data.schedule) {
163
186
  throw new Error(`Missing 'schedule' field for periodic event in ${filename}`);
@@ -171,12 +194,13 @@ export class EventsWatcher {
171
194
  text: data.text,
172
195
  schedule: data.schedule,
173
196
  timezone: data.timezone,
197
+ preAction,
174
198
  };
175
199
  default:
176
200
  throw new Error(`Unknown event type '${data.type}' in ${filename}`);
177
201
  }
178
202
  }
179
- handleImmediate(filename, event) {
203
+ async handleImmediate(filename, event) {
180
204
  const filePath = join(this.eventsDir, filename);
181
205
  try {
182
206
  const stat = statSync(filePath);
@@ -190,7 +214,7 @@ export class EventsWatcher {
190
214
  return;
191
215
  }
192
216
  log.logInfo(`Executing immediate event: ${filename}`);
193
- this.execute(filename, event);
217
+ await this.execute(filename, event);
194
218
  }
195
219
  handleOneShot(filename, event) {
196
220
  const atTime = new Date(event.at).getTime();
@@ -212,18 +236,28 @@ export class EventsWatcher {
212
236
  return;
213
237
  }
214
238
  log.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);
215
- const timer = setTimeout(() => {
239
+ const timer = setTimeout(async () => {
216
240
  this.timers.delete(filename);
217
- log.logInfo(`Executing one-shot event: ${filename}`);
218
- this.execute(filename, event);
241
+ try {
242
+ log.logInfo(`Executing one-shot event: ${filename}`);
243
+ await this.execute(filename, event);
244
+ }
245
+ catch (err) {
246
+ log.logWarning(`One-shot event execution failed: ${filename}`, String(err));
247
+ }
219
248
  }, delay);
220
249
  this.timers.set(filename, timer);
221
250
  }
222
251
  handlePeriodic(filename, event) {
223
252
  try {
224
- const cron = new Cron(event.schedule, { timezone: event.timezone }, () => {
225
- log.logInfo(`Executing periodic event: ${filename}`);
226
- this.execute(filename, event, false);
253
+ const cron = new Cron(event.schedule, { timezone: event.timezone }, async () => {
254
+ try {
255
+ log.logInfo(`Executing periodic event: ${filename}`);
256
+ await this.execute(filename, event, false);
257
+ }
258
+ catch (err) {
259
+ log.logWarning(`Periodic event execution failed: ${filename}`, String(err));
260
+ }
227
261
  });
228
262
  this.crons.set(filename, cron);
229
263
  const next = cron.nextRun();
@@ -234,7 +268,17 @@ export class EventsWatcher {
234
268
  this.markInvalid(filename, `Invalid cron schedule: ${event.schedule}\n${String(err)}`);
235
269
  }
236
270
  }
237
- execute(filename, event, deleteAfter = true) {
271
+ async execute(filename, event, deleteAfter = true) {
272
+ if (event.preAction) {
273
+ try {
274
+ await this.runPreAction(event.preAction, filename);
275
+ }
276
+ catch (err) {
277
+ const reason = err instanceof Error ? err.message : String(err);
278
+ log.logInfo(`Pre-action gate blocked event: ${filename} (${reason})`);
279
+ return;
280
+ }
281
+ }
238
282
  let scheduleInfo;
239
283
  switch (event.type) {
240
284
  case "immediate":
@@ -270,6 +314,21 @@ export class EventsWatcher {
270
314
  }
271
315
  }
272
316
  }
317
+ async runPreAction(action, filename) {
318
+ if (this.commandGuardConfig?.enabled) {
319
+ const guardResult = guardCommand(action.command, this.commandGuardConfig);
320
+ if (!guardResult.allowed) {
321
+ log.logWarning(`Pre-action command blocked by guard for ${filename}: ${guardResult.reason}`);
322
+ throw new Error(`guard: ${guardResult.reason}`);
323
+ }
324
+ }
325
+ const timeoutMs = action.timeout ?? 10_000;
326
+ const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
327
+ const result = await this.executor.exec(action.command, { timeout: timeoutSeconds });
328
+ if (result.code !== 0) {
329
+ throw new Error(`exit ${result.code}`);
330
+ }
331
+ }
273
332
  deleteFile(filename) {
274
333
  const filePath = join(this.eventsDir, filename);
275
334
  try {
@@ -313,7 +372,7 @@ export class EventsWatcher {
313
372
  /**
314
373
  * Create and start an events watcher.
315
374
  */
316
- export function createEventsWatcher(workspaceDir, bot) {
375
+ export function createEventsWatcher(workspaceDir, bot, executor, commandGuardConfig) {
317
376
  const eventsDir = join(workspaceDir, "events");
318
- return new EventsWatcher(eventsDir, bot);
377
+ return new EventsWatcher(eventsDir, bot, executor, commandGuardConfig);
319
378
  }
package/dist/settings.js CHANGED
@@ -194,13 +194,13 @@ export class PipiclawSettingsManager {
194
194
  }
195
195
  // Compaction details
196
196
  getCompactionReserveTokens() {
197
- return DEFAULT_COMPACTION.reserveTokens;
197
+ return this.getCompactionSettings().reserveTokens;
198
198
  }
199
199
  getCompactionKeepRecentTokens() {
200
- return DEFAULT_COMPACTION.keepRecentTokens;
200
+ return this.getCompactionSettings().keepRecentTokens;
201
201
  }
202
202
  getBranchSummarySettings() {
203
- return { reserveTokens: 16384 };
203
+ return { reserveTokens: this.getCompactionSettings().reserveTokens };
204
204
  }
205
205
  getBranchSummarySkipPrompt() {
206
206
  return false;
@@ -1,5 +1,6 @@
1
1
  import { type AgentEvent, type AgentMessage, type AgentTool } from "@mariozechner/pi-agent-core";
2
2
  import type { Api, Model } from "@mariozechner/pi-ai";
3
+ import type { MemoryCandidateStore } from "../memory/candidates.js";
3
4
  import type { Executor } from "../sandbox.js";
4
5
  import type { SecurityConfig } from "../security/types.js";
5
6
  import type { PipiclawMemoryRecallSettings } from "../settings.js";
@@ -44,6 +45,7 @@ export interface SubAgentToolOptions {
44
45
  channelDir: string;
45
46
  getSubAgentDiscovery?: () => SubAgentDiscoveryResult;
46
47
  getMemoryRecallSettings?: () => PipiclawMemoryRecallSettings;
48
+ memoryCandidateStore?: MemoryCandidateStore;
47
49
  securityConfig?: SecurityConfig;
48
50
  webConfig?: PipiclawWebToolsConfig;
49
51
  runtimeContext: {
@@ -1,7 +1,6 @@
1
1
  import { Agent } from "@mariozechner/pi-agent-core";
2
2
  import { convertToLlm } from "@mariozechner/pi-coding-agent";
3
3
  import { Type } from "@sinclair/typebox";
4
- import { createMemoryCandidateCache } from "../memory/candidates.js";
5
4
  import { readChannelSession } from "../memory/files.js";
6
5
  import { recallRelevantMemory } from "../memory/recall.js";
7
6
  import { formatModelReference } from "../models/utils.js";
@@ -187,7 +186,7 @@ function stripRuntimeContextWrapper(renderedText) {
187
186
  .replace(/\s*<\/runtime_context>$/i, "")
188
187
  .trim();
189
188
  }
190
- async function buildContextualBlocks(task, config, options, currentModel, candidateCache = createMemoryCandidateCache()) {
189
+ async function buildContextualBlocks(task, config, options, currentModel) {
191
190
  if (config.contextMode !== "contextual") {
192
191
  return [];
193
192
  }
@@ -226,7 +225,7 @@ async function buildContextualBlocks(task, config, options, currentModel, candid
226
225
  model: currentModel,
227
226
  resolveApiKey: options.resolveApiKey,
228
227
  allowedSources: ["workspace-memory", "channel-memory", "channel-history"],
229
- candidateCache,
228
+ candidateStore: options.memoryCandidateStore,
230
229
  });
231
230
  const recalledText = stripRuntimeContextWrapper(recalled.renderedText);
232
231
  if (recalledText) {
@@ -1,5 +1,6 @@
1
1
  import type { AgentTool } from "@mariozechner/pi-agent-core";
2
2
  import type { Api, Model } from "@mariozechner/pi-ai";
3
+ import type { MemoryCandidateStore } from "../memory/candidates.js";
3
4
  import type { Executor, SandboxConfig } from "../sandbox.js";
4
5
  import type { SecurityConfig, SecurityRuntimeContext } from "../security/types.js";
5
6
  import type { PipiclawMemoryRecallSettings } from "../settings.js";
@@ -17,6 +18,7 @@ export interface CreatePipiclawToolsOptions {
17
18
  sandboxConfig: SandboxConfig;
18
19
  getSubAgentDiscovery: () => SubAgentDiscoveryResult;
19
20
  getMemoryRecallSettings: () => PipiclawMemoryRecallSettings;
21
+ memoryCandidateStore: MemoryCandidateStore;
20
22
  securityConfig?: SecurityConfig;
21
23
  toolsConfig?: PipiclawToolsConfig;
22
24
  }
@@ -65,6 +65,7 @@ export function createPipiclawTools(options) {
65
65
  channelDir: options.channelDir,
66
66
  getSubAgentDiscovery: options.getSubAgentDiscovery,
67
67
  getMemoryRecallSettings: options.getMemoryRecallSettings,
68
+ memoryCandidateStore: options.memoryCandidateStore,
68
69
  securityConfig,
69
70
  webConfig: toolsConfig.tools.web,
70
71
  runtimeContext: {
@@ -28,6 +28,12 @@ const readSchema = Type.Object({
28
28
  offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
29
29
  limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
30
30
  });
31
+ function countTextLines(content) {
32
+ if (content.length === 0) {
33
+ return 0;
34
+ }
35
+ return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
36
+ }
31
37
  function formatPathBlockMessage(resolvedPath, category, reason) {
32
38
  const lines = [`Path blocked${category ? ` [${category}]` : ""}`];
33
39
  if (reason) {
@@ -84,16 +90,17 @@ export function createReadTool(executor, options = {}) {
84
90
  };
85
91
  }
86
92
  // Get total line count first
87
- const countResult = await executor.exec(`wc -l < ${shellEscapePath(path)}`, { signal });
93
+ const countResult = await executor.exec(`awk 'END { print NR }' ${shellEscapePath(path)}`, { signal });
88
94
  if (countResult.code !== 0) {
89
95
  throw new Error(countResult.stderr || `Failed to read file: ${path}`);
90
96
  }
91
- const totalFileLines = Number.parseInt(countResult.stdout.trim(), 10) + 1; // wc -l counts newlines, not lines
97
+ const totalFileLines = Number.parseInt(countResult.stdout.trim(), 10);
92
98
  // Apply offset if specified (1-indexed)
93
99
  const startLine = offset ? Math.max(1, offset) : 1;
94
100
  const startLineDisplay = startLine;
95
101
  // Check if offset is out of bounds
96
- if (startLine > totalFileLines) {
102
+ if ((totalFileLines === 0 && offset !== undefined && startLine > 1) ||
103
+ (totalFileLines > 0 && startLine > totalFileLines)) {
97
104
  throw new Error(`Offset ${offset} is beyond end of file (${totalFileLines} lines total)`);
98
105
  }
99
106
  // Read content with offset
@@ -113,7 +120,7 @@ export function createReadTool(executor, options = {}) {
113
120
  // Apply user limit if specified
114
121
  if (limit !== undefined) {
115
122
  const lines = selectedContent.split("\n");
116
- const endLine = Math.min(limit, lines.length);
123
+ const endLine = Math.min(limit, countTextLines(selectedContent));
117
124
  selectedContent = lines.slice(0, endLine).join("\n");
118
125
  userLimitedLines = endLine;
119
126
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oyasmi/pipiclaw",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "An AI assistant runtime for coding and team workflows, with DingTalk AI Cards, sub-agents, memory, and scheduled events.",
5
5
  "type": "module",
6
6
  "bin": {