@miphamai/cli 0.55.0 → 0.56.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.
package/bin/mipham.ts CHANGED
@@ -87,7 +87,9 @@ async function runWorkflowCLI(): Promise<boolean> {
87
87
  })
88
88
 
89
89
  // Commander will handle --help, unknown commands, etc.
90
- await program.parseAsync(process.argv)
90
+ // Skip node + script + the leading `workflow` keyword (already matched by the
91
+ // caller); `from: 'user'` stops commander from stripping two more elements.
92
+ await program.parseAsync(process.argv.slice(3), { from: 'user' })
91
93
  return true
92
94
  }
93
95
 
@@ -185,7 +187,9 @@ async function runPluginCLI(): Promise<boolean> {
185
187
  process.exit(result.success ? 0 : 1)
186
188
  })
187
189
 
188
- await program.parseAsync(process.argv)
190
+ // Skip node + script + the leading `plugin` keyword (already matched by the
191
+ // caller); `from: 'user'` stops commander from stripping two more elements.
192
+ await program.parseAsync(process.argv.slice(3), { from: 'user' })
189
193
  return true
190
194
  }
191
195
 
@@ -1054,6 +1058,21 @@ async function runTokenCLI(): Promise<boolean> {
1054
1058
  }
1055
1059
 
1056
1060
  async function main() {
1061
+ // ── Deleted-cwd guard ──────────────────────────────────────────────────
1062
+ // `process.cwd()` throws ENOENT when the directory the process was launched
1063
+ // from no longer exists (e.g. a removed git worktree). Print a clear message
1064
+ // instead of a raw crash dump (matches Claude Code 2.1.239).
1065
+ const { isDeletedCwdError, deletedCwdMessage } = await import('../src/shared/deleted-cwd')
1066
+ try {
1067
+ process.cwd()
1068
+ } catch (err) {
1069
+ if (isDeletedCwdError(err)) {
1070
+ console.error(deletedCwdMessage())
1071
+ process.exit(1)
1072
+ }
1073
+ throw err
1074
+ }
1075
+
1057
1076
  // ── Disable terminal special characters ──────────────────────────────────
1058
1077
  // Terminal intercepts Ctrl+S (XOFF), Ctrl+T (SIGINFO/status), Ctrl+R (rprnt)
1059
1078
  // before the app sees them. Disable these so Agent Dashboard shortcuts work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -38,6 +38,7 @@ import { UsageTracker } from './usage-tracker'
38
38
  import { getMetrics } from './metrics'
39
39
  import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
40
40
  import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
41
+ import { accumulateGraftSavings } from '../shared/graft-savings'
41
42
  import { getMessageBus, type AgentMessage } from '../agent/message-bus'
42
43
  import { createT } from '../i18n-core/t'
43
44
  import enUS from '../i18n-core/locales/en-US.json'
@@ -1127,6 +1128,9 @@ export class QueryEngine {
1127
1128
  tracker.persist()
1128
1129
  }
1129
1130
 
1131
+ // Accumulate graft token savings from "[graft] tokens saved ≈ N" footers
1132
+ accumulateGraftSavings(result.content)
1133
+
1130
1134
  return result
1131
1135
  } catch (err) {
1132
1136
  // P1-3: Trigger PostToolUseFailure hook on tool execution errors
package/src/index.tsx CHANGED
@@ -396,7 +396,7 @@ export async function runApp(options: RunOptions): Promise<void> {
396
396
  if (context.getMessageCount() === 0) {
397
397
  const basePrompt = instructions.buildSystemPrompt(config.permission as string)
398
398
  const memoryReminder = loadSessionMemories(basePrompt)
399
- const skillsReminder = skillsLoader.buildSystemReminder(basePrompt)
399
+ const skillsReminder = skillsLoader.buildSystemReminder()
400
400
 
401
401
  // Inject previous session summary for AI continuity
402
402
  let prompt = basePrompt
@@ -0,0 +1,174 @@
1
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import type { SkillsLoader } from '../skills/loader'
4
+ import type { AgentRegistry } from '../agent/agent-registry'
5
+ import type { McpClient } from '../mcp/client'
6
+ import type { McpServerConfig, ToolDefinition } from '../shared/types'
7
+ import { registerMcpServerTools } from '../mcp/registry'
8
+
9
+ /**
10
+ * Claude marketplace plugin adapter — loads the portable content of a Claude
11
+ * plugin (`.claude-plugin/plugin.json` + skills/agents/MCP) into Mipham Code's
12
+ * own subsystems. Commands (`commands/*.md`) and hooks (`hooks/hooks.json`) are
13
+ * not mapped here: Mipham Code has no file-based slash-command system, and the
14
+ * hook matcher semantics differ.
15
+ */
16
+
17
+ interface ClaudeManifest {
18
+ name: string
19
+ version?: string
20
+ mcpServers?: Record<string, unknown> | string
21
+ [key: string]: unknown
22
+ }
23
+
24
+ /** Read the Claude manifest at `<dir>/.claude-plugin/plugin.json`. */
25
+ export function readClaudeManifest(dir: string): ClaudeManifest | null {
26
+ const path = join(dir, '.claude-plugin', 'plugin.json')
27
+ if (!existsSync(path)) return null
28
+ try {
29
+ return JSON.parse(readFileSync(path, 'utf-8')) as ClaudeManifest
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ /** Expand `${CLAUDE_PLUGIN_ROOT}` (Claude's plugin-root placeholder). */
36
+ function expandPluginRoot(value: string, pluginRoot: string): string {
37
+ return value.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginRoot)
38
+ }
39
+
40
+ /** Load Claude skills — `skills/<name>/SKILL.md` (no leading dot on SKILL.md). */
41
+ function loadClaudeSkills(dir: string, skillsLoader: SkillsLoader): void {
42
+ const skillsDir = join(dir, 'skills')
43
+ if (!existsSync(skillsDir)) return
44
+ let entries: string[] = []
45
+ try {
46
+ entries = readdirSync(skillsDir)
47
+ } catch {
48
+ return
49
+ }
50
+ for (const entry of entries) {
51
+ let skillFile: string | null = null
52
+ try {
53
+ const full = join(skillsDir, entry)
54
+ if (statSync(full).isDirectory()) {
55
+ const candidate = join(full, 'SKILL.md')
56
+ if (existsSync(candidate)) skillFile = candidate
57
+ } else if (entry === 'SKILL.md') {
58
+ skillFile = full
59
+ }
60
+ } catch {
61
+ continue
62
+ }
63
+ if (skillFile) skillsLoader.loadSkillFile(skillFile, 'standard')
64
+ }
65
+ }
66
+
67
+ /** Map a Claude MCP server entry to a Mipham `McpServerConfig`. */
68
+ function toMcpServerConfig(name: string, raw: Record<string, unknown>): McpServerConfig | null {
69
+ const command = typeof raw.command === 'string' ? raw.command : undefined
70
+ const url = typeof raw.url === 'string' ? raw.url : undefined
71
+ if (!command && !url) return null
72
+
73
+ const cfg: McpServerConfig = { name }
74
+ if (command) {
75
+ cfg.command = command
76
+ cfg.args = Array.isArray(raw.args) ? raw.args.map(String) : undefined
77
+ } else {
78
+ cfg.url = url
79
+ }
80
+ if (raw.env && typeof raw.env === 'object') {
81
+ cfg.env = raw.env as Record<string, string>
82
+ }
83
+ if (raw.headers && typeof raw.headers === 'object') {
84
+ cfg.headers = raw.headers as Record<string, string>
85
+ }
86
+ return cfg
87
+ }
88
+
89
+ /** Load Claude MCP servers (`.mcp.json` + inline `mcpServers`) into the client. */
90
+ function loadClaudeMcp(
91
+ dir: string,
92
+ manifest: ClaudeManifest,
93
+ mcpClient: Pick<McpClient, 'connect'>,
94
+ toolsMap: Map<string, ToolDefinition>,
95
+ ): string[] {
96
+ const servers: McpServerConfig[] = []
97
+
98
+ const collect = (obj: unknown): void => {
99
+ if (!obj || typeof obj !== 'object') return
100
+ for (const [name, raw] of Object.entries(obj as Record<string, Record<string, unknown>>)) {
101
+ const cfg = toMcpServerConfig(name, raw)
102
+ if (cfg) servers.push(cfg)
103
+ }
104
+ }
105
+
106
+ // 1. `.mcp.json` at the plugin root
107
+ const mcpJsonPath = join(dir, '.mcp.json')
108
+ if (existsSync(mcpJsonPath)) {
109
+ try {
110
+ collect(JSON.parse(readFileSync(mcpJsonPath, 'utf-8')).mcpServers)
111
+ } catch {
112
+ /* skip unparseable .mcp.json */
113
+ }
114
+ }
115
+ // 2. Inline `mcpServers` field in the manifest
116
+ collect(manifest.mcpServers)
117
+
118
+ for (const cfg of servers) {
119
+ if (cfg.command) cfg.command = expandPluginRoot(cfg.command, dir)
120
+ if (cfg.args) cfg.args = cfg.args.map((a) => expandPluginRoot(a, dir))
121
+ if (cfg.env) {
122
+ for (const key of Object.keys(cfg.env)) cfg.env[key] = expandPluginRoot(cfg.env[key]!, dir)
123
+ }
124
+
125
+ mcpClient
126
+ .connect(cfg)
127
+ .then(() => {
128
+ const count = registerMcpServerTools(cfg.name, toolsMap)
129
+ if (count > 0) {
130
+ process.stderr.write(
131
+ `[plugin] "${manifest.name}": registered ${count} MCP tools from "${cfg.name}"\n`,
132
+ )
133
+ }
134
+ })
135
+ .catch((err: unknown) => {
136
+ process.stderr.write(
137
+ `[plugin] Failed to connect MCP "${cfg.name}" from "${manifest.name}": ${String(err)}\n`,
138
+ )
139
+ })
140
+ }
141
+
142
+ return servers.map((s) => s.name)
143
+ }
144
+
145
+ /** Load a Claude plugin's portable content (skills + agents + MCP) into Mipham Code.
146
+ * Returns the names of the connected MCP servers so the caller can wire cleanup. */
147
+ export function loadClaudePlugin(
148
+ dir: string,
149
+ deps: {
150
+ skillsLoader: SkillsLoader
151
+ agentRegistry: AgentRegistry
152
+ mcpClient: Pick<McpClient, 'connect'>
153
+ toolsMap: Map<string, ToolDefinition>
154
+ },
155
+ ): string[] {
156
+ const manifest = readClaudeManifest(dir)
157
+ if (!manifest) return []
158
+
159
+ loadClaudeSkills(dir, deps.skillsLoader)
160
+
161
+ // Claude agents use `.md` + frontmatter; largely compatible with Mipham's AgentDefinition.
162
+ const agentsDir = join(dir, 'agents')
163
+ if (existsSync(agentsDir)) {
164
+ try {
165
+ deps.agentRegistry.loadDirectory(agentsDir, 'user')
166
+ } catch (err) {
167
+ process.stderr.write(
168
+ `[plugin] Failed to load agents from "${manifest.name}": ${String(err)}\n`,
169
+ )
170
+ }
171
+ }
172
+
173
+ return loadClaudeMcp(dir, manifest, deps.mcpClient, deps.toolsMap)
174
+ }
@@ -7,6 +7,8 @@ import type { HookEngine } from '../core/hooks'
7
7
  import type { McpClient } from '../mcp/client'
8
8
  import { registerMcpServerTools } from '../mcp/registry'
9
9
  import { executeHook } from '../core/hooks-executor'
10
+ import { detectPluginFormat } from './plugin-validator'
11
+ import { loadClaudePlugin } from './claude-plugin'
10
12
  import type { McpServerConfig, ToolDefinition, HookConfig, HookEvent } from '../shared/types'
11
13
 
12
14
  /**
@@ -24,6 +26,29 @@ export function loadPlugins(
24
26
  toolsMap: Map<string, ToolDefinition>,
25
27
  ): void {
26
28
  for (const plugin of pluginManager.getEnabled()) {
29
+ // Claude marketplace plugins: load their portable content via the adapter.
30
+ if (detectPluginFormat(plugin.path) === 'claude') {
31
+ const claudeMcpServers = loadClaudePlugin(plugin.path, {
32
+ skillsLoader,
33
+ agentRegistry,
34
+ mcpClient,
35
+ toolsMap,
36
+ })
37
+ pluginManager.onRemove(plugin.name, () => {
38
+ for (const serverName of claudeMcpServers) {
39
+ try {
40
+ const toolNames = mcpClient.disconnect(serverName)
41
+ for (const toolName of toolNames) {
42
+ toolsMap.delete(`mcp__${serverName}__${toolName}`)
43
+ }
44
+ } catch {
45
+ /* best effort */
46
+ }
47
+ }
48
+ })
49
+ continue
50
+ }
51
+
27
52
  const mcpServers: string[] = []
