niahere 0.2.19 → 0.2.21

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.
@@ -1,14 +1,15 @@
1
1
  # Memory
2
2
 
3
- Things I've picked up that I don't want to forget. I maintain this myself.
3
+ Concise things I've picked up that I don't want to forget. I maintain this myself.
4
4
 
5
- Write here when:
6
- - Something surprised me or broke unexpectedly
7
- - {{ownerName}} corrected me or preferred a different approach
8
- - I learned a preference, habit, or pattern worth remembering
9
- - A workaround was needed that future-me should know about
5
+ Rules:
6
+ - One insight per entry, max 200 chars
7
+ - NO raw logs, transcripts, or status dumps
8
+ - NO duplicates check before adding
9
+ - Good: "curator job can hang needs timeout recovery"
10
+ - Bad: pasting nia status output or conversation logs
10
11
 
11
- Entries are grouped by date. Use `add_memory` tool to append, or edit directly.
12
+ Entries are grouped by date. Use `add_memory` tool to append.
12
13
 
13
14
  ---
14
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "description": "A personal AI assistant daemon — scheduled jobs, chat across Telegram and Slack, persona system, and visual identity.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: gh-stamp
3
+ description: "Stamp a GitHub PR with an LGTM approval comment. Use when someone wants to approve, stamp, or give a thumbs-up to a pull request."
4
+ ---
5
+
6
+ # gh-stamp
7
+
8
+ Approve-stamp a GitHub PR by posting a comment.
9
+
10
+ ## Trigger
11
+
12
+ User says "stamp", "stamp it", "stmap", or similar.
13
+
14
+ - Stamp only → use this skill alone.
15
+ - Stamp + review → run this skill first, then the pr-reviewer skill.
16
+ - Review only → use the pr-reviewer skill, not this.
17
+
18
+ ## Steps
19
+
20
+ 1. **Find the PR.** Accept a full URL, `owner/repo#number`, or a bare number if the repo is obvious from context. If unclear, ask and stop.
21
+ 2. **Post the comment.**
22
+ ```sh
23
+ gh pr comment <pr-url-or-number> --body "LGTM, Stamped ✅"
24
+ # or with repo qualifier:
25
+ gh pr comment --repo <owner/repo> <number> --body "LGTM, Stamped ✅"
26
+ ```
27
+ 3. **Confirm to user:** `LGTM, Stamped ✅`
28
+
29
+ Do not touch anything else on the PR.
package/src/cli/index.ts CHANGED
@@ -12,6 +12,7 @@ import { fail } from "../utils/cli";
12
12
  import { jobCommand } from "./job";
13
13
  import { statusCommand } from "./status";
14
14
  import { sendCommand, telegramCommand, slackCommand } from "./channels";
15
+ import { rulesCommand, memoryCommand } from "./self";
15
16
 
16
17
  // Set LOG_LEVEL from config before anything else logs
