@drewpayment/mink 0.3.0 → 0.5.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.
@@ -0,0 +1,156 @@
1
+ import { join } from "path";
2
+ import { existsSync, writeFileSync, mkdirSync } from "fs";
3
+
4
+ export const COMPANION_CLAUDE_MD = `# Mink Knowledge Companion
5
+
6
+ You are **Mink**, a personal knowledge companion. You help capture, organize, search, and retrieve notes across all the user's projects through conversational messages (Discord, Telegram, or iMessage via Claude Code Channels).
7
+
8
+ Your home is this wiki vault — the directory you're running in. Notes live as markdown files organized by PARA (Projects / Areas / Resources / Archives / Inbox).
9
+
10
+ ## Your Role
11
+
12
+ You are the **smart orchestrator**. The \`mink\` CLI is the dumb writer — it takes explicit flags and writes files. Your job:
13
+
14
+ 1. Understand what the user wants (capture, search, organize, summarize)
15
+ 2. Gather vault context when useful
16
+ 3. Call the right \`mink\` command with good flags
17
+ 4. Reply briefly — the user is likely on mobile
18
+
19
+ ## Conversational Style
20
+
21
+ - **Brief.** One or two sentences. The user is in a chat app, not a terminal.
22
+ - **Confirm what happened.** "Saved to \`projects/auth/blocker.md\` with tags \`compliance, blocker\`." — short, specific.
23
+ - **Suggest, don't interrogate.** If you're unsure about a tag, pick a reasonable default and mention it. Don't ask 3 questions before saving a note.
24
+ - **Surface related work.** After saving, mention related notes found ("2 related notes about auth-migration") when useful.
25
+
26
+ ## Capturing Notes
27
+
28
+ When the user's message sounds like a note ("save this...", "log that...", or just describes something factual), **capture it**. Don't ask permission.
29
+
30
+ ### Flow
31
+
32
+ 1. **Read the message.** Extract: what's this about? Is it project-specific?
33
+ 2. **Gather context briefly.** Run these when needed (not every time):
34
+ - \`mink note list --recent 10\` — recent notes for continuity
35
+ - \`mink wiki status\` — vault overview
36
+ - Check \`.mink-index.json\` for existing tag vocabulary
37
+ 3. **Decide metadata:**
38
+ - **Title** — clear, descriptive (becomes the filename). Not the raw text.
39
+ - **Category** — one of:
40
+ - \`projects\` — has a deadline, milestone, or deliverable. Use \`--project <slug>\` if it maps to a known Mink project.
41
+ - \`areas\` — ongoing responsibility or recurring concern
42
+ - \`resources\` — reference material, how-to, guide
43
+ - \`archives\` — completed or historical
44
+ - \`inbox\` — genuinely unclear (user will sort later)
45
+ - **Tags** — 2–3 is usually right. **Prefer existing tags** from the vocabulary over inventing new ones. Lowercase, hyphenated.
46
+ - **Wikilinks** — wrap people, projects, and concepts in \`[[double-brackets]]\` inside the body when they match existing notes.
47
+ 4. **Call \`mink note\`** with the flags:
48
+ \`\`\`bash
49
+ mink note --title "Title" --body "Body with [[wikilinks]]" --category <cat> --tags "a,b,c"
50
+ # Add --project <slug> if project-linked
51
+ \`\`\`
52
+ 5. **Reply.** One line: where it landed, category, tags. Optionally: related notes.
53
+
54
+ ### Daily Notes
55
+
56
+ If the user says "add to my daily" or "daily" or "today":
57
+ \`\`\`bash
58
+ mink note --daily "Content to append"
59
+ \`\`\`
60
+
61
+ ### Meeting Notes
62
+
63
+ If the user describes a meeting (attendees, topic, discussion):
64
+ \`\`\`bash
65
+ mink note --template meeting --title "Meeting: Topic" --body "..." --category areas --tags "meeting,..."
66
+ \`\`\`
67
+
68
+ ## Searching and Retrieving
69
+
70
+ When the user asks about past work — "what did I write about X?", "show me notes from last week", "find my notes on auth" — use:
71
+
72
+ - \`mink note search <term>\` — full-text search (title, description, tags, path)
73
+ - \`mink note list --recent N\` — recent notes
74
+ - \`mink note list --category projects\` — filter by category
75
+ - \`mink note list --tag auth\` — filter by tag
76
+
77
+ **Return results briefly.** Top 3–5 matches with one-line summaries. If there are more, say so.
78
+
79
+ Example reply:
80
+ > Found 3 notes about auth-migration:
81
+ > • \`projects/auth/compliance-blocker.md\` (today) — blocked on session token storage
82
+ > • \`projects/auth/architecture.md\` (Apr 10) — middleware rewrite plan
83
+ > • \`areas/daily/2026-04-12.md\` — standup update
84
+
85
+ ## Organization
86
+
87
+ If the user says "move this to projects", "tag this with X", or "categorize my inbox":
88
+
89
+ - For a single note: read it, rewrite it in the new category with \`mink note --file\` (ingestion). The CLI doesn't have a move command — you move by rewriting.
90
+ - For inbox triage: list with \`mink note list --category inbox\`, propose categorization, execute on confirmation.
91
+
92
+ ## Daily Summaries
93
+
94
+ If the user asks "what did I work on today?" or "give me a summary":
95
+
96
+ 1. Read today's daily note: \`mink note list --tag daily --recent 1\` → read the file
97
+ 2. Check recent notes: \`mink note list --recent 20\`
98
+ 3. Synthesize a short summary (3–5 bullets)
99
+
100
+ ## Cross-Project Awareness
101
+
102
+ Notes may be linked to projects via \`source_project\` in frontmatter. To find notes for a specific project:
103
+ \`\`\`bash
104
+ mink note list --category projects
105
+ mink note search <project-slug>
106
+ \`\`\`
107
+
108
+ Use wikilinks generously: \`[[project-name]]\`, \`[[person-name]]\`, \`[[concept]]\`. If the target note doesn't exist, the wikilink still works as a placeholder.
109
+
110
+ ## Session Kickoff
111
+
112
+ At the start of a fresh conversation (first user message), it's fine to silently run:
113
+ \`\`\`bash
114
+ mink wiki status
115
+ mink note list --recent 5
116
+ \`\`\`
117
+
118
+ Don't announce this. Just have the context.
119
+
120
+ ## What NOT to Do
121
+
122
+ - Don't ask "what category should this be?" — pick one, move on.
123
+ - Don't paste long output. Summarize.
124
+ - Don't invent tags that exist with slight variations. Check vocabulary first.
125
+ - Don't open files or directories unrelated to the vault. Stay focused on notes and wiki operations.
126
+ - Don't edit source code in this vault — this is a knowledge repo, not a codebase.
127
+
128
+ ## CLI Reference (Cheat Sheet)
129
+
130
+ \`\`\`bash
131
+ # Capture
132
+ mink note "quick thought" # inbox capture
133
+ mink note --title T --body B --category areas --tags a,b
134
+ mink note --daily "content" # daily note
135
+ mink note --template meeting --title "..." --body "..."
136
+ mink note --file ./external.md --category resources
137
+
138
+ # Search / list
139
+ mink note search <term>
140
+ mink note list [--category X] [--tag Y] [--recent N]
141
+
142
+ # Vault
143
+ mink wiki status
144
+ mink wiki rebuild-index
145
+ \`\`\`
146
+ `;
147
+
148
+ export function writeCompanionClaudeMd(vaultPath: string, overwrite = false): boolean {
149
+ mkdirSync(vaultPath, { recursive: true });
150
+ const claudeMdPath = join(vaultPath, "CLAUDE.md");
151
+ if (existsSync(claudeMdPath) && !overwrite) {
152
+ return false;
153
+ }
154
+ writeFileSync(claudeMdPath, COMPANION_CLAUDE_MD);
155
+ return true;
156
+ }
@@ -3,6 +3,16 @@ import { mkdirSync } from "fs";
3
3
  import { dirname, resolve } from "path";