28
53
  const hookEvents: HookEvent[] = []
29
54
 
@@ -1,4 +1,12 @@
1
- import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs'
1
+ import {
2
+ mkdirSync,
3
+ existsSync,
4
+ writeFileSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ rmSync,
8
+ renameSync,
9
+ } from 'node:fs'
2
10
  import { join } from 'node:path'
3
11
  import { homedir } from 'node:os'
4
12
  import { execSync } from 'node:child_process'
@@ -48,7 +56,7 @@ export class PluginManager {
48
56
  const similarWarning = this.findSimilarWarning(validation.manifest.name)
49
57
  this.plugins.push({
50
58
  name: validation.manifest.name,
51
- version: validation.manifest.version,
59
+ version: validation.manifest.version || '0.0.0',
52
60
  path: destDir,
53
61
  enabled: true,
54
62
  installedAt: new Date().toISOString(),
@@ -78,44 +86,65 @@ export class PluginManager {
78
86
  return { success: false, message: `Invalid package name: "${packageName}"` }
79
87
  }
80
88
 
81
- // Derive plugin name from npm package name (strip scope prefix)
82
- const pluginName = packageName.replace(/^@.+\//, '').replace(/^mipham-plugin-/, '')
89
+ // Staging dir name derived from the npm package name (scope stripped). The
90
+ // final directory is renamed to the manifest `name` once validated — the two
91
+ // can differ (e.g. a scoped package whose manifest declares a shorter name).
92
+ const stagingName = packageName.replace(/^@.+\//, '').replace(/^mipham-plugin-/, '')
83
93
 
84
- const destDir = join(this.pluginDir, pluginName)
85
- if (existsSync(destDir)) {
94
+ const stagingDir = join(this.pluginDir, stagingName)
95
+ if (existsSync(stagingDir)) {
86
96
  return {
87
97
  success: false,
88
- message: `Plugin "${pluginName}" is already installed`,
98
+ message: `Plugin "${stagingName}" is already installed`,
89
99
  }
90
100
  }
91
101
 
92
102
  try {
93
103
  // Install the npm package into the plugins directory
94
- mkdirSync(destDir, { recursive: true })
104
+ mkdirSync(stagingDir, { recursive: true })
95
105
 
96
106
  // Create a minimal package.json so npm install works in a subdirectory
97
107
  writeFileSync(
98
- join(destDir, 'package.json'),
108
+ join(stagingDir, 'package.json'),
99
109
  JSON.stringify({ private: true }, null, 2),
100
110
  'utf-8',
101
111
  )
102
112
 
103
- execSync(`npm install ${packageName} --prefix "${destDir}" --no-save`, {
113
+ execSync(`npm install ${packageName} --prefix "${stagingDir}" --no-save`, {
104
114
  encoding: 'utf-8',
105
115
  stdio: 'pipe',
106
116
  timeout: 60_000,
107
117
  })
108
118
 
109
- // Validate the installed plugin
110
- const validation = validatePlugin(destDir)
119
+ // npm installs the package into <stagingDir>/node_modules/<packageName>/ —
120
+ // validate there, then flatten it to the plugin dir root so its layout
121
+ // matches `install(sourcePath)`.
122
+ const pkgDir = join(stagingDir, 'node_modules', packageName)
123
+ const validation = validatePlugin(pkgDir)
111
124
  if (!validation.valid) {
112
125
  // Clean up on invalid plugin
113
- rmSync(destDir, { recursive: true, force: true })
126
+ rmSync(stagingDir, { recursive: true, force: true })
114
127
  return { success: false, message: validation.errors.join('; ') }
115
128
  }
116
129
 
130
+ this.copyDir(pkgDir, stagingDir)
131
+ rmSync(join(stagingDir, 'node_modules'), { recursive: true, force: true })
132
+
133
+ // Use the manifest `name` for the final directory + record + message so
134
+ // they all agree (npm package name is only the staging key).
135
+ const manifestName = validation.manifest?.name || stagingName
136
+ let destDir = stagingDir
137
+ if (manifestName !== stagingName) {
138
+ destDir = join(this.pluginDir, manifestName)
139
+ if (existsSync(destDir)) {
140
+ rmSync(stagingDir, { recursive: true, force: true })
141
+ return { success: false, message: `Plugin "${manifestName}" is already installed` }
142
+ }
143
+ renameSync(stagingDir, destDir)
144
+ }
145
+
117
146
  this.plugins.push({
118
- name: validation.manifest?.name || pluginName,
147
+ name: manifestName,
119
148
  version: validation.manifest?.version || '0.0.0',
120
149
  path: destDir,
121
150
  enabled: true,
@@ -123,18 +152,18 @@ export class PluginManager {
123
152
  })
124
153
 
125
154
  this.saveState()
126
- const similarWarning = this.findSimilarWarning(pluginName)
155
+ const similarWarning = this.findSimilarWarning(manifestName)
127
156
  return {
128
157
  success: true,
129
158
  message:
130
- `Plugin "${pluginName}" installed from npm` +
159
+ `Plugin "${manifestName}" installed from npm` +
131
160
  (similarWarning ? `\n⚠ ${similarWarning}` : ''),
132
161
  }
133
162
  } catch (err: unknown) {
134
163
  const msg = err instanceof Error ? err.message : String(err)
135
164
  // Clean up on failure
136
165
  try {
137
- rmSync(destDir, { recursive: true, force: true })
166
+ rmSync(stagingDir, { recursive: true, force: true })
138
167
  } catch {
139
168
  /* ok */
140
169
  }
@@ -1,32 +1,67 @@
1
- import { readFileSync } from 'node:fs'
1
+ import { readFileSync, existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
4
  export interface PluginManifest {
5
5
  name: string
6
- version: string
6
+ version?: string
7
7
  miphamVersion?: string
8
8
  hooks?: Record<string, unknown>
9
+ /** Claude manifest fields (commands/agents/skills/mcpServers/…) — read by the Claude loader. */
10
+ [key: string]: unknown
9
11
  }
10
12
 
11
- export function validatePlugin(dir: string): {
13
+ /** Which manifest convention a plugin uses. */
14
+ export type PluginFormat = 'mipham' | 'claude'
15
+
16
+ export interface PluginValidation {
12
17
  valid: boolean
13
18
  errors: string[]
14
19
  manifest?: PluginManifest
15
- } {
16
- const errors: string[] = []
20
+ format: PluginFormat
21
+ /** Resolved manifest path (empty when no manifest found). */
22
+ manifestPath: string
23
+ }
24
+
25
+ /**
26
+ * Resolve a plugin's manifest. Mipham plugins use `plugin.json` at the plugin
27
+ * root; Claude marketplace plugins use `.claude-plugin/plugin.json`.
28
+ */
29
+ function resolveManifestPath(dir: string): { path: string; format: PluginFormat } | null {
30
+ const mipham = join(dir, 'plugin.json')
31
+ if (existsSync(mipham)) return { path: mipham, format: 'mipham' }
32
+ const claude = join(dir, '.claude-plugin', 'plugin.json')
33
+ if (existsSync(claude)) return { path: claude, format: 'claude' }
34
+ return null
35
+ }
36
+
37
+ /** Detect a plugin's manifest convention (defaults to Mipham when absent). */
38
+ export function detectPluginFormat(dir: string): PluginFormat {
39
+ return resolveManifestPath(dir)?.format ?? 'mipham'
40
+ }
17
41
 
42
+ export function validatePlugin(dir: string): PluginValidation {
43
+ const resolved = resolveManifestPath(dir)
44
+ if (!resolved) {
45
+ return {
46
+ valid: false,
47
+ errors: ['No plugin manifest found (expected plugin.json or .claude-plugin/plugin.json)'],
48
+ format: 'mipham',
49
+ manifestPath: '',
50
+ }
51
+ }
52
+
53
+ const errors: string[] = []
18
54
  try {
19
- const raw = readFileSync(join(dir, 'plugin.json'), 'utf-8')
55
+ const raw = readFileSync(resolved.path, 'utf-8')
20
56
  const manifest = JSON.parse(raw) as PluginManifest
21
57
 
22
58
  if (!manifest.name || !/^[a-z0-9-]+$/.test(manifest.name)) {
23
59
  errors.push('Invalid plugin name: must be lowercase alphanumeric with hyphens')
24
60
  }
25
- if (!manifest.version) {
61
+ // Mipham plugins require a version; Claude plugins only require `name`.
62
+ if (resolved.format === 'mipham' && !manifest.version) {
26
63
  errors.push('Missing required field: version')
27
64
  }
28
-
29
- // Check for suspicious hooks
30
65
  if (manifest.hooks) {
31
66
  const hooksStr = JSON.stringify(manifest.hooks)
32
67
  if (hooksStr.includes('rm -rf') || hooksStr.includes('curl') || hooksStr.includes('eval')) {
@@ -34,8 +69,20 @@ export function validatePlugin(dir: string): {
34
69
  }
35
70
  }
36
71
 
37
- return { valid: errors.length === 0, errors, manifest }
72
+ return {
73
+ valid: errors.length === 0,
74
+ errors,
75
+ manifest,
76
+ format: resolved.format,
77
+ manifestPath: resolved.path,
78
+ }
38
79
  } catch (err) {
39
- return { valid: false, errors: [`Failed to read plugin.json: ${String(err)}`] }
80
+ const label = resolved.format === 'claude' ? '.claude-plugin/plugin.json' : 'plugin.json'
81
+ return {
82
+ valid: false,
83
+ errors: [`Failed to read ${label}: ${String(err)}`],
84
+ format: resolved.format,
85
+ manifestPath: resolved.path,
86
+ }
40
87
  }
41
88
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Detection and messaging for the "current working directory was deleted"
3
+ * startup failure.
4
+ *
5
+ * `process.cwd()` throws `ENOENT` when the directory the process was launched
6
+ * from no longer exists (e.g. a removed git worktree). The CLI entry checks
7
+ * this up front and prints a clear, actionable message instead of letting the
8
+ * error surface as a raw crash dump (matches Claude Code 2.1.239).
9
+ */
10
+
11
+ /** True when `err` is the ENOENT thrown by `process.cwd()` on a deleted cwd. */
12
+ export function isDeletedCwdError(err: unknown): boolean {
13
+ if (!(err instanceof Error)) return false
14
+ // Node sets `code = 'ENOENT'` with a message ending in `, uv_cwd`. Bun and
15
+ // other runtimes may omit the code but still name `getcwd`/`uv_cwd`.
16
+ return (err as NodeJS.ErrnoException).code === 'ENOENT' || /uv_cwd|getcwd/i.test(err.message)
17
+ }
18
+
19
+ /** Human-readable guidance shown when the launch directory was deleted. */
20
+ export function deletedCwdMessage(): string {
21
+ return (
22
+ `Mipham Code can't start: the current working directory no longer exists.\n\n` +
23
+ `The directory you launched from was deleted (for example, a removed git worktree).\n` +
24
+ `Change to a valid directory and run \`mipham\` again.`
25
+ )
26
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Session-scoped accumulator for graft's token savings. Graft tools emit a
3
+ * "[graft] tokens saved ≈ N" footer on their output; the engine parses that
4
+ * footer after each tool result and adds it here, so the statusline can show
5
+ * "~N tok saved" (mirroring graft's own hooks.js `handleToolSavings`).
6
+ */
7
+
8
+ /** Sum the "[graft] tokens saved ≈ N" footers in a tool output. */
9
+ export function parseGraftSavings(text: string): number {
10
+ let total = 0
11
+ for (const m of text.matchAll(/\[graft\] tokens saved ≈ ([\d,]+)/g)) {
12
+ const digits = m[1]
13
+ if (digits) total += Number(digits.replace(/,/g, '')) || 0
14
+ }
15
+ return total
16
+ }
17
+
18
+ let savings = 0
19
+
20
+ /** Add a tool result's graft savings to the session running total. */
21
+ export function accumulateGraftSavings(text: string): void {
22
+ if (!text) return
23
+ savings += parseGraftSavings(text)
24
+ }
25
+
26
+ /** The session's running graft token savings. */
27
+ export function getGraftSavings(): number {
28
+ return savings
29
+ }
30
+
31
+ /** Reset the running total (e.g. on session reset). */
32
+ export function resetGraftSavings(): void {
33
+ savings = 0
34
+ }
@@ -0,0 +1,36 @@
1
+ import { readFileSync, existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /** Graft's hook-maintained stats cache (`graft/.cache/stats.json`). */
5
+ export interface GraftStats {
6
+ nodeCount: number
7
+ edgeCount: number
8
+ staleCount: number
9
+ dirty: boolean
10
+ syncing: boolean
11
+ lastFile: string | null
12
+ }
13
+
14
+ /**
15
+ * Read graft's stats cache for the given project. Returns null when graft is
16
+ * not built/installed in `cwd` (no `graft/.cache/stats.json`, or an empty
17
+ * graph) — callers render nothing in that case.
18
+ */
19
+ export function readGraftStats(cwd: string): GraftStats | null {
20
+ const statsPath = join(cwd, 'graft', '.cache', 'stats.json')
21
+ if (!existsSync(statsPath)) return null
22
+ try {
23
+ const raw = JSON.parse(readFileSync(statsPath, 'utf-8')) as Record<string, unknown>
24
+ if (typeof raw.nodeCount !== 'number' || raw.nodeCount === 0) return null
25
+ return {
26
+ nodeCount: raw.nodeCount,
27
+ edgeCount: typeof raw.edgeCount === 'number' ? raw.edgeCount : 0,
28
+ staleCount: typeof raw.staleCount === 'number' ? raw.staleCount : 0,
29
+ dirty: raw.dirty === true,
30
+ syncing: raw.syncing === true,
31
+ lastFile: typeof raw.lastFile === 'string' ? raw.lastFile : null,
32
+ }
33
+ } catch {
34
+ return null
35
+ }
36
+ }
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.55.0' as const
12
+ export const PACKAGE_VERSION = '0.56.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -82,6 +82,11 @@ export class SkillsLoader implements Skills {
82
82
  }
83
83
  }
84
84
 
85
+ /** Load a single skill file by path (used by Claude plugins: `skills/<name>/SKILL.md`). */
86
+ loadSkillFile(path: string, type: 'standard' | 'mipham' = 'standard'): void {
87
+ this.tryLoad(path, type)
88
+ }
89
+
85
90
  get(name: string): SkillDefinition | undefined {
86
91
  return this.skills.get(name)
87
92
  }
@@ -195,40 +200,13 @@ export class SkillsLoader implements Skills {
195
200
  }
196
201
 
197
202
  /**
198
- * Recall the skills most relevant to a context (e.g. the session's system
199
- * prompt / project context), mirroring MemoryManager.recall's keyword
200
- * scoring. Name keywords are a strong signal (+3); description keywords are
201
- * weak (+1). Returns up to `limit` skills sorted by relevance.
202
- */
203
- recall(context: string, limit: number = 5): SkillDefinition[] {
204
- const ctxWords = new Set(context.toLowerCase().split(/\s+/))
205
- const scored: Array<{ skill: SkillDefinition; score: number }> = []
206
-
207
- for (const skill of this.list()) {
208
- if (skill.disableModelInvocation) continue
209
- let score = 0
210
- for (const word of skill.name.toLowerCase().split(/[-_\s]+/)) {
211
- if (word.length > 2 && ctxWords.has(word)) score += 3
212
- }
213
- for (const word of skill.description.toLowerCase().split(/\s+/)) {
214
- if (word.length > 3 && ctxWords.has(word)) score += 1
215
- }
216
- if (score > 0) scored.push({ skill, score })
217
- }
218
-
219
- scored.sort((a, b) => b.score - a.score)
220
- return scored.slice(0, limit).map((s) => s.skill)
221
- }
222
-
223
- /**
224
- * Build the system-reminder block for AI auto-triggering.
225
- * With a `context`, only the most relevant skills are injected (selective,
226
- * to save tokens); without one, all skills are listed.
203
+ * Build the system-reminder block listing every skill the AI may invoke via
204
+ * the Skill tool. A single full listing keeps the whole catalog discoverable:
205
+ * at session start there is no query to match against, so a keyword "recall"
206
+ * would silently hide most skills. Capped at `maxTokens` to stay bounded.
227
207
  */
228
- buildSystemReminder(context?: string, maxTokens: number = 5000): string {
229
- const selected = context
230
- ? this.recall(context)
231
- : this.list().filter((s) => !s.disableModelInvocation)
208
+ buildSystemReminder(maxTokens: number = 5000): string {
209
+ const selected = this.list().filter((s) => !s.disableModelInvocation)
232
210
 
233
211
  if (selected.length === 0) return ''
234
212
 
@@ -6,7 +6,7 @@ export interface Skills {
6
6
  get(name: string): SkillDefinition | undefined
7
7
  list(): SkillDefinition[]
8
8
  has(name: string): boolean
9
- buildSystemReminder(context?: string, maxTokens?: number): string
9
+ buildSystemReminder(maxTokens?: number): string
10
10
  }
11
11
 
12
12
  /** 缝键:ctx.skills。 */
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from
2
2
  import { join } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
4
  import type { ToolDefinition } from '../../shared/index.ts'
5
+ import { MemoryManager } from '../../core/memory/memory-manager'
5
6
 
6
7
  const MEMORY_DIR = join(homedir(), '.mipham', 'memory')
7
8
 
@@ -24,7 +25,7 @@ ${content}
24
25
 
25
26
  export const memoryTool: ToolDefinition = {
26
27
  name: 'Memory',
27
- description: 'Read and write persistent memory files in ~/.mipham/memory/.',
28
+ description: 'Read, write, list, and search persistent memory files in ~/.mipham/memory/.',
28
29
  category: 'agent',
29
30
  permission: 'auto',
30
31
  parameters: {
@@ -32,11 +33,12 @@ export const memoryTool: ToolDefinition = {
32
33
  properties: {
33
34
  action: {
34
35
  type: 'string',
35
- enum: ['read', 'write', 'list'],
36
+ enum: ['read', 'write', 'list', 'search'],
36
37
  description: 'Action to perform',
37
38
  },
38
39
  name: { type: 'string', description: 'Memory file name (slug)' },
39
40
  content: { type: 'string', description: 'Content to write (for write action)' },
41
+ query: { type: 'string', description: 'Search query (for search action)' },
40
42
  },
41
43
  required: ['action'],
42
44
  },
@@ -49,6 +51,22 @@ export const memoryTool: ToolDefinition = {
49
51
  return { success: true, content: files.join('\n') || '(no memories)' }
50
52
  }
51
53
 
54
+ // Semantic-less recall (keyword + wikilink + time-decay scoring) — lets the
55
+ // AI retrieve relevant memories mid-conversation, not only at session start.
56
+ if (action === 'search') {
57
+ const query = (params.query as string)?.trim()
58
+ if (!query) return { success: false, content: '', error: 'query is required' }
59
+ const mm = new MemoryManager(MEMORY_DIR)
60
+ mm.loadAll()
61
+ const results = mm.recall(query, 10)
62
+ if (results.length === 0) return { success: true, content: '(no matching memories)' }
63
+ const lines = results.map((entry) => {
64
+ const snippet = entry.content.replace(/\n+/g, ' ').trim().slice(0, 140)
65
+ return `- ${entry.name}: ${entry.description || snippet}\n ${snippet}`
66
+ })
67
+ return { success: true, content: lines.join('\n') }
68
+ }
69
+
52
70
  const name = params.name as string
53
71
  if (!name) return { success: false, content: '', error: 'name is required' }
54
72
  const filePath = join(MEMORY_DIR, `${name}.md`)
package/src/ui/app.tsx CHANGED
@@ -22,6 +22,18 @@ import { ChatPanel } from './chat'
22
22
  import { InputBar } from './input'
23
23
  import { ModelPicker } from './picker'
24
24
  import { AgentFooter, type AgentEntry } from './agent-footer'
25
+ import { GraftStatusLine } from './graft-status'
26
+
27
+ /** Current context-window usage % — undefined when unknown (remote stub). */
28
+ function contextUsagePct(engine: QueryEngine | RemoteEngine): number | undefined {
29
+ const ctx = engine.getContext() as {
30
+ getEstimatedTokens(): number
31
+ getMaxTokens?(): number
32
+ }
33
+ const max = ctx.getMaxTokens?.()
34
+ if (!max || max <= 0) return undefined
35
+ return Math.round((ctx.getEstimatedTokens() / max) * 100)
36
+ }
25
37
  import { AgentViewDashboard } from '../agent-view/dashboard'
26
38
  import type { AgentViewManager } from '../agent-view/agent-view-manager'
27
39
  import { WorkflowProgress } from './workflow-progress.js'
@@ -965,6 +977,9 @@ export function App({
965
977
  agentProgress={agentProgress}
966
978
  />
967
979
 
980
+ {/* graft status line — mirrors graft's own "◤ graft · …" bar */}
981
+ <GraftStatusLine cwd={process.cwd()} ctxPct={contextUsagePct(engine)} />
982
+
968
983
  {/* Status line — Claude Code style */}
969
984
  <Box marginTop={1} flexDirection="column">
970
985
  <Box flexDirection="row">
@@ -0,0 +1,59 @@
1
+ import React, { useState } from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { basename } from 'node:path'
4
+ import { readGraftStats, type GraftStats } from '../shared/graft-stats'
5
+ import { getGraftSavings } from '../shared/graft-savings'
6
+
7
+ /** Freshness segment mirroring graft's own statusline (see @nanonets/graft format.js). */
8
+ function freshnessSegment(stats: GraftStats): { label: string; color: string } {
9
+ if (stats.syncing) return { label: 'syncing…', color: 'yellow' }
10
+ if (stats.dirty && stats.staleCount > 0)
11
+ return { label: `⚠ ${stats.staleCount} stale`, color: 'yellow' }
12
+ if (stats.dirty) return { label: '⚠ stale', color: 'yellow' }
13
+ return { label: '✓ synced', color: 'blue' }
14
+ }
15
+
16
+ /**
17
+ * Bottom-of-screen graft status line — mirrors graft's own "◤ graft · …" bar:
18
+ * ◤ graft · N nodes / E edges · ✓ synced · ~T tok saved
19
+ * ▸ ctx X% · last: file
20
+ * Reads `graft/.cache/stats.json` once at mount; the session's tok-saved total
21
+ * is read live each render (the app re-renders on the agent tick). Returns
22
+ * null (renders nothing) when graft isn't built here.
23
+ */
24
+ export function GraftStatusLine({ cwd, ctxPct }: { cwd: string; ctxPct?: number }) {
25
+ const [stats] = useState(() => readGraftStats(cwd))
26
+ if (!stats) return null
27
+ const fresh = freshnessSegment(stats)
28
+ const saved = getGraftSavings()
29
+ const lastFile = stats.lastFile ? basename(stats.lastFile) : null
30
+
31
+ const bottom: string[] = []
32
+ if (typeof ctxPct === 'number') bottom.push(`ctx ${ctxPct}%`)
33
+ if (lastFile) bottom.push(`last: ${lastFile}`)
34
+
35
+ return (
36
+ <Box flexDirection="column">
37
+ <Box>
38
+ <Text dimColor>◤ </Text>
39
+ <Text color="blue">graft</Text>
40
+ <Text dimColor>
41
+ {' '}
42
+ · {stats.nodeCount} nodes / {stats.edgeCount} edges ·{' '}
43
+ </Text>
44
+ <Text color={fresh.color}>{fresh.label}</Text>
45
+ {saved > 0 && (
46
+ <>
47
+ <Text dimColor> · </Text>
48
+ <Text color="blue">~{saved.toLocaleString()} tok saved</Text>
49
+ </>
50
+ )}
51
+ </Box>
52
+ {bottom.length > 0 && (
53
+ <Box>
54
+ <Text dimColor>▸ {bottom.join(' · ')}</Text>
55
+ </Box>
56
+ )}
57
+ </Box>
58
+ )
59
+ }