17
18
  try {
@@ -207,6 +208,16 @@ switch (command) {
207
208
  break;
208
209
  }
209
210
 
211
+ case "rules": {
212
+ rulesCommand();
213
+ break;
214
+ }
215
+
216
+ case "memory": {
217
+ memoryCommand();
218
+ break;
219
+ }
220
+
210
221
  case "history": {
211
222
  const room = process.argv[3];
212
223
  try {
@@ -419,6 +430,8 @@ switch (command) {
419
430
  console.log(" history [room] — recent messages");
420
431
  console.log(" logs [-f] [--channel ch] — daemon logs (filter by channel)");
421
432
  console.log(" job <sub> — manage jobs");
433
+ console.log(" rules [show|reset] — view or reset rules.md");
434
+ console.log(" memory [show|reset] — view or reset memory.md");
422
435
  console.log(" db <sub> — database setup/status/migrate");
423
436
  console.log(" skills — list available skills");
424
437
  console.log(" config <sub> — get/set/list config values");
package/src/cli/job.ts CHANGED
@@ -49,7 +49,7 @@ export async function jobCommand(): Promise<void> {
49
49
  for (const job of jobs) {
50
50
  const tag = job.always ? " always" : "";
51
51
  const type = job.scheduleType !== "cron" ? ` (${job.scheduleType})` : "";
52
- console.log(` ${job.enabled ? "●" : "○"} ${job.name} ${job.schedule}${type}${tag} ${job.prompt.slice(0, 60)}${job.prompt.length > 60 ? "..." : ""}`);
52
+ console.log(` ${job.enabled ? "●" : "○"} ${job.name} ${job.schedule}${type}${tag}`);
53
53
  }
54
54
  }
55
55
  });
@@ -0,0 +1,74 @@
1
+ import { existsSync, readFileSync, copyFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { getPaths } from "../utils/paths";
4
+ import { fail } from "../utils/cli";
5
+
6
+ function selfFilePath(name: "rules" | "memory"): string {
7
+ const { selfDir } = getPaths();
8
+ return join(selfDir, `${name}.md`);
9
+ }
10
+
11
+ function defaultFilePath(name: "rules" | "memory"): string {
12
+ const projectRoot = join(import.meta.dir, "../..");
13
+ return join(projectRoot, "defaults", "self", `${name}.md`);
14
+ }
15
+
16
+ function show(name: "rules" | "memory"): void {
17
+ const path = selfFilePath(name);
18
+ if (!existsSync(path)) {
19
+ console.log(`No ${name}.md found.`);
20
+ return;
21
+ }
22
+ console.log(readFileSync(path, "utf8").trim());
23
+ }
24
+
25
+ function reset(name: "rules" | "memory"): void {
26
+ const path = selfFilePath(name);
27
+ const defaultPath = defaultFilePath(name);
28
+
29
+ if (!existsSync(defaultPath)) {
30
+ fail(`Default ${name}.md template not found.`);
31
+ }
32
+
33
+ if (existsSync(path)) {
34
+ copyFileSync(path, `${path}.bak`);
35
+ console.log(` backed up → ${name}.md.bak`);
36
+ }
37
+
38
+ copyFileSync(defaultPath, path);
39
+ console.log(` ${name}.md reset to default.`);
40
+ }
41
+
42
+ export function rulesCommand(): void {
43
+ const sub = process.argv[3];
44
+ switch (sub) {
45
+ case "show":
46
+ case undefined:
47
+ show("rules");
48
+ break;
49
+ case "reset":
50
+ reset("rules");
51
+ break;
52
+ default:
53
+ console.log("Usage: nia rules <show|reset>");
54
+ console.log(" show — display current rules (default)");
55
+ console.log(" reset — reset to default template (backs up current)");
56
+ }
57
+ }
58
+
59
+ export function memoryCommand(): void {
60
+ const sub = process.argv[3];
61
+ switch (sub) {
62
+ case "show":
63
+ case undefined:
64
+ show("memory");
65
+ break;
66
+ case "reset":
67
+ reset("memory");
68
+ break;
69
+ default:
70
+ console.log("Usage: nia memory <show|reset>");
71
+ console.log(" show — display current memory (default)");
72
+ console.log(" reset — reset to default template (backs up current)");
73
+ }
74
+ }
@@ -1,24 +1,123 @@
1
1
  import { homedir } from "os";
2
2
  import { existsSync } from "fs";
3
+ import { randomUUID } from "crypto";
4
+ import { query } from "@anthropic-ai/claude-agent-sdk";
3
5
  import type { JobInput, JobResult } from "../types";
4
6
  import { appendAudit, readState, writeState } from "../utils/logger";
5
7
  import type { AuditEntry, JobState } from "../types";
6
8
  import { getConfig } from "../utils/config";
7
9
  import { buildSystemPrompt } from "../chat/identity";
8
10
 
9
- // Resolve full path to codex so daemon doesn't depend on PATH
10
- const CODEX_CANDIDATES = ["/opt/homebrew/bin/codex", "/usr/local/bin/codex"];
11
- const codexPath = CODEX_CANDIDATES.find((p) => existsSync(p)) || "codex";
11
+ interface RunnerOutput {
12
+ agentText: string;
13
+ sessionId: string;
14
+ error?: string;
15
+ }
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Codex runner
19
+ // ---------------------------------------------------------------------------
20
+
21
+ function resolveCodexPath(): string {
22
+ const candidates = ["/opt/homebrew/bin/codex", "/usr/local/bin/codex"];
23
+ return candidates.find((p) => existsSync(p)) || "codex";
24
+ }
25
+
26
+ async function runJobWithCodex(fullPrompt: string, cwd: string, model: string): Promise<RunnerOutput> {
27
+ const codexPath = resolveCodexPath();
28
+ const args = [codexPath, "exec", fullPrompt, "-C", cwd, "--json", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox"];
29
+ if (model && model !== "default") {
30
+ args.splice(3, 0, "-m", model);
31
+ }
32
+
33
+ const proc = Bun.spawn(args, {
34
+ stdout: "pipe",
35
+ stderr: "pipe",
36
+ env: { ...process.env },
37
+ });
38
+
39
+ const stdout = await new Response(proc.stdout).text();
40
+ const stderr = await new Response(proc.stderr).text();
41
+ const exitCode = await proc.exited;
42
+
43
+ let agentText = "";
44
+ let sessionId = "";
45
+ for (const line of stdout.split("\n")) {
46
+ if (!line.trim()) continue;
47
+ try {
48
+ const event = JSON.parse(line);
49
+ if (event.type === "thread.started" && event.thread_id) {
50
+ sessionId = event.thread_id;
51
+ }
52
+ if (event.type === "item.completed" && event.item?.type === "agent_message") {
53
+ agentText = event.item.text || "";
54
+ }
55
+ } catch {}
56
+ }
57
+
58
+ if (exitCode !== 0) {
59
+ return { agentText, sessionId, error: stderr.trim() || `exit code ${exitCode}` };
60
+ }
61
+ return { agentText, sessionId };
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Claude Agent SDK runner
66
+ // ---------------------------------------------------------------------------
67
+
68
+ async function runJobWithClaude(systemPrompt: string, jobPrompt: string, cwd: string): Promise<RunnerOutput> {
69
+ const sessionId = randomUUID();
70
+
71
+ // One-shot async iterable: emit a single user message then close
72
+ async function* singleMessage() {
73
+ yield {
74
+ type: "user" as const,
75
+ message: { role: "user" as const, content: jobPrompt },
76
+ parent_tool_use_id: null,
77
+ session_id: "",
78
+ };
79
+ }
80
+
81
+ const handle = query({
82
+ prompt: singleMessage() as any,
83
+ options: {
84
+ systemPrompt,
85
+ cwd,
86
+ permissionMode: "bypassPermissions",
87
+ sessionId,
88
+ } as any,
89
+ });
90
+
91
+ let agentText = "";
92
+ let actualSessionId = sessionId;
12
93
 
94
+ try {
95
+ for await (const message of handle) {
96
+ if (message.type === "system" && (message as any).subtype === "init") {
97
+ actualSessionId = (message as any).session_id || sessionId;
98
+ }
99
+ if (message.type === "result") {
100
+ if (!(message as any).is_error) {
101
+ agentText = (message as any).result || "";
102
+ } else {
103
+ const errors = (message as any).errors;
104
+ return { agentText: "", sessionId: actualSessionId, error: errors?.join(", ") || "unknown error" };
105
+ }
106
+ }
107
+ }
108
+ } finally {
109
+ handle.close();
110
+ }
13
111
 
14
- function buildPrompt(job: JobInput): string {
15
- const systemPrompt = buildSystemPrompt("job");
16
- return `${systemPrompt}\n\n---\n\nJob: ${job.name} (schedule: ${job.schedule})\n\n${job.prompt}`;
112
+ return { agentText, sessionId: actualSessionId };
17
113
  }
18
114
 
115
+ // ---------------------------------------------------------------------------
116
+ // Public API
117
+ // ---------------------------------------------------------------------------
118
+
19
119
  export async function runJob(job: JobInput): Promise<JobResult> {
20
120
  const config = getConfig();
21
- const model = config.model;
22
121
  const timestamp = new Date().toISOString();
23
122
  const startMs = performance.now();
24
123
 
@@ -28,48 +127,29 @@ export async function runJob(job: JobInput): Promise<JobResult> {
28
127
  writeState(state);
29
128
 
30
129
  try {
31
- const fullPrompt = buildPrompt(job);
32
130
  const cwd = homedir();
33
- const args = [codexPath, "exec", fullPrompt, "-C", cwd, "--json", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox"];
34
- if (model && model !== "default") {
35
- args.splice(3, 0, "-m", model);
131
+ let output: RunnerOutput;
132
+
133
+ if (config.runner === "codex") {
134
+ const fullPrompt = `${buildSystemPrompt("job")}\n\n---\n\nJob: ${job.name} (schedule: ${job.schedule})\n\n${job.prompt}`;
135
+ output = await runJobWithCodex(fullPrompt, cwd, config.model);
136
+ } else {
137
+ const systemPrompt = buildSystemPrompt("job");
138
+ const jobPrompt = `Job: ${job.name} (schedule: ${job.schedule})\n\n${job.prompt}`;
139
+ output = await runJobWithClaude(systemPrompt, jobPrompt, cwd);
36
140
  }
37
- const proc = Bun.spawn(args, {
38
- stdout: "pipe",
39
- stderr: "pipe",
40
- env: { ...process.env },
41
- });
42
141
 
43
- const stdout = await new Response(proc.stdout).text();
44
- const stderr = await new Response(proc.stderr).text();
45
- const exitCode = await proc.exited;
46
142
  const duration_ms = Math.round(performance.now() - startMs);
143
+ const ok = !output.error;
47
144
 
48
- // Parse JSONL events for session ID and final agent message
49
- let agentText = "";
50
- let sessionId = "";
51
- for (const line of stdout.split("\n")) {
52
- if (!line.trim()) continue;
53
- try {
54
- const event = JSON.parse(line);
55
- if (event.type === "thread.started" && event.thread_id) {
56
- sessionId = event.thread_id;
57
- }
58
- if (event.type === "item.completed" && event.item?.type === "agent_message") {
59
- agentText = event.item.text || "";
60
- }
61
- } catch {}
62
- }
63
-
64
- const ok = exitCode === 0;
65
145
  const result: JobResult = {
66
146
  job: job.name,
67
147
  timestamp,
68
148
  status: ok ? "ok" : "error",
69
- result: agentText.trim(),
149
+ result: output.agentText.trim(),
70
150
  duration_ms,
71
- session_id: sessionId || undefined,
72
- error: ok ? undefined : stderr.trim() || `exit code ${exitCode}`,
151
+ session_id: output.sessionId || undefined,
152
+ error: output.error,
73
153
  };
74
154
 
75
155
  const auditEntry: AuditEntry = {
package/src/mcp/server.ts CHANGED
@@ -96,9 +96,9 @@ export function createNiaMcpServer() {
96
96
  ),
97
97
  tool(
98
98
  "add_memory",
99
- "Save a factual memory for future reference. Memories are read on demand, not loaded automatically. Use for things learned, preferences discovered, or context worth keeping.",
99
+ "Save a concise factual memory for future reference. Memories are read on demand, not loaded automatically. Use for preferences, corrections, or patterns worth keeping. RULES: Max 200 chars. One insight per entry. NO raw logs, NO conversation transcripts, NO status dumps, NO duplicate observations. Bad: pasting nia status output. Good: 'curator job can get stuck in running state — needs timeout recovery'.",
100
100
  {
101
- entry: z.string().describe("What to remember (e.g. 'Aman prefers short Slack messages in #tech')"),
101
+ entry: z.string().max(300).describe("A single concise insight (max 200 chars, no raw logs or transcripts)"),
102
102
  },
103
103
  async (args) => ({
104
104
  content: [{ type: "text" as const, text: handlers.addMemory(args.entry) }],
package/src/mcp/tools.ts CHANGED
@@ -230,19 +230,39 @@ export function addRule(rule: string): string {
230
230
  }
231
231
 
232
232
  export function addMemory(entry: string): string {
233
+ // Guard: reject raw logs, transcripts, and overly long entries
234
+ const trimmed = entry.trim();
235
+ if (!trimmed) return "Rejected: empty entry.";
236
+ if (trimmed.length > 300) return "Rejected: too long (max 300 chars). Distill to a single concise insight.";
237
+ if (trimmed.includes("[Thread context]") || trimmed.includes("[Current messag")) return "Rejected: no raw conversation transcripts.";
238
+ if (trimmed.split("\n").length > 5) return "Rejected: too many lines. One concise insight per memory.";
239
+
233
240
  const { selfDir } = getPaths();
234
241
  const memoryPath = join(selfDir, "memory.md");
242
+ const existing = existsSync(memoryPath) ? readFileSync(memoryPath, "utf8") : "";
243
+
244
+ // Deduplicate: skip if a substantially similar entry already exists
245
+ const normalized = trimmed.toLowerCase().replace(/[^a-z0-9 ]/g, "");
246
+ const lines = existing.split("\n").filter((l) => l.startsWith("- "));
247
+ for (const line of lines) {
248
+ const norm = line.slice(2).toLowerCase().replace(/[^a-z0-9 ]/g, "");
249
+ // Check if >60% of words overlap
250
+ const newWords = new Set(normalized.split(/\s+/).filter(Boolean));
251
+ const oldWords = new Set(norm.split(/\s+/).filter(Boolean));
252
+ if (newWords.size === 0) continue;
253
+ let overlap = 0;
254
+ for (const w of newWords) { if (oldWords.has(w)) overlap++; }
255
+ if (overlap / newWords.size > 0.6) return "Rejected: similar memory already exists.";
256
+ }
257
+
235
258
  const date = new Date().toISOString().slice(0, 10);
236
259
  const header = `\n## ${date}`;
237
260
 
238
- const existing = existsSync(memoryPath) ? readFileSync(memoryPath, "utf8") : "";
239
261
  if (existing.includes(header)) {
240
- // Append under existing date header
241
- const updated = existing.replace(header, `${header}\n- ${entry}`);
262
+ const updated = existing.replace(header, `${header}\n- ${trimmed}`);
242
263
  writeFileSync(memoryPath, updated, "utf8");
243
264
  } else {
244
- // New date section
245
- appendFileSync(memoryPath, `${header}\n- ${entry}\n`, "utf8");
265
+ appendFileSync(memoryPath, `${header}\n- ${trimmed}\n`, "utf8");
246
266
  }
247
267
  return `Memory saved.`;
248
268
  }
@@ -25,6 +25,7 @@ export interface ChannelsConfig {
25
25
 
26
26
  export interface Config {
27
27
  model: string;
28
+ runner: "claude" | "codex";
28
29
  timezone: string;
29
30
  activeHours: { start: string; end: string };
30
31
  database_url: string;
@@ -10,6 +10,7 @@ const TIME_RE = /^\d{2}:\d{2}$/;
10
10
 
11
11
  const DEFAULTS: Config = {
12
12
  model: "default",
13
+ runner: "claude",
13
14
  timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone,
14
15
  activeHours: { start: "00:00", end: "23:59" },
15
16
  database_url: DEFAULT_DATABASE_URL,
@@ -54,6 +55,9 @@ export function loadConfig(): Config {
54
55
  // Model
55
56
  const model = typeof raw.model === "string" ? raw.model : DEFAULTS.model;
56
57
 
58
+ // Runner — "codex" is opt-in, everything else defaults to "claude"
59
+ const runner: Config["runner"] = raw.runner === "codex" ? "codex" : DEFAULTS.runner;
60
+
57
61
  // Timezone
58
62
  let timezone = DEFAULTS.timezone;
59
63
  if (typeof raw.timezone === "string") {
@@ -140,6 +144,7 @@ export function loadConfig(): Config {
140
144
 
141
145
  return {
142
146
  model,
147
+ runner,
143
148
  timezone,
144
149
  activeHours: { start, end },
145
150
  database_url,