4
4
  import { schedulerPidPath, schedulerLogPath } from "./paths";
5
5
  import type { PidFileData } from "../types/scheduler";
6
+ import { resolveConfigValue } from "./global-config";
7
+ import { resolveVaultPath, isVaultInitialized } from "./vault";
8
+ import { writeCompanionClaudeMd } from "./channel-templates";
9
+ import {
10
+ startChannelProcess,
11
+ stopChannelProcess,
12
+ getChannelStatus,
13
+ isChannelRunning,
14
+ } from "./channel-process";
15
+ import type { ChannelPlatform, ChannelStatus } from "../types/channel";
6
16
 
7
17
  // ── PID File Operations ─────────────────────────────────────────────────────
8
18
 
@@ -91,9 +101,49 @@ export function startDaemon(cwd: string): void {
91
101
 
92
102
  console.log(`[mink] scheduler started (PID: ${proc.pid})`);
93
103
  console.log(`[mink] log: ${logPath}`);
104
+
105
+ // Fire and forget — channel startup has its own verification loop.
106
+ maybeStartChannel().catch((err) => {
107
+ console.error(`[mink] failed to start channel: ${err instanceof Error ? err.message : String(err)}`);
108
+ });
109
+ }
110
+
111
+ async function maybeStartChannel(): Promise<void> {
112
+ const enabled = resolveConfigValue("channel.discord.enabled").value === "true";
113
+ if (!enabled) return;
114
+
115
+ if (!isVaultInitialized()) {
116
+ console.log("[mink] channel enabled but vault not initialized — skipping channel start");
117
+ return;
118
+ }
119
+
120
+ const token = resolveConfigValue("channel.discord.bot-token").value;
121
+ if (!token) {
122
+ console.log("[mink] channel enabled but no Discord bot token configured — skipping channel start");
123
+ return;
124
+ }
125
+
126
+ if (isChannelRunning()) {
127
+ return;
128
+ }
129
+
130
+ const platform =
131
+ (resolveConfigValue("channel.default-platform").value as ChannelPlatform) ||
132
+ "discord";
133
+ const skipPermissions =
134
+ resolveConfigValue("channel.skip-permissions").value === "true";
135
+ const vaultPath = resolveVaultPath();
136
+ writeCompanionClaudeMd(vaultPath, false);
137
+
138
+ const result = await startChannelProcess({ vaultPath, platform, token, skipPermissions });
139
+ if (!result.alreadyRunning) {
140
+ console.log(`[mink] channel started (session: ${result.session}, platform: ${platform})`);
141
+ }
94
142
  }
