Compare commits

..

3 Commits

Author SHA1 Message Date
alex 2f198ea0d2 feat: remove heuristics, go LLM-only with mocks + restructure
BREAKING: Heuristic fallback removed — LLM client now required

Changes:
- Remove extractFileHeuristic, extractPackageHeuristic, all helpers
- extractFileLLM / extractPackageLLM now throw LLMError when client missing
- Add hybrid binary detection: extension blacklist + content sniffing
- Increase file size limit: 50KB → 500KB (text files only)
- Restructure src/ into subdirectories:
  - src/llm/ — all LLM clients, extract, batch, cache, error
  - src/ast/ — AST extraction
  - src/cli/ — CLI entry point
- Update all imports across codebase and tests
- Add tests/mock-llm.ts helper for deterministic mock clients
- Update all tests to use mock LLM clients (no heuristics dependency)
- All 52 tests passing (including 8 real LLM integration tests)
2026-06-10 17:34:37 +02:00
alex d948d88d0a feat: add live progress streaming to init/reinit tools
- InitOptions.onProgress callback for directory-level progress
- generateDirectoryMap reports per-file progress (→ filename)
- initProject reports [N/M] Analyzing <path> (X files)...
- pi-extension wires _onUpdate to onProgress for live Pi UI updates
- User sees real-time feedback instead of silent long waits
2026-06-10 16:32:08 +02:00
alex bd5ba74aa2 fix: parallelize file processing in init to prevent Pi timeout
- Use processFiles from llm-batch.ts in generateDirectoryMap
- Process files with 4 concurrent LLM calls + 100ms batch delays
- Reduces init time from O(n) sequential to O(n/4) parallel
- Prevents Pi tool timeout on projects with many files
2026-06-10 16:27:05 +02:00
24 changed files with 620 additions and 517 deletions
+62
View File
@@ -0,0 +1,62 @@
{
"25c2db9d6c4c2c511bec4a736f5adf77423be6011bbedd916fb55003d059b4e3": {
"result": "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
"ts": 1781105452802
},
"889300ab1d0afe082d820fdd9150229625c3ab69b3b84c2ca65d67479be388cc": {
"result": "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
"ts": 1781105452841
},
"585c5da8de102beb6a9906f0e3f07a134b5392bed52fc1a0aaef989e69aa66b6": {
"result": "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
"ts": 1781105452845
},
"a6f92e629a4c341fe1e10f92ef923c54002b8a79ec3bedb0b9079bc7aca4e2e0": {
"result": "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
"ts": 1781105452846
},
"a6c149b54aec4ced46eb38d6faf1f20ec62fd2f9a2f2dcb6a15c4f339dfb5f1e": {
"result": "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
"ts": 1781105452846
},
"4eda95f6452551a12d5b434e284c2e539ce57e0206d5ffa49609f4fa8354a966": {
"result": "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
"ts": 1781105452846
},
"510456d000664badf3cbec9f70c80769a119a590e84b1971e49ac5728882a699": {
"result": "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
"ts": 1781105452847
},
"b0f5449d50fda0b5c0cf47103188df2d4339b047e1e03cab7a15a431f2990185": {
"result": "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
"ts": 1781105452848
},
"eebe9a750792a3d802c6868f32ac264d44ef193a6f1940c4ad5e715b6e31074a": {
"result": "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
"ts": 1781105452848
},
"4958b5d7ec5b4c975bb1e2972519aff5fbd94cb7342be4f8dd854a5c7353bd26": {
"result": "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
"ts": 1781105452849
},
"389a385ef4fc5d1d253253fa3c931c505c7fbcca68db4ebe6b34392b3ca32d65": {
"result": "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
"ts": 1781105452849
},
"879d2eff31eaf101d30de95e0e2df1c1a3a305c4e3b0515a22c85969af49ff25": {
"result": "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
"ts": 1781105452947
},
"c2f0a91350b7f5e11a95848c4d806e49d2017862acbc43afb1dd52a5e73c0b24": {
"result": "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
"ts": 1781105452948
},
"037ecd1db38c230c248787e60fd7bfc0cb0101b187b59535b6e7483be762d350": {
"result": "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
"ts": 1781105452950
},
"b5d546753d33709dff508a6f2c2e547f432267fbb05b68d3da74fa5f7bda9f7a": {
"result": "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
"ts": 1781105452953
}
}
+206
View File
@@ -0,0 +1,206 @@
{
"5ef2d43fdcc0141a7494c926da032af6708a60be024fdc7873cf37469b1a9a7e": {
"result": "PURPOSE: Specifies files and directories for Git to ignore in a Node.js project\nDEPS: git\nCONCEPTS: version control, ignore patterns, build artifacts, environment configuration, dependency management",
"ts": 1781101253058
},
"a093bb2fed59d699b7f9a62a5203cc196054c624e9c39c6d908c9e7db5dd2464": {
"result": "PURPOSE: Configures npm to use legacy peer dependency resolution behavior\nDEPS: npm\nCONCEPTS: package management configuration, peer dependency handling",
"ts": 1781101255838
},
"edd7b515e876afba58cd2397d4b04cf6e0309bbc2bfb81f79f7e94adb6862b30": {
"result": "PURPOSE: Describes a Pi skill that generates per-directory `.pi-map.md` files to provide machine-readable project summaries for agent comprehension.\nDEPS: npm, Node.js\nCONCEPTS: hierarchical project analysis, documentation generation, CLI tooling, agent-oriented design",
"ts": 1781101257984
},
"1c04338b0ebd9d3a241c4e7258ce7f9d2cc90be5be03788a9d7d6aff0209de47": {
"result": "PURPOSE: Generates and maintains hierarchical `.pi-map.md` analysis files per directory to enable instant codebase comprehension via AST parsing and LLM heuristics\nDEPS: npm, TypeScript/JavaScript AST parser, Python AST parser, Go AST parser, Rust AST parser, filesystem watcher\nCONCEPTS: hierarchical documentation, incremental updates, AST-based symbol extraction, LLM heuristics, dirty tracking/validation, dense markdown optimization, guard pattern, token budget management",
"ts": 1781101262689
},
"7ea3c13985c332cbdca448f7b7cd2067b4d08429b938bb61075255b8ff1a27b1": {
"result": "PURPOSE: Design a hierarchical project analysis skill for the Pi coding agent that generates and maintains compact, LLM-optimized `.pi-map.md` files per directory using hybrid LLM + AST extraction.\nDEPS: Pi Extension API, OpenAI-compatible LLM API, tree-sitter/LSP parsers, p-limit, SHA-256 hashing, Node.js/npm, TypeScript\nCONCEPTS: hierarchical project mapping, LLM-AST hybrid extraction, dense markdown format, token-efficient context, caching by content hash, incremental patching with dirty markers, validation and stale data mitigation, concurrency and rate limiting, prompt engineering",
"ts": 1781101268134
},
"b6228af707d3c250d26a87cd883ec75d6123bb561f0f1134d2ebc22b242c500e": {
"result": "PURPOSE: Implementation plan for replacing heuristic code analysis with real LLM integration in a Pi skill package that generates hierarchical `.pi-map.md` files for software projects.\n\nDEPS: openai, p-limit, Pi ExtensionContext/modelRegistry, TypeScript/Node.js\n\nCONCEPTS: hierarchical project analysis, LLM abstraction layer, dual-provider support, disk caching with LRU eviction, parallel batch processing with retries and exponential backoff, token budget management with truncation, prompt engineering, atomic file writes, factory pattern, dependency injection",
"ts": 1781101272709
},
"191f240ea0ca94939a3bac0c07ddea729dfecd34fa553c344151eee74afae826": {
"result": "PURPOSE: Defines a TypeScript-based Pi skill package for hierarchical project analysis that generates and maintains `.pi-map.md` files via CLI and OpenAI integration.\nDEPS: typescript, vitest, eslint, openai, tree-sitter, tree-sitter-python, tree-sitter-typescript, ignore, p-limit, picocolors\nCONCEPTS: CLI tooling, static code analysis, AI-powered code intelligence, hierarchical project mapping, tree-sitter parsing, concurrency limiting, Pi skill framework",
"ts": 1781101276443
},
"6736b63703d810d05e20f75829d48c3dcb26ecc34a61a72b34e67ba16c75d62a": {
"result": "PURPOSE: Registers four Pi extension tools (project_map_init, project_map_patch, project_map_validate, project_map_reinit) for managing .pi-map.md project analysis files, plus session lifecycle hooks for auto-loading maps and injecting maintenance hints.\nDEPS: @mariozechner/pi-coding-agent, typebox, fs, path, ./src/index.js, ./src/llm-client.js, ./src/llm-error.js\nCONCEPTS: Extension API registration, schema validation with TypeBox, recursive directory traversal, event hooks (session_start, before_agent_start), LLM client abstraction, error handling with custom error types",
"ts": 1781101281499
},
"fd478849f18f30f060a04006c2c99a5cf778928741fe7840bce91c1b612b475b": {
"result": "PURPOSE: Configures TypeScript compiler options for a Node.js project targeting ES2022 with strict type checking, declaration generation, and source maps.\nDEPS: TypeScript, Node.js\nCONCEPTS: ES modules, strict type checking, declaration files, source maps, JSON module resolution, project structure separation",
"ts": 1781101285063
},
"ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356": {
"result": "PURPOSE: Empty JSON configuration file with no defined settings\nDEPS: none\nCONCEPTS: JSON, configuration file, empty object",
"ts": 1781101294054
},
"4e5f16536ba51381392c91d7548737ba1e75c260b40ba6bf12cd00e91b6662bd": {
"result": "PURPOSE: Specifies files and directories for Git to ignore in the repository.\nDEPS: none\nCONCEPTS: version control, ignore patterns, build artifacts, dependencies, environment files",
"ts": 1781101300959
},
"61b1cfba0fb43448ef5c8af601bd8d948fe6f2007bf1cbd7ad3cf38de8796959": {
"result": "PURPOSE: Provides a brief overview of a small test project for pi-project-map functionality, describing its directory structure.\nDEPS: none\nCONCEPTS: documentation, project structure",
"ts": 1781101302888
},
"774a9e5bc3d0cccb73f48c399090a674b15de7e1c3847338f8ce8f379aac4202": {
"result": "PURPOSE: Defines Node.js package metadata, entry point, and build/test scripts for a TypeScript project.\nDEPS: typescript, vitest\nCONCEPTS: npm package configuration, build automation, testing setup",
"ts": 1781101305949
},
"9b6a96e8186a8ab864e7ce794d4abffa04909d942ab25e3d2c7b4fd3b3993ff9": {
"result": "PURPOSE: Configures TypeScript compiler settings for a Node.js project targeting ES2022 with strict type checking.\nDEPS: TypeScript, Node.js\nCONCEPTS: compiler configuration, module resolution, strict mode, source/output directory mapping, ES2022 target",
"ts": 1781101309464
},
"5c4644938090d20c6ec6ad41ddcd405cd4dd743f0304839fda6536779d4a3977": {
"result": "PURPOSE: Documents the API for user model operations and validation utilities\nDEPS: none\nCONCEPTS: API documentation, CRUD operations, input validation, serialization, error handling",
"ts": 1781101314989
},
"b7ce5aa53a8029b3a8e964615c083e395923731fefa98859b52f63353cb06cd2": {
"result": "PURPOSE: Creates and validates a user, then logs the result, while also re-exporting its dependencies.\nDEPS: ./models/user.js, ./utils/validation.js, ./utils/logger.js\nCONCEPTS: async/await, re-exports, validation, logging",
"ts": 1781101321083
},
"5b7780f6c6dc3950d1eea8da636328ed4357546abd5c6b4e61158bec33f38d29": {
"result": "PURPOSE: Renders a reusable button component with configurable variant, label, click handler, and disabled state.\nDEPS: React\nCONCEPTS: functional components, props interface with optional/required fields, default parameter values, template literals for dynamic class names, JSX",
"ts": 1781101327896
},
"fecf0c5d358cd2308ec354f8648e249595634fb2b9e826bc786ae5b8cfb1b5fe": {
"result": "PURPOSE: Renders a user information card with optional edit and delete action buttons.\nDEPS: React, ../models/user.js\nCONCEPTS: Functional components, Props interface, Optional callbacks, Conditional rendering, JSX",
"ts": 1781101330838
},
"1d213e971dc36a5b0c3b09164532f4f23c47cf0d2303e696c3cc4527f1ad75cd": {
"result": "PURPOSE: Defines a User type and provides factory/serialization functions for user objects with email validation.\nDEPS: ../utils/validation.js\nCONCEPTS: interface, type omission (Omit), spread operator, factory pattern, serialization, UUID generation",
"ts": 1781101336857
},
"49f96f98e46b41171f7eec2185a7b70eccc069e35fc48e595477cb40e3058fe6": {
"result": "PURPOSE: Provides a simple timestamped console logging utility with typed severity levels and convenience methods.\nDEPS: none\nCONCEPTS: union types, function overloading via wrappers, template literals, pure functions",
"ts": 1781101341327
},
"d3f67a1eaae3b0c14e53fc33ff25f70695b99cac492b011d3d141c282775d3ee": {
"result": "PURPOSE: Provides string validation utility functions for email format, non-emptiness, and minimum length checks.\nDEPS: none\nCONCEPTS: regular expressions, pure functions, utility module pattern, string validation",
"ts": 1781101343923
},
"14311d5732fba356276aa801829d764e2f05655058c78c6da57b703fa507d9ac": {
"result": "PURPOSE: Tests user model creation and serialization with validation\nDEPS: vitest, ../src/models/user.js\nCONCEPTS: unit testing, test-driven development, validation, serialization, error handling",
"ts": 1781101348436
},
"0323321ea8b99c6b2ae75230e04e5df264d8a459f0e61608985a823f7a3d9e72": {
"result": "PURPOSE: Unit tests for string validation utility functions\nDEPS: vitest, ../src/utils/validation.js\nCONCEPTS: unit testing, test suites, parameterized assertions, edge case testing",
"ts": 1781101350510
},
"ccc1278c89e099da1a812dd261013282602a1b9a178e73474e20c659d87f9ec8": {
"result": "PURPOSE: Injects a maintenance instruction reminder into every AI agent prompt to ensure .pi-map.md files stay updated\nDEPS: none\nCONCEPTS: prompt injection, string interpolation, constant exports, sidecar/hook pattern",
"ts": 1781101355928
},
"c149160b07dd2c83c597ac8db04f0f2b664d0bfe0c73494bd441ec40537cabcd": {
"result": "PURPOSE: Extracts structured AST data (exports, dependencies, classes, functions) from source code files across multiple languages using tree-sitter parsers.\nDEPS: fs, path, tree-sitter, tree-sitter-typescript, tree-sitter-python, tree-sitter-go, tree-sitter-rust (optional)\nCONCEPTS: AST parsing, tree traversal, visitor pattern, dynamic module loading, language-agnostic code analysis, recursive descent parsing",
"ts": 1781101363602
},
"9ccca325201c14ff749252083ed2586da7ab4f51276990697a1d42a588d2866c": {
"result": "PURPOSE: Implements a CLI tool for generating and managing hierarchical `.pi-map.md` project analysis files using LLM-powered directory summarization.\nDEPS: picocolors, ./init.js, ./patch.js, ./validate.js, ./discover.js, ./llm-client.js, ./config.js, ../package.json\nCONCEPTS: command pattern, argument parsing, dependency injection, async/await, error handling with custom error types, process exit codes, string formatting",
"ts": 1781101369019
},
"f6650315d5deae3897ddee8471fe0c1976e587e59767616c732f09f5139ee3b5": {
"result": "PURPOSE: Defines a configuration interface and loader for a project mapping tool that merges user-defined JSON config with sensible defaults.\nDEPS: fs, path\nCONCEPTS: interface definition, default constants, shallow merge, file-based configuration, optional chaining via try/catch fallback",
"ts": 1781101372705
},
"ac6587bef7660e8c8de7cdb4a9a9f5453899154964801bd55ae396a8fec0b95b": {
"result": "PURPOSE: Recursively discovers project files and directories while respecting .gitignore patterns and default ignore rules.\nDEPS: fs, path, ignore\nCONCEPTS: recursive directory traversal, gitignore pattern matching, file system filtering, tree walking",
"ts": 1781101375796
},
"9083911787627247be76be8bc29763c6eb76ff19e22bb07ae35adc582dc42eb9": {
"result": "PURPOSE: Implements an LLMClient interface that sends code analysis prompts to OpenAI's chat completions API with configured model and error handling.\nDEPS: openai, ./llm-error.js, ./llm-client.js\nCONCEPTS: dependency injection, interface implementation, environment-based configuration, error wrapping, async/await, default parameters",
"ts": 1781101379466
},
"2592c1c238f016a227e8653703dba9c9a44b475b454973e81da502e433ffb826": {
"result": "PURPOSE: Provides bidirectional conversion between PackageMapData objects and a custom markdown format for package documentation.\nDEPS: none\nCONCEPTS: string parsing, markdown serialization/deserialization, state machine parsing, data transformation",
"ts": 1781101382110
},
"8c3890f74246ac93b3be460ed2e93ab961054d858d6cc34c3789084a695c30f0": {
"result": "PURPOSE: Main entry point that re-exports core functions for the pi-project-map skill\nDEPS: ./init.js, ./patch.js, ./validate.js, ./format.js\nCONCEPTS: barrel exports, module re-export pattern, skill architecture",
"ts": 1781101384907
},
"00cccebfa97420a2ddf71be724790f61699fb6ba7f59beba4dca1bf0820bd1bb": {
"result": "PURPOSE: Generates `.pi-map.md` documentation files for each directory in a project by combining LLM-based and AST-based extraction of file and package metadata.\nDEPS: ./discover.js, ./format.js, ./llm-extract.js, ./ast-extract.js, ./merge.js, fs, path, ./llm-client.js\nCONCEPTS: async/await, dependency injection, data merging from multiple sources, file I/O, map generation/caching",
"ts": 1781101388067
},
"1ef82a3248c87d9bef8b7a1d0d79f8c3294ee083ba591dc2978e5594f009c52f": {
"result": "PURPOSE: Implements an LLM client for Kimi.com's Anthropic-compatible API to send prompts and return completions.\nDEPS: llm-error.js, llm-client.js\nCONCEPTS: dependency injection via options, environment variable configuration, fetch API, error wrapping, interface implementation",
"ts": 1781101390722
},
"027ef87301425b5f8383bf6fa33b8a21467ca0ef9572e354aae1d5119e221318": {
"result": "PURPOSE: Provides utilities for batch processing files with concurrency limiting, retry logic with exponential backoff, and configurable delays between batches.\nDEPS: p-limit, ./llm-error.js\nCONCEPTS: concurrency control, retry pattern with exponential backoff, batch processing, promise pooling, default options merging",
"ts": 1781101394350
},
"bdc9b833e0b658b5f929c43e700a083b441b69e01172e6246c7eb8e942d5fb3f": {
"result": "PURPOSE: Provides a file-based caching system for LLM results keyed by hash, with atomic writes and automatic directory creation.\nDEPS: fs, path, process\nCONCEPTS: persistent cache, atomic file writes (write-then-rename), defensive programming (corrupted cache recovery), JSON serialization, timestamp tracking, optional configuration parameters",
"ts": 1781101397618
},
"ce37f5999955126fbb37f59302ed4b83242b798e332850f28eaa1c481230e3b5": {
"result": "PURPOSE: Provides a factory function to create LLM client instances for different providers (Pi, Kimi, OpenAI/External) behind a common interface.\nDEPS: llm-error.js, external-llm-client.js, kimi-llm-client.js, pi-llm-client.js\nCONCEPTS: factory pattern, strategy pattern, interface abstraction, dependency inversion",
"ts": 1781101401617
},
"936ebbbb957d1dfcc601687b077c5f4ef0107cb4f54c11db5d815c903a6c224d": {
"result": "PURPOSE: Defines a custom error class for LLM-related errors with optional cause chaining.\nDEPS: none\nCONCEPTS: custom error class, error cause chaining, readonly properties, TypeScript class inheritance",
"ts": 1781101403870
},
"4af9338e28d8e3574ff4e94a79f4c597f32329faa06ffd24dd4c7cb1f6987d4d": {
"result": "PURPOSE: Extracts semantic metadata (purpose, dependencies, concepts) from source code files and packages using LLM prompts with heuristic fallbacks for multiple programming languages.\nDEPS: fs, crypto, path, ./llm-client.js, ./llm-cache.js, ./llm-error.js\nCONCEPTS: LLM prompt engineering, caching with content hashing, context window truncation, heuristic fallback pattern, regex-based parsing, multi-language support, structured output parsing",
"ts": 1781101408698
},
"64fe8dcb933ddef5bcf79a11fe1d89585743321c2ba3af55c2ee6f1502945c52": {
"result": "PURPOSE: Merges LLM-generated file metadata with AST-extracted code structure into a unified FileEntry format using a compact DSL for exports.\nDEPS: ./format.js\nCONCEPTS: data merging, DSL encoding, deduplication, nullish coalescing, Set operations",
"ts": 1781101411772
},
"1aab9d3c142e39bc334ac2510fbe2d35cff52e6650ee07402dcda81a0522f2d6": {
"result": "PURPOSE: Updates a `.pi-map.md` documentation file for a package, either by full rewrite for small packages or section-level patching for larger packages, using both LLM and AST extraction.\nDEPS: path, fs, ./format.js, ./llm-extract.js, ./ast-extract.js, ./merge.js, ./init.js, ./llm-client.js\nCONCEPTS: conditional logic based on size threshold, file I/O operations, async/await, data merging from multiple sources, in-place array updates, dirty flag pattern",
"ts": 1781101416625
},
"b889955e1d8823a9e9103b36b42daa411f2381be2e18defb970212802f4915e2": {
"result": "PURPOSE: Implements an LLM client adapter that delegates to Pi's internal AI runtime, handling model resolution, authentication, and response parsing.\nDEPS: ./llm-error.js, ./llm-client.js, @mariozechner/pi-ai\nCONCEPTS: adapter pattern, dynamic imports, optional chaining, defensive error handling, runtime environment detection",
"ts": 1781101420745
},
"de1d2f98382d9e1e9fa7cf7ed1684993d4b7fdb505206757508dd8d917ecf525": {
"result": "PURPOSE: Validates `.pi-map.md` files against actual project structure and source code exports, with optional auto-fix capability.\nDEPS: discover.js, format.js, fs, path, ast-extract.js, init.js\nCONCEPTS: AST analysis, set comparison, discrepancy detection, optional mutation/fixing, verbose logging",
"ts": 1781101424333
},
"35562a91c040cbd1e1a7180f30faeb04be96dd94278e8c549e28fb8b6251ffe5": {
"result": "PURPOSE: TypeScript declaration file for the Pi AI runtime module providing a `complete` function for LLM inference with structured chat completion API\nDEPS: none\nCONCEPTS: ambient module declaration, type-only declaration file (.d.ts), runtime-only module, LLM chat completion API, discriminated union types, optional chaining pattern",
"ts": 1781101430608
},
"54e0b7bab2574a8ae7713ff80ef3c072a7351849e6a769af07a093e49fdd36c3": {
"result": "PURPOSE: Tests the AST extraction utility for parsing TypeScript file exports, imports, and handling unsupported file types.\nDEPS: vitest, fs, path, os, ../src/ast-extract.js\nCONCEPTS: unit testing, temporary file creation, async/await, null assertion handling, test fixtures",
"ts": 1781101436720
},
"bdbe3bafc717ce91a3ebe87c39c973f644611b653a9f6e5e689e7797e3b3b0ff": {
"result": "PURPOSE: Tests markdown rendering and parsing functions for package metadata, including round-trip serialization and edge cases like empty arrays and multiline fields.\nDEPS: vitest, ../src/format.js\nCONCEPTS: unit testing, round-trip testing, snapshot-like assertions, edge case handling, type imports",
"ts": 1781101440871
},
"7a708d3abc7a83c5ba649c898af12bc3243973d0e3bc581ff48a3cbd4cb52320": {
"result": "PURPOSE: Integration tests for a project mapping tool that verifies init, patch, and validate functionality using temporary directories\nDEPS: vitest, fs, path, os, ../src/init.js, ../src/patch.js, ../src/validate.js\nCONCEPTS: integration testing, temporary filesystem fixtures, setup/teardown hooks, async/await, test-driven development",
"ts": 1781101445059
},
"511ff5fedc1e22e8979c7ad5f29e01a04267c384046e50673545ca45adc9e71b": {
"result": "PURPOSE: Unit tests for retry logic and parallel file processing utilities in an LLM batch processing module.\nDEPS: vitest, ../src/llm-batch.js, ../src/llm-error.js\nCONCEPTS: unit testing, async/await, retry pattern, concurrency control, error handling, parameterized testing",
"ts": 1781101448595
},
"ac5829c11c6079597acd38e8a39eeb84d4eb28a27c733574b687ff17c82f9307": {
"result": "PURPOSE: Tests the getCached and setCached functions from llm-cache.js using a temporary file-based cache in a vitest test suite.\nDEPS: vitest, ../src/llm-cache.js, fs, path, os\nCONCEPTS: unit testing, file system mocking/cleanup, temporary directories, beforeEach/afterEach hooks, test isolation",
"ts": 1781101452064
},
"243326dc6b993d8dfbb98ebfbd51119e8824a55c3d262fd54351e2598509942f": {
"result": "PURPOSE: Tests the llm-extract module's ability to extract file metadata (exports, dependencies, purpose, concepts) using both heuristic parsing and LLM-based analysis.\nDEPS: vitest, ../src/llm-extract.js, fs, path, os, ../src/llm-client.js\nCONCEPTS: unit testing, mocking, temporary file fixtures, parameterized testing, fallback strategies",
"ts": 1781101455713
},
"c7c525672af743b8e7cc12441011b85fe1bec325c138c21c2b4ee1415a8bf863": {
"result": "PURPOSE: Integration test suite for LLM client functionality using the Kimi API, testing client creation, file/package extraction, caching, parallel processing, and error handling.\nDEPS: vitest, fs, path, os, ../src/llm-client.js, ../src/llm-extract.js, ../src/llm-batch.js\nCONCEPTS: integration testing, environment variable management, temporary file system operations, test skipping conditionally, API client mocking/spying, parallel processing with concurrency control, caching verification, error handling validation",
"ts": 1781101460014
},
"64f5ab73ff71a9c41639f3c8a5e248387a01e27a9612f0ed2b682708557f6a27": {
"result": "PURPOSE: Unit tests for a Pi extension that registers project map tools and lifecycle event handlers.\nDEPS: vitest, fs, path, os, @mariozechner/pi-coding-agent, typebox, @mariozechner/pi-ai, ../pi-extension.js\nCONCEPTS: unit testing, mocking with vi.mock, dependency injection, temporary filesystem fixtures, tool registration testing, event handler testing",
"ts": 1781101463953
}
}
+14 -4
View File
@@ -8,8 +8,8 @@ import {
validateMaps,
reinitPath,
} from "./src/index.js";
import { createLLMClient } from "./src/llm-client.js";
import { LLMError } from "./src/llm-error.js";
import { createLLMClient } from "./src/llm/llm-client.js";
import { LLMError } from "./src/llm/llm-error.js";
/**
* Get the LLM client for Pi runtime.
@@ -78,7 +78,12 @@ export default function (pi: ExtensionAPI) {
try {
const targetPath = params.path || ctx.cwd;
const client = getPiLLMClient(ctx);
await initProject(targetPath, { verbose: false, llmClient: client, cacheDir: ctx.cwd });
await initProject(targetPath, {
verbose: false,
llmClient: client,
cacheDir: ctx.cwd,
onProgress: (msg) => _onUpdate?.({ content: [{ type: "text", text: msg }] }),
});
return {
content: [
{
@@ -199,7 +204,12 @@ export default function (pi: ExtensionAPI) {
try {
const targetPath = params.path || ctx.cwd;
const client = getPiLLMClient(ctx);
await reinitPath(targetPath, { verbose: false, llmClient: client, cacheDir: ctx.cwd });
await reinitPath(targetPath, {
verbose: false,
llmClient: client,
cacheDir: ctx.cwd,
onProgress: (msg) => _onUpdate?.({ content: [{ type: "text", text: msg }] }),
});
return {
content: [
{
+7 -7
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env node
import { initProject } from "./init.js";
import { patchFile } from "./patch.js";
import { validateMaps } from "./validate.js";
import { reinitPath } from "./init.js";
import { discoverProject } from "./discover.js";
import { createLLMClient, LLMError } from "./llm-client.js";
import { loadConfig } from "./config.js";
import { initProject } from "../init.js";
import { patchFile } from "../patch.js";
import { validateMaps } from "../validate.js";
import { reinitPath } from "../init.js";
import { discoverProject } from "../discover.js";
import { createLLMClient, LLMError } from "../llm/llm-client.js";
import { loadConfig } from "../config.js";
import pc from "picocolors";
const args = process.argv.slice(2);
+24 -12
View File
@@ -4,17 +4,19 @@ import {
type PackageMapData,
type FileEntry,
} from "./format.js";
import { extractFileLLM, extractPackageLLM } from "./llm-extract.js";
import { extractFileAST } from "./ast-extract.js";
import { extractFileLLM, extractPackageLLM } from "./llm/llm-extract.js";
import { extractFileAST } from "./ast/ast-extract.js";
import { mergeFileData } from "./merge.js";
import { processFiles } from "./llm/llm-batch.js";
import { writeFileSync } from "fs";
import { join } from "path";
import type { LLMClient } from "./llm-client.js";
import type { LLMClient } from "./llm/llm-client.js";
export interface InitOptions {
verbose?: boolean;
llmClient?: LLMClient;
cacheDir?: string;
onProgress?: (message: string) => void;
}
export async function initProject(
@@ -22,11 +24,15 @@ export async function initProject(
options: InitOptions = {},
): Promise<void> {
const entries = discoverProject(rootPath);
options.onProgress?.(`Scanning ${entries.length} directories...`);
for (const entry of entries) {
await generateDirectoryMap(entry, options.llmClient, options.cacheDir);
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
options.onProgress?.(`[${i + 1}/${entries.length}] Analyzing ${entry.relativePath} (${entry.files.length} files)...`);
await generateDirectoryMap(entry, options.llmClient, options.cacheDir, options.onProgress);
}
options.onProgress?.(`Generated ${entries.length} .pi-map.md files`);
if (options.verbose !== false) {
console.log(`Generated ${entries.length} .pi-map.md files`);
}
@@ -36,14 +42,20 @@ export async function generateDirectoryMap(
entry: DirectoryEntry,
llmClient?: LLMClient,
cacheDir?: string,
onProgress?: (message: string) => void,
): Promise<FileEntry[]> {
const fileData: FileEntry[] = [];
for (const file of entry.files) {
const filePath = join(entry.dirPath, file);
const llmData = await extractFileLLM(filePath, llmClient, cacheDir);
const astData = await extractFileAST(filePath);
fileData.push(mergeFileData(file, llmData, astData));
}
// Process files in parallel (4 concurrent) with retry logic
const fileData = await processFiles(
entry.files,
async (file) => {
const filePath = join(entry.dirPath, file);
onProgress?.(`${file}`);
const llmData = await extractFileLLM(filePath, llmClient, cacheDir);
const astData = await extractFileAST(filePath);
return mergeFileData(file, llmData, astData);
},
{ concurrency: 4, batchDelayMs: 100, maxRetries: 2 },
);
const packageData = await extractPackageLLM(
entry.relativePath,
-387
View File
@@ -1,387 +0,0 @@
import { readFileSync, statSync } from "fs";
import { createHash } from "crypto";
import { extname, basename } from "path";
import type { LLMClient } from "./llm-client.js";
import { getCached, setCached } from "./llm-cache.js";
import { LLMError } from "./llm-error.js";
interface LLMFileData {
purpose: string;
exports: string[];
deps: string[];
concepts: string[];
}
interface LLMPackageData {
role: string;
arch: string;
}
const MAX_FILE_SIZE = 50 * 1024; // 50KB
const CONTEXT_BUDGET = 4000; // tokens
const CHARS_PER_TOKEN = 4; // approximate for ASCII
// Heuristic patterns for common file types (used as fallback + for tests)
const FILE_TYPE_PURPOSES: Record<string, string> = {
".ts": "TypeScript module",
".tsx": "React component",
".js": "JavaScript module",
".jsx": "React component",
".py": "Python module",
".go": "Go module",
".rs": "Rust module",
".java": "Java class",
".kt": "Kotlin class",
".swift": "Swift module",
".c": "C source",
".cpp": "C++ source",
".h": "C/C++ header",
".rb": "Ruby module",
".php": "PHP script",
".sh": "Shell script",
".md": "Documentation",
".json": "Configuration",
".yaml": "Configuration",
".yml": "Configuration",
".toml": "Configuration",
".ini": "Configuration",
".env": "Environment config",
".dockerfile": "Docker image definition",
dockerfile: "Docker image definition",
".sql": "Database schema/queries",
".css": "Stylesheet",
".scss": "SCSS stylesheet",
".less": "LESS stylesheet",
".html": "HTML template",
".vue": "Vue component",
".svelte": "Svelte component",
};
function truncateForContext(content: string, promptLength: number): string {
const maxChars = CONTEXT_BUDGET * CHARS_PER_TOKEN - promptLength;
if (content.length <= maxChars) return content;
return content.slice(0, maxChars - 20) + "\n[...truncated]";
}
function buildFilePrompt(filePath: string, content: string): string {
const name = basename(filePath);
const ext = extname(filePath).toLowerCase();
const typeHint = FILE_TYPE_PURPOSES[ext] || FILE_TYPE_PURPOSES[name.toLowerCase()] || ext || "file";
return `Analyze this ${typeHint} file. Respond in this exact format (one line each):
PURPOSE: <concise one-sentence description of what this file does>
DEPS: <comma-separated list of key dependencies/modules it relies on, or "none">
CONCEPTS: <comma-separated list of key concepts/patterns used, or "none">
File: ${name}
\`\`\`
${content}
\`\`\`
`;
}
function parseFileResponse(response: string): { purpose: string; deps: string[]; concepts: string[] } {
const lines = response.split("\n");
let purpose = "";
let deps: string[] = [];
let concepts: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("PURPOSE:")) {
purpose = trimmed.slice("PURPOSE:".length).trim();
} else if (trimmed.startsWith("DEPS:")) {
const depsStr = trimmed.slice("DEPS:".length).trim();
deps = depsStr === "none" ? [] : depsStr.split(",").map((s) => s.trim()).filter(Boolean);
} else if (trimmed.startsWith("CONCEPTS:")) {
const conceptsStr = trimmed.slice("CONCEPTS:".length).trim();
concepts = conceptsStr === "none" ? [] : conceptsStr.split(",").map((s) => s.trim()).filter(Boolean);
}
}
return { purpose, deps, concepts };
}
function buildPackagePrompt(relativePath: string, fileSummaries: { name: string; purpose: string }[]): string {
const filesList = fileSummaries.map((f) => `- ${f.name}: ${f.purpose}`).join("\n");
return `Analyze this code package/directory. Respond in this exact format (one line each):
ROLE: <concise one-sentence description of this package's role in the project>
ARCH: <concise description of architecture/patterns used in this package>
Directory: ${relativePath}
Files:
${filesList}
`;
}
function parsePackageResponse(response: string): { role: string; arch: string } {
const lines = response.split("\n");
let role = "";
let arch = "";
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("ROLE:")) {
role = trimmed.slice("ROLE:".length).trim();
} else if (trimmed.startsWith("ARCH:")) {
arch = trimmed.slice("ARCH:".length).trim();
}
}
return { role, arch };
}
// ============================================================================
// PRODUCTION: Real LLM calls
// ============================================================================
export async function extractFileLLM(
filePath: string,
client?: LLMClient,
cacheDir?: string,
): Promise<LLMFileData> {
const content = readFileSync(filePath, "utf8");
const hash = createHash("sha256").update(content).digest("hex");
// Check disk cache
const cached = getCached(hash, cacheDir);
if (cached) {
const parsed = parseFileResponse(cached);
return {
purpose: parsed.purpose,
exports: [], // AST provides precise exports
deps: parsed.deps,
concepts: parsed.concepts,
};
}
// Skip very large files
const size = statSync(filePath).size;
if (size > MAX_FILE_SIZE) {
return {
purpose: "Large/generated file",
exports: [],
deps: [],
concepts: [],
};
}
// If no LLM client provided, fall back to heuristics (for backward compat / tests)
if (!client) {
return extractFileHeuristic(filePath, content);
}
const prompt = buildFilePrompt(filePath, truncateForContext(content, 200));
const response = await client.complete(prompt);
setCached(hash, response, cacheDir);
const parsed = parseFileResponse(response);
return {
purpose: parsed.purpose,
exports: [], // AST provides precise exports
deps: parsed.deps,
concepts: parsed.concepts,
};
}
export async function extractPackageLLM(
relativePath: string,
fileData: { name: string; purpose: string }[],
client?: LLMClient,
_cacheDir?: string,
): Promise<LLMPackageData> {
if (!client) {
return extractPackageHeuristic(relativePath, fileData);
}
const prompt = buildPackagePrompt(relativePath, fileData);
const response = await client.complete(prompt);
const parsed = parsePackageResponse(response);
return {
role: parsed.role || dirNameToRole(relativePath),
arch: parsed.arch || `Contains ${fileData.length} files.`,
};
}
// ============================================================================
// HEURISTIC FALLBACK (for tests / no-LLM mode)
// ============================================================================
export async function extractFileHeuristic(
filePath: string,
content?: string,
): Promise<LLMFileData> {
const fileContent = content ?? readFileSync(filePath, "utf8");
const ext = extname(filePath).toLowerCase();
const name = basename(filePath);
const baseName = basename(filePath, ext);
const exports = extractExportsHeuristic(fileContent, ext, name);
const deps = extractDepsHeuristic(fileContent, ext);
const purpose = generatePurpose(name, ext, baseName, exports);
return { purpose, exports, deps, concepts: [] };
}
export async function extractPackageHeuristic(
relativePath: string,
fileData: { name: string; purpose: string }[],
): Promise<LLMPackageData> {
const dirName = basename(relativePath);
const role = dirNameToRole(dirName);
const purposes = fileData.map((f) => f.purpose);
const hasTests = purposes.some((p) => p.includes("Test"));
const hasTypes = purposes.some((p) => p.includes("Type"));
const hasComponents = purposes.some(
(p) => p.includes("component") || p.includes("Component"),
);
const hasUtils = purposes.some((p) => p.includes("Utility"));
let arch = "";
if (hasTests) arch += "Contains tests. ";
if (hasTypes) arch += "Defines shared types. ";
if (hasComponents) arch += "Component-based architecture. ";
if (hasUtils) arch += "Shared utilities. ";
if (!arch) arch = `Contains ${fileData.length} files.`;
return { role, arch: arch.trim() };
}
// ============================================================================
// INTERNAL HEURISTIC HELPERS
// ============================================================================
function dirNameToRole(dirName: string): string {
if (dirName === ".") return "Project root";
if (dirName === "src" || dirName === "lib" || dirName === "source") return "Source code";
if (dirName === "test" || dirName === "tests" || dirName === "spec") return "Test suite";
if (dirName === "docs" || dirName === "doc") return "Documentation";
if (dirName === "config" || dirName === "configuration") return "Configuration";
if (dirName === "utils" || dirName === "helpers" || dirName === "util") return "Utility functions";
if (dirName === "types" || dirName === "type") return "Type definitions";
if (dirName === "components" || dirName === "component") return "UI components";
if (dirName === "hooks" || dirName === "hook") return "Custom hooks";
if (dirName === "api" || dirName === "apis") return "API endpoints/handlers";
if (dirName === "db" || dirName === "database" || dirName === "models") return "Database layer";
if (dirName === "auth" || dirName === "authentication") return "Authentication layer";
return `Package ${dirName}`;
}
function extractExportsHeuristic(content: string, ext: string, _filename: string): string[] {
const exports: string[] = [];
if ([".ts", ".tsx", ".js", ".jsx", ".mjs"].includes(ext)) {
const exportRegex = /(?:^|\n)\s*export\s+(?:default\s+)?(?:async\s+)?(?:function\s+|class\s+|const\s+|let\s+|var\s+|interface\s+|type\s+|enum\s+)?([A-Za-z_$][A-Za-z0-9_$]*)/g;
let match: RegExpExecArray | null;
match = exportRegex.exec(content);
while (match !== null) {
exports.push(match[1]);
match = exportRegex.exec(content);
}
const namedExportRegex = /(?:^|\n)\s*export\s*\{\s*([^}]+)\s*\}/g;
match = namedExportRegex.exec(content);
while (match !== null) {
const names = match[1].split(",").map((s) => s.trim().split(/\s+as\s+/)[0].trim());
exports.push(...names);
match = namedExportRegex.exec(content);
}
} else if (ext === ".py") {
const pyRegex = /^(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)|class\s+([A-Za-z_][A-Za-z0-9_]*)/gm;
let match: RegExpExecArray | null = pyRegex.exec(content);
while (match !== null) {
exports.push(match[1] || match[2]);
match = pyRegex.exec(content);
}
} else if (ext === ".go") {
const goRegex = /^(?:func|type|var|const)\s+([A-Z][A-Za-z0-9_]*)/gm;
let match: RegExpExecArray | null = goRegex.exec(content);
while (match !== null) {
exports.push(match[1]);
match = goRegex.exec(content);
}
} else if (ext === ".rs") {
const rsRegex = /pub\s+(?:fn|struct|enum|trait|type|const|static|use)\s+([A-Za-z_][A-Za-z0-9_]*)/g;
let match: RegExpExecArray | null = rsRegex.exec(content);
while (match !== null) {
exports.push(match[1]);
match = rsRegex.exec(content);
}
}
return [...new Set(exports)];
}
function extractDepsHeuristic(content: string, ext: string): string[] {
const deps: string[] = [];
if ([".ts", ".tsx", ".js", ".jsx", ".mjs"].includes(ext)) {
const importRegex = /import\s+(?:(?:type\s+)?\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"]([^'"]+)['"]/g;
let match: RegExpExecArray | null = importRegex.exec(content);
while (match !== null) {
deps.push(match[1]);
match = importRegex.exec(content);
}
const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
match = requireRegex.exec(content);
while (match !== null) {
deps.push(match[1]);
match = requireRegex.exec(content);
}
} else if (ext === ".py") {
const pyImportRegex = /^(?:from|import)\s+([A-Za-z_][A-Za-z0-9_.]*)/gm;
let match: RegExpExecArray | null = pyImportRegex.exec(content);
while (match !== null) {
deps.push(match[1]);
match = pyImportRegex.exec(content);
}
} else if (ext === ".go") {
const goImportRegex = /"([^"]+)"/g;
let match: RegExpExecArray | null = goImportRegex.exec(content);
while (match !== null) {
if (match[1].includes("/")) deps.push(match[1]);
match = goImportRegex.exec(content);
}
} else if (ext === ".rs") {
const rsUseRegex = /use\s+([A-Za-z_][A-Za-z0-9_:]*)/g;
let match: RegExpExecArray | null = rsUseRegex.exec(content);
while (match !== null) {
deps.push(match[1]);
match = rsUseRegex.exec(content);
}
}
return [...new Set(deps)];
}
function generatePurpose(name: string, ext: string, _baseName: string, exports: string[]): string {
if (/test|spec/i.test(name) && exports.length === 0) return "Test suite";
if (/config|settings/i.test(name)) return "Configuration";
if (/util|helper/i.test(name)) return "Utility functions";
if (/types?\.d?\.ts$/.test(name)) return "Type definitions";
if (/index\./.test(name)) return "Module entry point";
if (/middleware/.test(name)) return "Middleware";
if (/route/.test(name)) return "Route handlers";
if (/controller/.test(name)) return "Controller";
if (/service/.test(name)) return "Service layer";
if (/model/.test(name)) return "Data model";
if (/schema/.test(name)) return "Data schema";
if (/component/.test(name) || /\.tsx$/.test(name) || /\.vue$/.test(name) || /\.svelte$/.test(name)) {
return "UI component";
}
if (/hook|use[A-Z]/.test(name)) return "React hook";
if (/style|\.css|\.scss|\.less/.test(name)) return "Styling";
if (/docker/i.test(name)) return "Container definition";
if (/\.env/.test(name)) return "Environment variables";
if (/readme/i.test(name)) return "Project documentation";
if (exports.length > 0) {
const firstFew = exports.slice(0, 3).join(", ");
if (exports.length <= 3) return `Exports: ${firstFew}`;
return `Exports ${exports.length} symbols: ${firstFew}...`;
}
return FILE_TYPE_PURPOSES[ext] || (ext ? `${ext.slice(1).toUpperCase()} file` : `${name} file`);
}
+217
View File
@@ -0,0 +1,217 @@
import { readFileSync, statSync } from "fs";
import { createHash } from "crypto";
import { extname, basename } from "path";
import type { LLMClient } from "./llm-client.js";
import { getCached, setCached } from "./llm-cache.js";
import { LLMError } from "./llm-error.js";
interface LLMFileData {
purpose: string;
exports: string[];
deps: string[];
concepts: string[];
}
interface LLMPackageData {
role: string;
arch: string;
}
const MAX_FILE_SIZE = 500 * 1024; // 500KB
const CONTEXT_BUDGET = 4000; // tokens
const CHARS_PER_TOKEN = 4; // approximate for ASCII
// Known binary extensions — skip without reading content
const BINARY_EXTENSIONS = new Set([
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".svgz",
".mp3", ".mp4", ".avi", ".mov", ".mkv", ".flv", ".wmv",
".wav", ".ogg", ".flac", ".aac", ".wma",
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
".exe", ".dll", ".so", ".dylib", ".bin",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".wasm", ".class", ".jar", ".o", ".a",
".ttf", ".otf", ".woff", ".woff2", ".eot",
".db", ".sqlite", ".sqlite3",
]);
function isBinaryFile(filePath: string): boolean {
// Fast-path: check extension
const ext = extname(filePath).toLowerCase();
if (BINARY_EXTENSIONS.has(ext)) return true;
// Fallback: sniff first 8KB for null bytes or non-printable ratio
try {
const buf = readFileSync(filePath).subarray(0, 8192);
let nonPrintable = 0;
for (let i = 0; i < buf.length; i++) {
const b = buf[i];
if (b === 0) return true; // null byte = definitely binary
if (b < 0x20 && b !== 0x09 && b !== 0x0a && b !== 0x0d) {
nonPrintable++;
}
}
// If >30% non-printable, treat as binary
return nonPrintable / buf.length > 0.3;
} catch {
return false;
}
}
function truncateForContext(content: string, promptLength: number): string {
const maxChars = CONTEXT_BUDGET * CHARS_PER_TOKEN - promptLength;
if (content.length <= maxChars) return content;
return `${content.slice(0, maxChars - 20)}\n[...truncated]`;
}
function buildFilePrompt(filePath: string, content: string): string {
const name = basename(filePath);
return `Analyze this file. Respond in this exact format (one line each):
PURPOSE: <concise one-sentence description of what this file does>
DEPS: <comma-separated list of key dependencies/modules it relies on, or "none">
CONCEPTS: <comma-separated list of key concepts/patterns used, or "none">
File: ${name}
\`\`\`
${content}
\`\`\`
`;
}
function parseFileResponse(response: string): { purpose: string; deps: string[]; concepts: string[] } {
const lines = response.split("\n");
let purpose = "";
let deps: string[] = [];
let concepts: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("PURPOSE:")) {
purpose = trimmed.slice("PURPOSE:".length).trim();
} else if (trimmed.startsWith("DEPS:")) {
const depsStr = trimmed.slice("DEPS:".length).trim();
deps = depsStr === "none" ? [] : depsStr.split(",").map((s) => s.trim()).filter(Boolean);
} else if (trimmed.startsWith("CONCEPTS:")) {
const conceptsStr = trimmed.slice("CONCEPTS:".length).trim();
concepts = conceptsStr === "none" ? [] : conceptsStr.split(",").map((s) => s.trim()).filter(Boolean);
}
}
return { purpose, deps, concepts };
}
function buildPackagePrompt(relativePath: string, fileSummaries: { name: string; purpose: string }[]): string {
const filesList = fileSummaries.map((f) => `- ${f.name}: ${f.purpose}`).join("\n");
return `Analyze this code package/directory. Respond in this exact format (one line each):
ROLE: <concise one-sentence description of this package's role in the project>
ARCH: <concise description of architecture/patterns used in this package>
Directory: ${relativePath}
Files:
${filesList}
`;
}
function parsePackageResponse(response: string): { role: string; arch: string } {
const lines = response.split("\n");
let role = "";
let arch = "";
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("ROLE:")) {
role = trimmed.slice("ROLE:".length).trim();
} else if (trimmed.startsWith("ARCH:")) {
arch = trimmed.slice("ARCH:".length).trim();
}
}
return { role, arch };
}
export async function extractFileLLM(
filePath: string,
client?: LLMClient,
cacheDir?: string,
): Promise<LLMFileData> {
// LLM-only: require a client
if (!client) {
throw new LLMError(
"No LLM client configured. " +
"Set OPENAI_API_KEY / KIMI_API_KEY environment variable, " +
"or run inside Pi with a configured model.",
);
}
// Skip binary files
if (isBinaryFile(filePath)) {
return {
purpose: "Binary file",
exports: [],
deps: [],
concepts: [],
};
}
const content = readFileSync(filePath, "utf8");
const hash = createHash("sha256").update(content).digest("hex");
// Check disk cache
const cached = getCached(hash, cacheDir);
if (cached) {
const parsed = parseFileResponse(cached);
return {
purpose: parsed.purpose,
exports: [], // AST provides precise exports
deps: parsed.deps,
concepts: parsed.concepts,
};
}
// Skip very large files
const size = statSync(filePath).size;
if (size > MAX_FILE_SIZE) {
return {
purpose: "Large file",
exports: [],
deps: [],
concepts: [],
};
}
const prompt = buildFilePrompt(filePath, truncateForContext(content, 200));
const response = await client.complete(prompt);
setCached(hash, response, cacheDir);
const parsed = parseFileResponse(response);
return {
purpose: parsed.purpose,
exports: [], // AST provides precise exports
deps: parsed.deps,
concepts: parsed.concepts,
};
}
export async function extractPackageLLM(
relativePath: string,
fileData: { name: string; purpose: string }[],
client?: LLMClient,
_cacheDir?: string,
): Promise<LLMPackageData> {
// LLM-only: require a client
if (!client) {
throw new LLMError(
"No LLM client configured. " +
"Set OPENAI_API_KEY / KIMI_API_KEY environment variable, " +
"or run inside Pi with a configured model.",
);
}
const prompt = buildPackagePrompt(relativePath, fileData);
const response = await client.complete(prompt);
const parsed = parsePackageResponse(response);
return {
role: parsed.role || `Package ${basename(relativePath)}`,
arch: parsed.arch || `Contains ${fileData.length} files.`,
};
}
+3 -3
View File
@@ -1,12 +1,12 @@
import { dirname, join, basename, relative } from "path";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { parsePackageMap, renderPackageMap } from "./format.js";
import { extractFileLLM } from "./llm-extract.js";
import { extractFileAST } from "./ast-extract.js";
import { extractFileLLM } from "./llm/llm-extract.js";
import { extractFileAST } from "./ast/ast-extract.js";
import { mergeFileData } from "./merge.js";
import { generateDirectoryMap } from "./init.js";
import { readdirSync, statSync } from "fs";
import type { LLMClient } from "./llm-client.js";
import type { LLMClient } from "./llm/llm-client.js";
const SMALL_PACKAGE_THRESHOLD = 10;
+1 -1
View File
@@ -2,7 +2,7 @@ import { discoverProject } from "./discover.js";
import { parsePackageMap } from "./format.js";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { extractFileAST } from "./ast-extract.js";
import { extractFileAST } from "./ast/ast-extract.js";
import { generateDirectoryMap } from "./init.js";
export interface ValidationResult {
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { extractFileAST } from "../src/ast-extract.js";
import { extractFileAST } from "../src/ast/ast-extract.js";
import { mkdtempSync, writeFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
+15 -9
View File
@@ -11,6 +11,7 @@ import { tmpdir } from "os";
import { initProject } from "../src/init.js";
import { patchFile } from "../src/patch.js";
import { validateMaps } from "../src/validate.js";
import { createMockFileClient, createMockPackageClient } from "./mock-llm.js";
describe("integration", () => {
let dir: string;
@@ -26,25 +27,26 @@ describe("integration", () => {
it("init creates .pi-map.md files", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "index.ts"), `export function foo() {}\n`);
await initProject(dir);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
expect(map).toContain("# src");
expect(map).toContain("foo");
});
it("patch updates a file entry", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "index.ts"), `export function foo() {}\n`);
writeFileSync(join(dir, "src", "utils.ts"), `export const bar = 1;\n`);
await initProject(dir);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
// Modify a file
writeFileSync(
join(dir, "src", "index.ts"),
`export function foo() {}\nexport function baz() {}\n`,
);
await patchFile(join(dir, "src", "index.ts"));
await patchFile(join(dir, "src", "index.ts"), client, dir);
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
expect(map).toContain("baz");
@@ -59,14 +61,15 @@ describe("integration", () => {
`export const x${i} = ${i};\n`,
);
}
await initProject(dir);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
// Modify a file
writeFileSync(
join(dir, "src", "file0.ts"),
`export const x0 = 0;\nexport const y = 99;\n`,
);
await patchFile(join(dir, "src", "file0.ts"));
await patchFile(join(dir, "src", "file0.ts"), client, dir);
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
expect(map).toContain("y");
@@ -77,7 +80,8 @@ describe("integration", () => {
it("validate detects new files", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
await initProject(dir);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
// Add new file
writeFileSync(join(dir, "src", "b.ts"), `export const b = 2;\n`);
@@ -91,7 +95,8 @@ describe("integration", () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
writeFileSync(join(dir, "src", "b.ts"), `export const b = 2;\n`);
await initProject(dir);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
// Delete a file
rmSync(join(dir, "src", "b.ts"));
@@ -104,7 +109,8 @@ describe("integration", () => {
it("validate detects changed signatures", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
await initProject(dir);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
// Change exports
writeFileSync(
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { withRetry, processFiles } from "../src/llm-batch.js";
import { withRetry, processFiles } from "../src/llm/llm-batch.js";
import { LLMError } from "../src/llm-error.js";
describe("withRetry", () => {
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { getCached, setCached } from "../src/llm-cache.js";
import { getCached, setCached } from "../src/llm/llm-cache.js";
import { existsSync, unlinkSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
+46 -86
View File
@@ -1,67 +1,17 @@
import { describe, it, expect } from "vitest";
import { extractFileLLM, extractFileHeuristic } from "../src/llm-extract.js";
import { extractFileLLM } from "../src/llm/llm-extract.js";
import { writeFileSync, mkdtempSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import type { LLMClient } from "../src/llm-client.js";
import type { LLMClient } from "../src/llm/llm-client.js";
describe("llm-extract heuristics", () => {
it("extracts TypeScript exports", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-"));
const file = join(dir, "test.ts");
writeFileSync(
file,
`export function foo() {}
export class Bar {}
export const baz = 1;
export type Qux = string;
export { a, b as c };
`,
);
const result = await extractFileLLM(file);
expect(result.exports).toContain("foo");
expect(result.exports).toContain("Bar");
expect(result.exports).toContain("baz");
expect(result.exports).toContain("Qux");
expect(result.exports).toContain("a");
expect(result.exports).toContain("b");
});
it("extracts TypeScript imports", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-"));
const file = join(dir, "test.ts");
writeFileSync(
file,
`import { foo } from "./bar";
import * as baz from "baz-lib";
import type { Qux } from "qux";
const x = require("legacy");
`,
);
const result = await extractFileLLM(file);
expect(result.deps).toContain("./bar");
expect(result.deps).toContain("baz-lib");
expect(result.deps).toContain("qux");
expect(result.deps).toContain("legacy");
});
it("infers purpose from filename patterns", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-"));
const file = join(dir, "userController.ts");
writeFileSync(file, `export class UserController {}`);
const result = await extractFileLLM(file);
expect(result.purpose).toMatch(/Controller|Exports/);
});
it("handles non-code files", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-"));
const file = join(dir, "Dockerfile");
writeFileSync(file, `FROM node:20\nWORKDIR /app`);
const result = await extractFileLLM(file);
expect(result.purpose).toBe("Container definition");
expect(result.exports).toEqual([]);
});
});
function createMockClient(response: string): LLMClient {
return {
async complete() {
return response;
},
};
}
describe("llm-extract with mock client", () => {
it("uses LLM client when provided", async () => {
@@ -69,11 +19,9 @@ describe("llm-extract with mock client", () => {
const file = join(dir, "test.ts");
writeFileSync(file, `export const foo = 1;`);
const mockClient: LLMClient = {
async complete() {
return "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing";
},
};
const mockClient = createMockClient(
"PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
);
const result = await extractFileLLM(file, mockClient, tmpdir());
expect(result.purpose).toBe("Test file");
@@ -81,42 +29,54 @@ describe("llm-extract with mock client", () => {
expect(result.concepts).toContain("testing");
});
it("throws without LLM client", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-"));
const file = join(dir, "test.ts");
writeFileSync(file, `export const foo = 1;`);
await expect(extractFileLLM(file)).rejects.toThrow("No LLM client configured");
});
it("skips large files", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-"));
const file = join(dir, "big.ts");
writeFileSync(file, "x".repeat(60 * 1024));
writeFileSync(file, "x".repeat(600 * 1024));
const mockClient: LLMClient = {
async complete() {
return "PURPOSE: Should not call\nDEPS: none\nCONCEPTS: none";
},
};
const mockClient = createMockClient(
"PURPOSE: Should not call\nDEPS: none\nCONCEPTS: none",
);
const result = await extractFileLLM(file, mockClient);
expect(result.purpose).toBe("Large/generated file");
expect(result.purpose).toBe("Large file");
});
it("falls back to heuristics without client", async () => {
it("skips binary files", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-"));
const file = join(dir, "utils.ts");
writeFileSync(file, `export function helper() {}`);
const file = join(dir, "image.png");
// Write some binary-looking content with null bytes
const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
writeFileSync(file, buf);
const result = await extractFileLLM(file);
expect(result.purpose).toBe("Utility functions");
expect(result.exports).toContain("helper");
const mockClient = createMockClient(
"PURPOSE: Should not call\nDEPS: none\nCONCEPTS: none",
);
const result = await extractFileLLM(file, mockClient);
expect(result.purpose).toBe("Binary file");
});
});
describe("extractFileHeuristic", () => {
it("returns structured data", async () => {
it("parses response with deps and concepts", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-"));
const file = join(dir, "test.ts");
writeFileSync(file, `export const x = 1;`);
writeFileSync(file, `import { foo } from "bar";\nexport const x = 1;`);
const result = await extractFileHeuristic(file);
expect(result.purpose).toBeDefined();
expect(Array.isArray(result.exports)).toBe(true);
expect(Array.isArray(result.deps)).toBe(true);
expect(Array.isArray(result.concepts)).toBe(true);
const mockClient = createMockClient(
"PURPOSE: Config module\nDEPS: bar, baz\nCONCEPTS: constants, config",
);
const result = await extractFileLLM(file, mockClient, tmpdir());
expect(result.purpose).toBe("Config module");
expect(result.deps).toEqual(["bar", "baz"]);
expect(result.concepts).toEqual(["constants", "config"]);
});
});
+5 -5
View File
@@ -2,9 +2,9 @@ import { describe, it, expect } from "vitest";
import { writeFileSync, mkdtempSync, readFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { createLLMClient } from "../src/llm-client.js";
import { extractFileLLM, extractPackageLLM } from "../src/llm-extract.js";
import { processFiles } from "../src/llm-batch.js";
import { createLLMClient } from "../src/llm/llm-client.js";
import { extractFileLLM, extractPackageLLM } from "../src/llm/llm-extract.js";
import { processFiles } from "../src/llm/llm-batch.js";
// Load .env file manually (no dotenv dependency needed)
function loadEnv(): Record<string, string> {
@@ -135,7 +135,7 @@ describe.skipIf(!hasKimiKey)("LLM integration with Kimi", () => {
it("skips large files without calling LLM", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-llm-"));
const file = join(dir, "big.ts");
writeFileSync(file, "x".repeat(60 * 1024));
writeFileSync(file, "x".repeat(600 * 1024));
let calls = 0;
const trackingClient = createLLMClient("kimi", { model: kimiModel });
@@ -146,7 +146,7 @@ describe.skipIf(!hasKimiKey)("LLM integration with Kimi", () => {
};
const result = await extractFileLLM(file, trackingClient, dir);
expect(result.purpose).toBe("Large/generated file");
expect(result.purpose).toBe("Large file");
expect(calls).toBe(0); // Should never call LLM for large files
});
});
+17
View File
@@ -0,0 +1,17 @@
import type { LLMClient } from "../src/llm/llm-client.js";
export function createMockFileClient(purpose = "Test file"): LLMClient {
return {
async complete() {
return `PURPOSE: ${purpose}\nDEPS: none\nCONCEPTS: testing`;
},
};
}
export function createMockPackageClient(): LLMClient {
return {
async complete() {
return "ROLE: Test package\nARCH: Test architecture";
},
};
}