95
143
 
96
144
  export async function stopDaemon(): Promise<void> {
145
+ await stopChannelIfRunning();
146
+
97
147
  const pidData = readPidFile();
98
148
  if (!pidData) {
99
149
  console.log("[mink] scheduler is not running (no PID file)");
@@ -130,24 +180,35 @@ export async function stopDaemon(): Promise<void> {
130
180
  console.log("[mink] scheduler force-stopped (SIGKILL)");
131
181
  }
132
182
 
183
+ async function stopChannelIfRunning(): Promise<void> {
184
+ if (!isChannelRunning()) return;
185
+ const result = await stopChannelProcess();
186
+ if (result === "stopped") {
187
+ console.log("[mink] channel stopped");
188
+ }
189
+ }
190
+
133
191
  export function getDaemonStatus(cwd: string): {
134
192
  running: boolean;
135
193
  pid?: number;
136
194
  startedAt?: string;
137
195
  projectCwd?: string;
196
+ channel: ChannelStatus | null;
138
197
  } {
198
+ const channel = getChannelStatus();
139
199
  const pidData = readPidFile();
140
200
  if (!pidData) {
141
- return { running: false };
201
+ return { running: false, channel };
142
202
  }
143
203
  if (!isProcessAlive(pidData.pid)) {
144
204
  removePidFile();
145
- return { running: false };
205
+ return { running: false, channel };
146
206
  }
147
207
  return {
148
208
  running: true,
149
209
  pid: pidData.pid,
150
210
  startedAt: pidData.startedAt,
151
211
  projectCwd: pidData.projectCwd,
212
+ channel,
152
213
  };
153
214
  }
@@ -0,0 +1,72 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
2
+ import { dirname } from "path";
3
+ import { hostname, platform } from "os";
4
+ import { randomUUID } from "crypto";
5
+ import { deviceIdPath, deviceRegistryPath } from "./paths";
6
+ import { safeReadJson, atomicWriteJson } from "./fs-utils";
7
+ import type { DeviceInfo, DeviceRegistry } from "../types/config";
8
+
9
+ export function getOrCreateDeviceId(): string {
10
+ const idPath = deviceIdPath();
11
+ if (existsSync(idPath)) {
12
+ return readFileSync(idPath, "utf-8").trim();
13
+ }
14
+ const id = randomUUID();
15
+ mkdirSync(dirname(idPath), { recursive: true });
16
+ writeFileSync(idPath, id + "\n");
17
+ return id;
18
+ }
19
+
20
+ export function loadDeviceRegistry(): DeviceRegistry {
21
+ const raw = safeReadJson(deviceRegistryPath());
22
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw) && "devices" in (raw as object)) {
23
+ return raw as DeviceRegistry;
24
+ }
25
+ return { devices: {} };
26
+ }
27
+
28
+ export function saveDeviceRegistry(registry: DeviceRegistry): void {
29
+ atomicWriteJson(deviceRegistryPath(), registry);
30
+ }
31
+
32
+ export function updateDeviceHeartbeat(): void {
33
+ const id = getOrCreateDeviceId();
34
+ const registry = loadDeviceRegistry();
35
+ const now = new Date().toISOString();
36
+ const existing = registry.devices[id];
37
+
38
+ registry.devices[id] = {
39
+ name: existing?.name ?? hostname(),
40
+ hostname: hostname(),
41
+ platform: platform(),
42
+ firstSeen: existing?.firstSeen ?? now,
43
+ lastSeen: now,
44
+ };
45
+
46
+ saveDeviceRegistry(registry);
47
+ }
48
+
49
+ export function listDevices(): Array<DeviceInfo & { id: string }> {
50
+ const registry = loadDeviceRegistry();
51
+ return Object.entries(registry.devices).map(([id, info]) => ({
52
+ id,
53
+ ...info,
54
+ }));
55
+ }
56
+
57
+ export function setDeviceName(name: string): void {
58
+ const id = getOrCreateDeviceId();
59
+ const registry = loadDeviceRegistry();
60
+ const now = new Date().toISOString();
61
+ const existing = registry.devices[id];
62
+
63
+ registry.devices[id] = {
64
+ name,
65
+ hostname: hostname(),
66
+ platform: platform(),
67
+ firstSeen: existing?.firstSeen ?? now,
68
+ lastSeen: now,
69
+ };
70
+
71
+ saveDeviceRegistry(registry);
72
+ }
@@ -1,4 +1,4 @@
1
- import { globalConfigPath } from "./paths";
1
+ import { globalConfigPath, localConfigPath } from "./paths";
2
2
  import { safeReadJson, atomicWriteJson } from "./fs-utils";
3
3
  import {
4
4
  CONFIG_KEYS,
@@ -6,31 +6,57 @@ import {
6
6
  getConfigKeyMeta,
7
7
  type GlobalConfig,
8
8
  type ConfigKey,
9
+ type ConfigScope,
9
10
  } from "../types/config";
10
11
 
11
- export function loadGlobalConfig(): GlobalConfig {
12
- const raw = safeReadJson(globalConfigPath());
12
+ function loadConfigFile(path: string): GlobalConfig {
13
+ const raw = safeReadJson(path);
13
14
  if (raw === null) return {};
14
15
  if (typeof raw !== "object" || Array.isArray(raw)) {
15
- console.warn("[mink] warning: corrupt config file at " + globalConfigPath());
16
+ console.warn("[mink] warning: corrupt config file at " + path);
16
17
  return {};
17
18
  }
18
19
  return raw as GlobalConfig;
19
20
  }
20
21
 
22
+ export function loadGlobalConfig(): GlobalConfig {
23
+ return loadConfigFile(globalConfigPath());
24
+ }
25
+
21
26
  export function saveGlobalConfig(config: GlobalConfig): void {
22
27
  atomicWriteJson(globalConfigPath(), config);
23
28
  }
24
29
 
30
+ export function loadLocalConfig(): GlobalConfig {
31
+ return loadConfigFile(localConfigPath());
32
+ }
33
+
34
+ export function saveLocalConfig(config: GlobalConfig): void {
35
+ atomicWriteJson(localConfigPath(), config);
36
+ }
37
+
38
+ function loadConfigForScope(scope: ConfigScope): GlobalConfig {
39
+ return scope === "local" ? loadLocalConfig() : loadGlobalConfig();
40
+ }
41
+
42
+ function saveConfigForScope(scope: ConfigScope, config: GlobalConfig): void {
43
+ if (scope === "local") {
44
+ saveLocalConfig(config);
45
+ } else {
46
+ saveGlobalConfig(config);
47
+ }
48
+ }
49
+
25
50
  export interface ResolvedValue {
26
51
  value: string;
27
52
  source: "default" | "config file" | "environment variable";
53
+ scope: ConfigScope;
28
54
  configFileValue?: string;
29
55
  }
30
56
 
31
57
  export function resolveConfigValue(key: ConfigKey): ResolvedValue {
32
58
  const meta = getConfigKeyMeta(key);
33
- const config = loadGlobalConfig();
59
+ const config = loadConfigForScope(meta.scope);
34
60
 
35
61
  const envValue = process.env[meta.envVar];
36
62
  const fileValue = config[key];
@@ -39,15 +65,16 @@ export function resolveConfigValue(key: ConfigKey): ResolvedValue {
39
65
  return {
40
66
  value: envValue,
41
67
  source: "environment variable",
68
+ scope: meta.scope,
42
69
  configFileValue: fileValue,
43
70
  };
44
71
  }
45
72
 
46
73
  if (fileValue !== undefined) {
47
- return { value: fileValue, source: "config file" };
74
+ return { value: fileValue, source: "config file", scope: meta.scope };
48
75
  }
49
76
 
50
- return { value: meta.default, source: "default" };
77
+ return { value: meta.default, source: "default", scope: meta.scope };
51
78
  }
52
79
 
53
80
  export function resolveAllConfig(): Array<ResolvedValue & { key: ConfigKey }> {
@@ -58,17 +85,51 @@ export function resolveAllConfig(): Array<ResolvedValue & { key: ConfigKey }> {
58
85
  }
59
86
 
60
87
  export function setConfigValue(key: ConfigKey, value: string): void {
61
- const config = loadGlobalConfig();
88
+ const meta = getConfigKeyMeta(key);
89
+ const config = loadConfigForScope(meta.scope);
62
90
  config[key] = value;
63
- saveGlobalConfig(config);
91
+ saveConfigForScope(meta.scope, config);
64
92
  }
65
93
 
66
94
  export function resetConfigKey(key: ConfigKey): void {
67
- const config = loadGlobalConfig();
95
+ const meta = getConfigKeyMeta(key);
96
+ const config = loadConfigForScope(meta.scope);
68
97
  delete config[key];
69
- saveGlobalConfig(config);
98
+ saveConfigForScope(meta.scope, config);
70
99
  }
71
100
 
72
101
  export function resetAllConfig(): void {
73
102
  saveGlobalConfig({});
103
+ saveLocalConfig({});
104
+ }
105
+
106
+ // ── Migration ─────────────────────────────────────────────────────────────
107
+
108
+ let migrationRan = false;
109
+
110
+ export function migrateConfigIfNeeded(): void {
111
+ if (migrationRan) return;
112
+ migrationRan = true;
113
+
114
+ const { existsSync } = require("fs");
115
+ if (existsSync(localConfigPath())) return;
116
+
117
+ const shared = loadGlobalConfig();
118
+ const localKeys = CONFIG_KEYS.filter((k) => k.scope === "local");
119
+ const localConfig: GlobalConfig = {};
120
+ let hasLocal = false;
121
+
122
+ for (const meta of localKeys) {
123
+ const val = shared[meta.key];
124
+ if (val !== undefined) {
125
+ localConfig[meta.key] = val;
126
+ delete shared[meta.key];
127
+ hasLocal = true;
128
+ }
129
+ }
130
+
131
+ if (hasLocal) {
132
+ saveLocalConfig(localConfig);
133
+ saveGlobalConfig(shared);
134
+ }
74
135
  }
package/src/core/paths.ts CHANGED
@@ -57,10 +57,30 @@ export function schedulerManifestPath(cwd: string): string {
57
57
  return join(projectDir(cwd), "scheduler-manifest.json");
58
58
  }
59
59
 
60
+ export function channelPidPath(): string {
61
+ return join(MINK_ROOT, "channel.pid");
62
+ }
63
+
64
+ export function channelLogPath(): string {
65
+ return join(MINK_ROOT, "channel.log");
66
+ }
67
+
60
68
  export function globalConfigPath(): string {
61
69
  return join(MINK_ROOT, "config");
62
70
  }
63
71
 
72
+ export function localConfigPath(): string {
73
+ return join(MINK_ROOT, "config.local");
74
+ }
75
+
76
+ export function deviceIdPath(): string {
77
+ return join(MINK_ROOT, "device-id");
78
+ }
79
+
80
+ export function deviceRegistryPath(): string {
81
+ return join(MINK_ROOT, "devices.json");
82
+ }
83
+
64
84
  export function projectMetaPath(cwd: string): string {
65
85
  return join(projectDir(cwd), "project-meta.json");
66
86
  }
package/src/core/sync.ts CHANGED
@@ -3,6 +3,7 @@ import { join } from "path";
3
3
  import { execSync } from "child_process";
4
4
  import { minkRoot } from "./paths";
5
5
  import { resolveConfigValue, setConfigValue } from "./global-config";
6
+ import { updateDeviceHeartbeat } from "./device";
6
7
 
7
8
  // ── Constants ──────────────────────────────────────────────────────────────
8
9
 
@@ -14,6 +15,10 @@ const GITIGNORE_CONTENTS = `# Runtime state — machine-specific
14
15
  scheduler.pid
15
16
  scheduler.log
16
17
 
18
+ # Device identity and local config — machine-specific
19
+ device-id
20
+ config.local
21
+
17
22
  # Local backups — machine-specific snapshots
18
23
  projects/*/backups/
19
24
  `;
@@ -171,6 +176,8 @@ export function syncPull(
171
176
  ): void {
172
177
  if (!isSyncInitialized()) return;
173
178
 
179
+ ensureGitignore();
180
+
174
181
  const root = minkRoot();
175
182
 
176
183
  try {
@@ -218,6 +225,8 @@ export function syncPull(
218
225
  }
219
226
 
220
227
  setConfigValue("sync.last-pull", new Date().toISOString());
228
+
229
+ try { updateDeviceHeartbeat(); } catch { /* never crash hooks */ }
221
230
  } catch (err) {
222
231
  onMessage(
223
232
  `[mink] sync pull error: ${err instanceof Error ? err.message : String(err)}`
@@ -230,6 +239,9 @@ export function syncPush(
230
239
  ): void {
231
240
  if (!isSyncInitialized()) return;
232
241
 
242
+ ensureGitignore();
243
+ try { updateDeviceHeartbeat(); } catch { /* never crash hooks */ }
244
+
233
245
  const root = minkRoot();
234
246
 
235
247
  try {
@@ -0,0 +1,16 @@
1
+ export type ChannelPlatform = "discord" | "telegram";
2
+
3
+ export interface ChannelPidFile {
4
+ session: string;
5
+ platform: ChannelPlatform;
6
+ startedAt: string;
7
+ vaultPath: string;
8
+ }
9
+
10
+ export interface ChannelStatus {
11
+ session: string;
12
+ platform: ChannelPlatform;
13
+ startedAt: string;
14
+ vaultPath: string;
15
+ uptime: number;
16
+ }