diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 462a1ee..6f1f6f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,10 +22,10 @@ jobs: - run: swift build -c release shared_candidate: - uses: sass-maker/saas-maker/.github/workflows/daddy-macos-candidate.yml@7a3b78acbcd523bb968ff19e1b3ae9427d022b91 + uses: sass-maker/saas-maker/.github/workflows/daddy-macos-candidate.yml@bdea25593dab5857248255cc8cfe064260da35c9 with: app: contextdaddy - tooling-ref: 7a3b78acbcd523bb968ff19e1b3ae9427d022b91 + tooling-ref: bdea25593dab5857248255cc8cfe064260da35c9 site: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d37a7c1..76b2aad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: repository: sass-maker/saas-maker - ref: 7a3b78acbcd523bb968ff19e1b3ae9427d022b91 + ref: bdea25593dab5857248255cc8cfe064260da35c9 path: .daddy-tooling persist-credentials: false - name: Verify release tag and source commit diff --git a/DESIGN.md b/DESIGN.md index 42688f9..2a311d5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -66,3 +66,76 @@ Dense operational information can become tiring or ambiguous. Progressive disclo ## Skills usability correction The owner selected A, Guided Library, for onboarding. A dismissible three-step guide explains finding a skill, inspecting agent access, and local versus plugin-owned changes. It sits beside the workspace on wide windows and above it on smaller windows, can be reopened with Guide, and never performs a skill mutation. Native titled window chrome owns the traffic-light space; the root and skill scroll viewport use explicit available dimensions. Selecting a skill brings its inspector into view, with a route back to the guide. + + +## Skill organization ledger (owner selected B, 2026-09-27) + +Skills now uses a sortable, selectable ledger with columns for skill, location/scope, agent access, invocation, and instruction-copy count. Compact windows stack the same data into labelled rows; the existing bounded page scroll remains the only main scroll area. Search, ownership, agent, location, scope, and invocation filters compose. Selection persists across pages and filters and states that explicitly. + +The change tray offers Move to folder, Share with agents, Set invocation, and Compare selected copies. Clean up duplicates in the header opens the full catalog when no rows are selected. Every operation has one configuration-and-preview sheet, a visible Apply count, per-item inclusion, and a completion or partial-failure report. History retains undo. + +Cleanup requires an explicit keep-source choice for each group; there is no default canonical source that might select an archived project. Groups are paginated eight at a time. Plugin/system groups explain ownership. Full-folder comparison follows the instruction-only grouping and rejects mismatched support files or permissions. Version drift stays reviewable through Agent policies. The preview calls out location-dependent references and the future shared-edit impact before consolidation. + +The visual system remains native black/mint with rounded headings, monospaced paths, amber uncertainty, and existing button geometry. Owner previews and selection are retained under artifacts/design/skill-organization. No new runtime dependencies. + +## Recommended cleanup and folder context + +The owner clarified that folder counts alone do not solve cleanup, then requested implementation of the described recommended-plan workflow. The new Skills entry follows that action-first hierarchy using the established native ledger and preview components: scope selector, local name/source totals, an optional collapsed agent comparison, and a paginated decision queue. It develops the cleanup-workbench direction shown in the folder-context previews; no new visual identity replaces the previously selected system. + +Ready to preview, Needs a decision, Plugin ownership, and Keep independent separate verified actions from interpretation. Suggested source paths and affected agents are visible before opening a recommendation. Full-source and exposure evidence is disclosed on demand. The source library, History and refresh are reachable from the header. + +Choose folder and Quick targets scope discovery to a working directory. Agent comparisons remain collapsed initially so they do not push cleanup actions below the first screen. Partial coverage is visible above totals; plugin activation remains explicitly unverified. Agent rows show distinct names and sources separately, with automatic/manual/model-only/disabled policy and overlapping review counts. + +Preview sheets state the full-plan before/after projection, expose per-item inclusion and every mutation path, and keep completion or partial-failure results visible. History offers guarded restoration. Keep as-is records a reopenable review decision; unchanged source counts make clear that it did not clean up files. Unsupported plugin removal is described honestly rather than represented by an inert Apply button. + +## Folder-first simplification + +After the owner rejected the control-heavy interface and asked us to implement the described simplification, folder search and path entry moved directly into Skills. Cleanup proposals precede inventory totals; counts and coverage remain in a disclosure. Project files and instructions are a secondary inspector rather than a competing primary sidebar destination. Proposal rows expose source, affected agents and invocation policies before review. The existing native visual system and preview/apply/restore mechanics remain. + +## Navigation correction: build 16 + +The approved sortable ledger is the default Skills view. A persistent segmented control names Library, Cleanup, and Agent access rather than requiring users to find differently placed return buttons. Folder scope is shared across the library, cleanup recommendations and agent access summaries; the current path remains visible. Plugin counts use plain ownership language. A source with no known route explains the uncertainty and offers sharing when locally editable. + +## Final simplified interface: build 17 + +Owner explicitly requested one final single-screen output after approving one list and a details panel. Skills now has a compact title, folder menu, search, collapsed filters, and one list. Details sit beside the list when space permits and below it on narrow windows. Cleanup is a dialog; advanced operations and History are under More. No view-mode navigation, hero metrics, automatic onboarding cards or empty bulk-action trays appear. + +## Answer-first pages (owner delegated, 2026-09-27) + +The owner delegated direction selection for Skills, Agent activity and Diagnostics. The established native black/mint system stays. Skills adds a scoped location map before its ledger: each location discloses logical routes, resolved physical sources, discovered agent access and matching-instruction leads. A location filters the library and its unselected cleanup scope; exact-folder validation still gates consolidation. Route counts are not added as unique skills. Unrecognized locations and partial coverage remain explicit limitations. + +Agent activity replaces the OpenTelemetry navigation label. Recent recorded runs and review signals precede collapsed measurements, source notes and interpretation details. Missing adapters are unavailable rather than zero; trace presence is not a successful outcome. Diagnostics always opens configuration health, leads with detected issues and their impact/next step, identifies scanned files, and collapses raw coverage evidence. No new telemetry adapter, memory manager or unattended cleanup is implied. + +## All-agent pages (build 23) + +Skills, Agent activity and Diagnostics support selecting Codex, Claude, Cursor, Devin and Grok. Activity combines existing agent-isolated indexed history with a separate metadata-only activity-file browser. It reads filenames and modification dates under known per-agent roots, excludes linked roots/descendants and irrelevant Cursor MCP/canvas directories, and reports bounded scan coverage. File writes are never claimed as task completions or token totals. Cursor billing remains in its own dashboard; no new billing or live-trace adapter is implied. + +Diagnostics resolves discovered skill availability, invocation uncertainty and overlap per agent, with review briefs and navigation preserving both agent and folder scope. Codex structural startup-configuration checks remain a separately identified adapter; other agents receive clearly labeled setup investigation paths, not invented successful configuration checks. Local history, source files and live OTEL remain separate evidence categories. + +## Completed local management workflows (build 25) + +The library opens on current local definitions, excluding archived and installer-owned files from the default list and location map. Plugin files have a separate explicit entry, and the folder map is expandable so search and the ledger remain immediately reachable. The existing overlap/scope decision workbench is reachable from More. Cleanup snapshots its reviewed definitions; a post-apply rescan cannot change the approved count. + +Diagnostics now performs bounded structural checks for all five agents: Codex/Grok MCP command references and supported Codex misplaced settings; Claude/Cursor/Devin JSON object validity and declared MCP launcher references. User files and files directly in the selected folder are identified individually as checked, absent or unverified. Linked paths and oversized/unreadable files are not parsed. Credential values, URLs, environment values and arguments never enter findings. No executable is launched and no remote server is contacted. This is not a full agent schema, connectivity, permissions or imported/managed settings validator. Repair handoffs retain the selected issues and verify the same sources. + +Invocation editing supports Codex policy files, Devin triggers and shared Claude/Cursor/Grok frontmatter. The resolver follows their distinct rules: Codex ignores other agents' frontmatter invocation flags, Devin uses triggers, and Grok user-invocable=false disables both user and model visibility. A requested restriction that does not control Codex is surfaced as a policy review item with a direct filtered-library handoff. Known project paths in recorded activity can open that folder's skills for the same agent. + +Evidence: full suite 117 passed after the bounded scan contention fix; subsequent focused policy/native suites passed after final changes. Native fixture cleanup applied, reduced two definitions to one source retaining Codex and Claude routes, and restored through History with both files intact. Cursor fixture diagnosis was captured, repaired and independently reported detector-cleared. Real skills and agent configuration were not changed. Live telemetry still requires an agent that emits the supported signals; local history and file metadata remain separately labeled. + +## Plugin ledger (build 28) + +The owner delegated design judgment after seeing three previews. The plugin ledger uses the established native visual system: one row per agent/marketplace/plugin identity, adjacent evidence inspector at wide widths, stacked detail on narrow windows. Skills → Manage plugins and More → Manage plugins both open this view. + +A bounded read-only scan inventories the default Codex and Claude cache hierarchies, including plugins with no skills. It groups versions, counts skill directory names, measures regular-file bytes, and labels incomplete sizes as lower bounds. Linked directories are not traversed; dependency trees and repository internals are excluded from size measurement. Whole SKILL.md hashes identify matching local instructions without claiming complete-folder equivalence. + +Claude registry version 2 supplies per-version installation references and user/project/local scopes. Missing, malformed or unsupported registry data cannot establish an unreferenced version. Codex and Claude explicit enablement declarations are shown with their source files; selected folders add ancestor/local settings. These are individual declarations, not effective activation or observed invocation. Custom homes, managed configuration, trust, profiles, runtime overrides and Cursor/Devin/Grok plugin ownership remain unresolved and are labelled. + +The management sheet supplies the exact plugin identity, scope evidence, an agent-ready brief, official owner instructions and a recheck action. Disable/uninstall is performed in the owning plugin manager. ContextDaddy does not offer direct cache deletion or infer safe deletion from age, modification dates or a missing registry reference. No actual plugins were modified during this implementation. + +## Completion workflows, build 30 + +Preserve the owner-selected sortable ledger and delegated answer-first direction. Memory uses the same black/mint hierarchy, path typography, folder/search controls, paginated list and responsive selected-document inspector. It adds no alternate visual language. Separate Memory navigation reflects the owner's later three-page structure; Skills retains invocation controls, which Memory does not imitate. + +Content comparison is explicit because it reads local document bodies. Matching contents link to counterpart locations; independent scope remains a review decision. Document changes require a before/after preview, with archive consequences and guarded recovery visible. Plugin action sheets show exact owner, scope and command before applying. Activity distinguishes filtered session history from unfiltered live aggregates and gives direct folder-preserving review actions. + +Responsive native renders cover 390, 768 and 1440 points. These are fixture renders, not evidence that cloud memories or unsupported agent adapters are resolved. Installed interactive checks are tracked separately in artifacts/design/complete-workflows/verification.md. diff --git a/PRODUCT.md b/PRODUCT.md index 1cbe5f0..df4fecf 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -11,7 +11,7 @@ The primary user is a developer running Codex, Claude, Cursor, Devin, or Grok on ## Product promises - One physical skill is one ledger record, even when it has many logical exposures. -- The primary navigation follows user decisions: Usage, Skills, Projects, and OpenTelemetry. Raw source files and configuration diagnostics remain available as secondary Files & diagnostics, not competing representations of skills. +- The primary navigation follows user decisions: Usage, Skills, and OpenTelemetry. Project files and instructions remain a secondary inspector; working-folder selection lives directly in Skills. Raw source files and configuration diagnostics remain available as secondary Files & diagnostics, not competing representations of skills. - Policy is runtime-specific and explains whether it is explicit or derived. - Non-auto skills show how to invoke them. - One selected-agent skill-access view makes automatic, manual-only, model-only, disabled, undiscoverable, and review-needed policy directly filterable. @@ -35,7 +35,19 @@ The primary user is a developer running Codex, Claude, Cursor, Devin, or Grok on - OTEL is directly reachable and reports a disconnected local source as unavailable, never as zero activity. - Overlapping operation durations are never added together as wall time. - Discovery is read-only. Skill changes require an explicit preview and apply action; edits, local imports/updates, sharing links, and archival retain recovery evidence. +- Skills opens one sortable library with folder selection, search and a selected-skill inspector. Cleanup opens as a dialog; agent access is part of the selected skill. +- Local skill names, physical sources, agent routes, archived copies and installer-owned definitions are distinct counts. Names do not establish equivalent capabilities, and the 100–200 curation target never causes automatic retirement. +- Folder context uses a targeted scan of global roots and the selected directory's ancestors, excluding sibling/descendant projects and cache-only material. Policies are resolved after scoping; multiple applicable aliases remain attached to one physical source. Per-agent counts are discovery evidence, with partial coverage and unverified activation shown explicitly. +- Cleanup separates verified whole-folder consolidation, decisions about purpose/version overlap or global links, independent project copies, and plugin ownership. Different versions in non-overlapping project scopes are not treated as competing installations. +- A recommended source is proposed for matching local copies. The owner accepts its destination and impact in a preview before applying. Consolidation cannot make an independent checkout depend on an external source. +- Scope cleanup removes only explicitly previewed global directory links where a project route for the same provider already exists. It preserves the physical source and project routes, and explains loss of availability elsewhere. Moving a source alone does not narrow access. +- Purpose overlap is a review lead, not a semantic merge. The owner can compare sources, preview archiving a chosen local source, or keep the workflows separate. Kept decisions are reversible app-local review state and never count as removed skills. - Skills opens one searchable library across physical definitions, logical exposures, agents, and ownership. Favorites and tags are app-local organization. +- Skills provides a sortable ledger for physical location and scope, discovered agent access, and per-agent invocation. Mixed policies and unverified activation remain distinct. +- Bulk move, share, invocation, and cleanup actions prepare per-item previews. Moves leave a forwarding link at the original route; consolidation archives a whole-folder-identical copy and replaces it with a link to an explicitly selected source. +- Cleanup compares complete regular-file contents, folder structure, and permissions, and revalidates both folders before applying. Different versions are reviewed separately. Plugin/system caches and protected files are not changed. +- Codex invocation uses the skill-local policy file; Claude/Cursor changes use shared frontmatter and explain cross-agent impact. Unsupported or ambiguous policy edits are declined. +- Bulk apply stops on the first failure, reports the completed count, and retains individual recovery receipts. Each mutation is serialized; a batch is not an all-or-nothing transaction. - Added skill directories extend bounded discovery without automatically granting an agent access. Recognized agent roots retain their scope and policy evidence. - Plugin and system definitions are managed by their owner. ContextDaddy does not rewrite their caches or execute imported scripts. - Whole-folder local updates preserve support files and executable permissions. Conflicting destinations, stale previews, symlinked sources, oversized folders, and protected configuration files are rejected. @@ -54,4 +66,44 @@ The primary user is a developer running Codex, Claude, Cursor, Devin, or Grok on - Shipping, notarizing, or distributing before local behavior is proven. - Reading document bodies during background discovery; previews are explicit, bounded user actions. - Retaining or displaying config credentials, headers, environment values, MCP arguments, or unrelated configuration bodies; health checks inspect only bounded structural fields. -- Automatically deleting, merging, linking, or rewriting skills based on redundancy analysis. +- Unattended deletion, merging, linking, or rewriting of skills based only on redundancy analysis. Cleanup requires a reviewable source recommendation or explicit source choice, whole-folder verification for consolidation, preview, and apply. + +## Answer-first pages (owner delegated, 2026-09-27) + +The owner delegated direction selection for Skills, Agent activity and Diagnostics. The established native black/mint system stays. Skills adds a scoped location map before its ledger: each location discloses logical routes, resolved physical sources, discovered agent access and matching-instruction leads. A location filters the library and its unselected cleanup scope; exact-folder validation still gates consolidation. Route counts are not added as unique skills. Unrecognized locations and partial coverage remain explicit limitations. + +Agent activity replaces the OpenTelemetry navigation label. Recent recorded runs and review signals precede collapsed measurements, source notes and interpretation details. Missing adapters are unavailable rather than zero; trace presence is not a successful outcome. Diagnostics always opens configuration health, leads with detected issues and their impact/next step, identifies scanned files, and collapses raw coverage evidence. No new telemetry adapter, memory manager or unattended cleanup is implied. + +## All-agent pages (build 23) + +Skills, Agent activity and Diagnostics support selecting Codex, Claude, Cursor, Devin and Grok. Activity combines existing agent-isolated indexed history with a separate metadata-only activity-file browser. It reads filenames and modification dates under known per-agent roots, excludes linked roots/descendants and irrelevant Cursor MCP/canvas directories, and reports bounded scan coverage. File writes are never claimed as task completions or token totals. Cursor billing remains in its own dashboard; no new billing or live-trace adapter is implied. + +Diagnostics resolves discovered skill availability, invocation uncertainty and overlap per agent, with review briefs and navigation preserving both agent and folder scope. Codex structural startup-configuration checks remain a separately identified adapter; other agents receive clearly labeled setup investigation paths, not invented successful configuration checks. Local history, source files and live OTEL remain separate evidence categories. + +## Completed local management workflows (build 25) + +The library opens on current local definitions, excluding archived and installer-owned files from the default list and location map. Plugin files have a separate explicit entry, and the folder map is expandable so search and the ledger remain immediately reachable. The existing overlap/scope decision workbench is reachable from More. Cleanup snapshots its reviewed definitions; a post-apply rescan cannot change the approved count. + +Diagnostics now performs bounded structural checks for all five agents: Codex/Grok MCP command references and supported Codex misplaced settings; Claude/Cursor/Devin JSON object validity and declared MCP launcher references. User files and files directly in the selected folder are identified individually as checked, absent or unverified. Linked paths and oversized/unreadable files are not parsed. Credential values, URLs, environment values and arguments never enter findings. No executable is launched and no remote server is contacted. This is not a full agent schema, connectivity, permissions or imported/managed settings validator. Repair handoffs retain the selected issues and verify the same sources. + +Invocation editing supports Codex policy files, Devin triggers and shared Claude/Cursor/Grok frontmatter. The resolver follows their distinct rules: Codex ignores other agents' frontmatter invocation flags, Devin uses triggers, and Grok user-invocable=false disables both user and model visibility. A requested restriction that does not control Codex is surfaced as a policy review item with a direct filtered-library handoff. Known project paths in recorded activity can open that folder's skills for the same agent. + +Evidence: full suite 117 passed after the bounded scan contention fix; subsequent focused policy/native suites passed after final changes. Native fixture cleanup applied, reduced two definitions to one source retaining Codex and Claude routes, and restored through History with both files intact. Cursor fixture diagnosis was captured, repaired and independently reported detector-cleared. Real skills and agent configuration were not changed. Live telemetry still requires an agent that emits the supported signals; local history and file metadata remain separately labeled. + +## Plugin ledger (build 28) + +The owner delegated design judgment after seeing three previews. The plugin ledger uses the established native visual system: one row per agent/marketplace/plugin identity, adjacent evidence inspector at wide widths, stacked detail on narrow windows. Skills → Manage plugins and More → Manage plugins both open this view. + +A bounded read-only scan inventories the default Codex and Claude cache hierarchies, including plugins with no skills. It groups versions, counts skill directory names, measures regular-file bytes, and labels incomplete sizes as lower bounds. Linked directories are not traversed; dependency trees and repository internals are excluded from size measurement. Whole SKILL.md hashes identify matching local instructions without claiming complete-folder equivalence. + +Claude registry version 2 supplies per-version installation references and user/project/local scopes. Missing, malformed or unsupported registry data cannot establish an unreferenced version. Codex and Claude explicit enablement declarations are shown with their source files; selected folders add ancestor/local settings. These are individual declarations, not effective activation or observed invocation. Custom homes, managed configuration, trust, profiles, runtime overrides and Cursor/Devin/Grok plugin ownership remain unresolved and are labelled. + +The management sheet supplies the exact plugin identity, scope evidence, an agent-ready brief, official owner instructions and a recheck action. Disable/uninstall is performed in the owning plugin manager. ContextDaddy does not offer direct cache deletion or infer safe deletion from age, modification dates or a missing registry reference. No actual plugins were modified during this implementation. + +## Memory, activity answers and owner actions (build 30) + +Memory is a first-class sidebar destination using the approved sortable-ledger direction. It separates instructions/rules from saved-memory stores; supports folder paths, folder browsing, agent/type/search filters, largest-first ordering, explicit bounded content comparison, and exact-copy counterpart navigation. Selected documents can be viewed, edited or archived with a preview, private recovery history, stale-file checks, permission preservation and guarded restore. Codex agent-owned memory stays read-only; no real user memories were automatically rewritten. Default Claude and Codex stores are metadata-indexed; custom roots, cloud memory, imports, managed policies and cross-agent AGENTS loading remain unverified. Claude project-store names are not reverse-decoded into assumed folder ownership. + +Activity opens with what happened, what used tokens, and what to review. Agent-isolated session history has a last-activity window and exact folder/descendant matching, excluding unknown attribution when a filter requires it. Token totals are explicitly session-lifetime totals, not period consumption or context-window occupancy. Largest sessions link to Memory and Skills with their folder. Missing indexes and refresh errors remain visible. Live telemetry is separately scoped to its existing aggregate window; history filters do not pretend to filter telemetry. + +Plugin management now previews allowlisted owner-CLI commands in the app. Claude supports enable, disable and uninstall for verified user/project/local registrations; uninstall preserves plugin data and never requests dependency pruning. Codex supports removal only when a user-scope declaration is verified. Enablement remains in Codex settings because this installed CLI exposes no persistent enable/disable command. Managed or unknown registrations remain read-only. Actions recheck evidence before launch, discard raw process output, time out after 45 seconds, persist receipts, then rescan to distinguish command completion from verified local state. No real plugin action was executed during development; a disposable owner-CLI fixture exercised the command path. diff --git a/README.md b/README.md index 755ceae..6e4c432 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ The primary navigation is **Usage**, **Skills**, **Projects**, and **OpenTelemet - Duplicate-byte estimates cover exact local file copies only; the app does not claim context savings or infer that an unobserved skill is unused. - In-app local telemetry for Prometheus metrics and Tempo sessions, with no external dashboard required for the core workflow. - Usage first scan shows side-by-side Codex/Claude allowance followed by one local history chart including Devin. The historical chart has shared agent filters, model/model-provider/project grouping where supported, range, day/week/month scale, generated/cache/cost metrics where available, selectable periods and exact breakdown. Model, project, and session drill-downs remain below. Cache reads, generated tokens, and estimated cost stay separate; unpriced models are flagged. +- Skills can run an explicit, read-only **Read local history** scan. Claude and Devin `skill` tool calls are counted separately from Cursor/Grok skill-file reads and Codex tool calls that reference a `SKILL.md` path. The library shows last-seen dates, agent and folder evidence, 30/90-day or all-history windows, and an evidence-only filter. Cursor dates use transcript modification time because its records lack event timestamps. Name-only tool calls cannot identify a particular same-name copy; no recorded event never means unused. This backfill is held in memory for the app session and does not automatically remove skills. - Project grouping is a separately labelled session ledger, joining ccusage session IDs to Codex's read-only thread-index `cwd`, Grok's project paths, and Claude's encoded project slugs. It queries only Codex rollout path and working directory metadata, never prompt bodies. Buckets use session last activity, not an invented daily allocation; totals may not reconcile to daily accounting. Missing session identity remains **Unattributed**. Model-provider labels are inferred from reported model names, not billing endpoints; unknown aliases remain unknown. Devin's indexed daily tokens participate in the shared model and provider grouping; project attribution remains unavailable. - Provider allowance for Codex and Claude is fetched on **Check both allowances**, or on opening Usage after the user enables the opt-in automatic switch (at most once per 15 minutes). It is never added to local token history. Codex reset-credit expiry is shown only when reported; detail rows may be capped. - Skills and OTEL review panels can **Copy all issues** into an agent-ready brief with IDs, evidence, source scope, and verification limits. After skill edits, **Verify after changes** rescans and distinguishes detector-cleared, still-detected, and unverified findings. The library supports previewed local skill edits with recovery; OTEL signals need a new comparable observation window. @@ -78,7 +79,7 @@ ContextDaddy was extracted from StorageDaddy's MIT-licensed context work, with t ## Privacy boundary -ContextDaddy inspects well-known agent roots and opens at most 64 KiB of each `SKILL.md` to parse frontmatter and compute a one-way SHA-256 fingerprint; it does not retain or display the skill body during discovery. Other eligible document bodies open only after an explicit Preview action, through a guarded regular-file reader capped at 256 KiB, and MCP configuration bodies are never previewed. Configuration health reads only bounded structural fields and ignores credential, header, environment, and MCP argument values. It skips secret-shaped and generated directories during discovery. Telemetry is fetched from a loopback-only local stack whose collector removes prompt, response, command, argument, result, account, and host fields before persistence. Offline ccusage history remains in memory for the current app session. ContextDaddy performs no config writes and makes no claim about exact internet bytes without a dedicated sensor. +ContextDaddy inspects well-known agent roots and opens at most 64 KiB of each `SKILL.md` to parse frontmatter and compute a one-way SHA-256 fingerprint; it does not retain or display the skill body during discovery. Other eligible document bodies open only after an explicit Preview action, through a guarded regular-file reader capped at 256 KiB, and MCP configuration bodies are never previewed. Configuration health reads only bounded structural fields and ignores credential, header, environment, and MCP argument values. It skips secret-shaped and generated directories during discovery. The explicit skill-history scan reads local transcript records, extracts structured skill tool calls and file-path evidence, and retains only names, agents, session keys, dates, project paths, and evidence classes in memory; prompt, response, and command bodies are discarded. Telemetry is fetched from a loopback-only local stack whose collector removes prompt, response, command, argument, result, account, and host fields before persistence. Offline ccusage history remains in memory for the current app session. ContextDaddy performs no config writes and makes no claim about exact internet bytes without a dedicated sensor. ## Status diff --git a/Sources/ContextCore/AIContextDiscovery.swift b/Sources/ContextCore/AIContextDiscovery.swift index 8b7ebd4..6d16e5d 100644 --- a/Sources/ContextCore/AIContextDiscovery.swift +++ b/Sources/ContextCore/AIContextDiscovery.swift @@ -51,6 +51,37 @@ public enum AIContextDiscovery { return AIContextDiscoveryReport(items: items, folderRankings: rankings, coverage: coverage, elapsed: Date().timeIntervalSince(started)) } + + /// Inspect one working directory and its ancestors, never sibling projects or caches. + public static func discoverFolder(_ directory: URL, home: URL = FileManager.default.homeDirectoryForCurrentUser) throws -> AIContextDiscoveryReport { + let root = directory.resolvingSymlinksInPath().standardizedFileURL + guard isDirectory(root) else { throw SkillManagementError(message: "Choose an existing folder.") } + let started = Date() + var state = State(configuration: .init(home: home, projectRoots: [])) + try state.discoverGlobals() + var ancestor = root + while ancestor.path != "/" { + try Task.checkCancellation() + // Home agent roots were already indexed with global scope. + if ancestor != home.resolvingSymlinksInPath().standardizedFileURL { + state.coverageRoots.append(ancestor.path) + try state.projectMarkers(at: ancestor) + } + ancestor.deleteLastPathComponent() + } + let items = state.items.values.sorted { $0.path < $1.path } + var coverage = AIContextCoverage(roots: state.coverageRoots, visitedEntries: state.visited, + itemLimitReached: state.itemLimitReached, entryLimitReached: state.entryLimitReached, + unreadableCount: state.unreadable, skippedLinks: state.skippedLinks, + notes: state.notes + ["Targeted folder scan. Plugin activation and live prompt loading are not verified; plugin caches are excluded."]) + coverage.skillDepthReached = state.skillDepthReached + return AIContextDiscoveryReport(items: items, folderRankings: rankings(items: items, in: root, home: home), + coverage: coverage, elapsed: Date().timeIntervalSince(started)) + } + + public static func rankings(items: [AIContextItem], in directory: URL, home: URL = FileManager.default.homeDirectoryForCurrentUser, preserveSkillAliases: Bool = false) -> [AIContextFolderRanking] { + rank(items: items, roots: [directory.resolvingSymlinksInPath().standardizedFileURL.path], home: home, deduplicateSkills: !preserveSkillAliases) + } } private struct State { @@ -302,8 +333,12 @@ private struct State { pluginEntries += 1; visited += 1; return true } mutating func children(_ root: URL) -> [URL] { - do { return try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil).sorted { $0.path < $1.path } } - catch { unreadable += 1; return [] } + do { return try BoundedDirectoryReader.children(root).sorted { $0.path < $1.path } } + catch { + unreadable += 1 + if notes.count < 40 { notes.append("Could not read directory: \(root.path). \(error.localizedDescription)") } + return [] + } } } @@ -350,7 +385,7 @@ private func item(url: URL, resolved: String?, info: stat, provider: AIContextPr return AIContextItem(id: path, path: path, resolvedPath: resolved, name: kind == .skill ? url.deletingLastPathComponent().lastPathComponent : url.lastPathComponent, scope: scope, kind: kind, provider: provider, source: source, logicalBytes: Int64(info.st_size), allocatedBytes: allocated, modified: contextDate(info), applicability: origin) } -private func rank(items: [AIContextItem], roots: Set, home: URL) -> [AIContextFolderRanking] { +private func rank(items: [AIContextItem], roots: Set, home: URL, deduplicateSkills: Bool = true) -> [AIContextFolderRanking] { let candidateRoots = roots.isEmpty ? Set(items.filter { $0.scope == .project }.map { URL(fileURLWithPath: $0.path).deletingLastPathComponent().path }) : roots return candidateRoots.flatMap { path -> [AIContextFolderRanking] in let effectivePath = canonicalPath(path) @@ -378,7 +413,7 @@ private func rank(items: [AIContextItem], roots: Set, home: URL) -> [AIC } var physicalSkills = Set() relevant.removeAll { contribution in - guard contribution.item.kind == .skill else { return false } + guard deduplicateSkills, contribution.item.kind == .skill else { return false } return !physicalSkills.insert(contribution.item.resolvedPath ?? contribution.item.path).inserted } // A Codex override in one directory replaces that directory's AGENTS.md. @@ -423,3 +458,34 @@ private func canonicalPath(_ path: String) -> String { let value = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path return value == "/private/var" ? "/var" : value.hasPrefix("/private/var/") ? "/var/" + value.dropFirst("/private/var/".count) : value } + +/// Filesystem access may wait for a disconnected volume or an OS permission prompt. +/// Bound both the caller's wait and outstanding reads; never create unlimited stuck workers. +enum BoundedDirectoryReader { + private final class ResultBox: @unchecked Sendable { + let lock = NSLock() + var result: Result<[URL], Error>? + } + private static let permits = DispatchSemaphore(value: 8) + static func children(_ root: URL, timeout: TimeInterval = 2, + read: @escaping @Sendable (URL) throws -> [URL] = { try FileManager.default.contentsOfDirectory(at: $0, includingPropertiesForKeys: nil) }) throws -> [URL] { + let deadline = DispatchTime.now() + max(0, timeout) + guard permits.wait(timeout: deadline) == .success else { + throw SkillManagementError(message: "Directory access is still pending. Retry when unavailable locations recover.") + } + let box = ResultBox(), signal = DispatchSemaphore(value: 0) + // A dedicated thread avoids starving behind callers blocking a shared + // executor. The permits above cap outstanding filesystem reads at eight. + Thread.detachNewThread { + let result = Result { try read(root) } + box.lock.lock(); box.result = result; box.lock.unlock() + permits.signal() + signal.signal() + } + guard signal.wait(timeout: deadline) == .success else { + throw SkillManagementError(message: "Directory access timed out; check macOS access or volume availability.") + } + box.lock.lock(); let result = box.result; box.lock.unlock() + return try result!.get() + } +} diff --git a/Sources/ContextCore/ActivityAnswers.swift b/Sources/ContextCore/ActivityAnswers.swift new file mode 100644 index 0000000..4287616 --- /dev/null +++ b/Sources/ContextCore/ActivityAnswers.swift @@ -0,0 +1,42 @@ +import Foundation + +public struct ActivityAnswers: Sendable { + public let sessions: [UsageSession] + public let missingDates: Int + public let missingProjects: Int + public let input: UInt64 + public let cached: UInt64 + public let output: UInt64 + public let largest: [UsageSession] + + public init(sessions: [UsageSession], runtime: AgentRuntime, folder: String = "", days: Int? = 7, now: Date = Date()) { + let owned = sessions.filter { $0.agent.caseInsensitiveCompare(runtime.rawValue) == .orderedSame } + let start = days.map { now.addingTimeInterval(-Double($0) * 86400) } + let parser = ISO8601DateFormatter() + func date(_ value: String?) -> Date? { + guard let value else { return nil } + parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = parser.date(from: value) { return date } + parser.formatOptions = [.withInternetDateTime] + return parser.date(from: value) + } + missingDates = owned.filter { date($0.lastActivity) == nil }.count + missingProjects = owned.filter { $0.project == nil }.count + let path = folder.isEmpty ? "" : URL(fileURLWithPath: folder).standardizedFileURL.path + let selected = owned.filter { session in + if let start { + guard let date = date(session.lastActivity), date >= start, date <= now else { return false } + } + guard !path.isEmpty else { return true } + guard let project = session.project, project.hasPrefix("/") else { return false } + let source = URL(fileURLWithPath: project).standardizedFileURL.path + return source == path || source.hasPrefix(path + "/") + }.sorted { ($0.lastActivity ?? "") > ($1.lastActivity ?? "") } + self.sessions = selected + func sum(_ value: (UsageTotals) -> UInt64) -> UInt64 { + selected.reduce(0) { total, session in let (next, overflow) = total.addingReportingOverflow(value(session.totals)); return overflow ? .max : next } + } + input = sum(\.inputTokens); cached = sum(\.cacheReadTokens); output = sum(\.outputTokens) + largest = selected.sorted { $0.totals.totalTokens > $1.totals.totalTokens }.prefix(3).map { $0 } + } +} diff --git a/Sources/ContextCore/AgentActivityFiles.swift b/Sources/ContextCore/AgentActivityFiles.swift new file mode 100644 index 0000000..43b2f9f --- /dev/null +++ b/Sources/ContextCore/AgentActivityFiles.swift @@ -0,0 +1,67 @@ +import Foundation + +public struct AgentActivityFile: Identifiable, Sendable { + public let path: String + public let modified: Date + public var id: String { path } +} + +public struct AgentActivityFiles: Sendable { + public let runtime: AgentRuntime + public let root: String + public let files: [AgentActivityFile] + public let status: String + public let partial: Bool + + /// Reads filenames and modification dates only. Never opens transcript bodies. + public static func scan(runtime: AgentRuntime, home: URL = FileManager.default.homeDirectoryForCurrentUser, + maximumEntries: Int = 12_000) -> Self { + let relative: String = switch runtime { + case .codex: ".codex/sessions" + case .claude: ".claude/projects" + case .cursor: ".cursor/projects" + case .devin: ".local/share/devin/cli/transcripts" + case .grok: ".grok/sessions" + } + let root = home.appendingPathComponent(relative) + let manager = FileManager.default + guard manager.fileExists(atPath: root.path) else { + return Self(runtime: runtime, root: root.path, files: [], status: "No local activity folder found", partial: false) + } + if (try? root.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true { + return Self(runtime: runtime, root: root.path, files: [], status: "Linked activity root not scanned", partial: true) + } + let keys: Set = [.isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey, .contentModificationDateKey] + var unreadable = false + guard let enumerator = manager.enumerator(at: root, includingPropertiesForKeys: Array(keys), + options: [.skipsPackageDescendants, .skipsHiddenFiles], errorHandler: { _, _ in unreadable = true; return true }) else { + return Self(runtime: runtime, root: root.path, files: [], status: "Local activity folder is unreadable", partial: true) + } + let deadline = Date().addingTimeInterval(3) + var files: [AgentActivityFile] = [] + var visited = 0 + var limited = false + for case let url as URL in enumerator { + visited += 1 + if visited > maximumEntries || Date() > deadline || Task.isCancelled { limited = true; break } + guard let values = try? url.resourceValues(forKeys: keys) else { unreadable = true; continue } + if values.isSymbolicLink == true { enumerator.skipDescendants(); continue } + if values.isDirectory == true { + if ["mcps", "canvases", "memory", "tool-results", "node_modules"].contains(url.lastPathComponent) || enumerator.level > 6 { enumerator.skipDescendants() } + continue + } + guard values.isRegularFile == true, let date = values.contentModificationDate else { continue } + let accepted: Bool = switch runtime { + case .codex, .claude: url.pathExtension == "jsonl" + case .cursor: url.path.contains("/agent-transcripts/") && ["txt", "jsonl"].contains(url.pathExtension) + case .devin: url.pathExtension == "json" + case .grok: ["jsonl", "json"].contains(url.pathExtension) && url.lastPathComponent != "prompt_history.jsonl" + } + if accepted { files.append(AgentActivityFile(path: url.path, modified: date)) } + } + let recent = files.sorted { $0.modified > $1.modified } + return Self(runtime: runtime, root: root.path, files: Array(recent.prefix(200)), + status: limited || unreadable ? "Partial local activity scan" : files.isEmpty ? "No matching activity files found" : "Local activity files found", + partial: limited || unreadable || files.count > 200) + } +} diff --git a/Sources/ContextCore/AgentConfigurationHealth.swift b/Sources/ContextCore/AgentConfigurationHealth.swift index 91740bb..3487e09 100644 --- a/Sources/ContextCore/AgentConfigurationHealth.swift +++ b/Sources/ContextCore/AgentConfigurationHealth.swift @@ -1,4 +1,5 @@ import Foundation +import CryptoKit public enum ConfigurationIssueSeverity: String, Sendable, Equatable { case warning = "Warning" @@ -36,15 +37,33 @@ public struct ConfigurationHealthIssue: Identifiable, Sendable, Equatable { } } +public struct ConfigurationFileCheck: Identifiable, Sendable, Equatable { + public enum Status: String, Sendable { case checked = "Checked", missing = "Not present", unverified = "Not checked" } + public let runtime: AgentRuntime + public let path: String + public let status: Status + public let detail: String + public var id: String { runtime.rawValue + path } +} + public struct ConfigurationHealthReport: Sendable, Equatable { public let issues: [ConfigurationHealthIssue] public let scannedFiles: [String] public let generatedAt: Date + public let files: [ConfigurationFileCheck] - public init(issues: [ConfigurationHealthIssue], scannedFiles: [String], generatedAt: Date = Date()) { + public init(issues: [ConfigurationHealthIssue], scannedFiles: [String], generatedAt: Date = Date(), files: [ConfigurationFileCheck] = []) { self.issues = issues self.scannedFiles = scannedFiles self.generatedAt = generatedAt + self.files = files.isEmpty ? scannedFiles.map { .init(runtime: .codex, path: $0, status: .checked, detail: "Structural checks") } : files + } + + public func forAgent(_ runtime: AgentRuntime) -> Self { + let selected = files.filter { $0.runtime == runtime } + return .init(issues: issues.filter { $0.runtime == runtime }, + scannedFiles: selected.filter { $0.status == .checked }.map(\.path), + generatedAt: generatedAt, files: selected) } public static let empty = ConfigurationHealthReport(issues: [], scannedFiles: []) @@ -107,10 +126,11 @@ public enum AgentConfigurationAuditor { var hasURL = false } - private static func auditCodexConfig( + static func auditCodexConfig( _ body: String, url: URL, - configuration: Configuration + configuration: Configuration, + runtime: AgentRuntime = .codex ) -> [ConfigurationHealthIssue] { var issues: [ConfigurationHealthIssue] = [] var table = "" @@ -127,7 +147,7 @@ public enum AgentConfigurationAuditor { let key = line[.. Bool { let manager = FileManager.default + if command.hasPrefix("~/") { return manager.isExecutableFile(atPath: configuration.home.appendingPathComponent(String(command.dropFirst(2))).path) } if command.hasPrefix("/") { return manager.isExecutableFile(atPath: command) } if command.contains("/") { let base: URL @@ -205,7 +226,7 @@ public enum AgentConfigurationAuditor { private static func unquote(_ value: S) -> String { let string = String(value).trimmingCharacters(in: .whitespaces) - guard string.count >= 2, string.first == "\"", string.last == "\"" else { return string } + guard string.count >= 2, (string.first == "\"" && string.last == "\"") || (string.first == "'" && string.last == "'") else { return string } return String(string.dropFirst().dropLast()) } diff --git a/Sources/ContextCore/AgentSetupAudit.swift b/Sources/ContextCore/AgentSetupAudit.swift new file mode 100644 index 0000000..f669ff4 --- /dev/null +++ b/Sources/ContextCore/AgentSetupAudit.swift @@ -0,0 +1,138 @@ +import Foundation +import CryptoKit + +/// Bounded, read-only checks of documented local settings. Never executes a launcher, +/// connects to a server, retains a document body or includes config values in findings. +public enum AgentSetupAudit { + public struct Target: Sendable { + public let runtime: AgentRuntime + public let url: URL + public init(_ runtime: AgentRuntime, _ url: URL) { self.runtime = runtime; self.url = url } + } + + public static func targets(home: URL, project: URL? = nil) -> [Target] { + var result: [Target] = [] + func add(_ runtime: AgentRuntime, _ base: URL, _ paths: [String]) { + result += paths.map { Target(runtime, base.appendingPathComponent($0)) } + } + add(.codex, home, [".codex/config.toml"]) + add(.claude, home, [".claude/settings.json"]) + add(.cursor, home, [".cursor/mcp.json", ".cursor/cli-config.json"]) + add(.devin, home, [".config/devin/config.json", ".config/devin/mcp_config.json"]) + add(.grok, home, [".grok/config.toml"]) + if let project, project.standardizedFileURL != home.standardizedFileURL { + add(.codex, project, [".codex/config.toml"]) + add(.claude, project, [".claude/settings.json", ".claude/settings.local.json", ".mcp.json"]) + add(.cursor, project, [".cursor/mcp.json"]) + add(.devin, project, [".devin/config.json", ".devin/config.local.json", ".devin/mcp_config.json", ".devin/mcp_config.local.json"]) + add(.grok, project, [".grok/config.toml"]) + } + return result + } + + public static func audit(home: URL = FileManager.default.homeDirectoryForCurrentUser, + project: URL? = nil, targets override: [Target]? = nil, + executableSearchPaths: [String]? = nil, + maximumBytes: Int = 512 * 1_024) -> ConfigurationHealthReport { + let configuration = AgentConfigurationAuditor.Configuration(home: home, executableSearchPaths: executableSearchPaths) + var files: [ConfigurationFileCheck] = [] + var issues: [ConfigurationHealthIssue] = [] + for target in override ?? targets(home: home, project: project) { + let url = target.url + let runtime = target.runtime + func record(_ status: ConfigurationFileCheck.Status, _ detail: String) { + files.append(.init(runtime: runtime, path: url.path, status: status, detail: detail)) + } + // Resolve no linked component: an ordinary config name could point at credentials. + let canonical = url.resolvingSymlinksInPath().standardizedFileURL + let base = home.resolvingSymlinksInPath().standardizedFileURL + let logicalBase = home.standardizedFileURL.path + let normalized = url.path.hasPrefix(logicalBase + "/") + ? base.appendingPathComponent(String(url.path.dropFirst(logicalBase.count + 1))).standardizedFileURL + : url.standardizedFileURL + guard canonical == normalized else { record(.unverified, "Linked configuration path; inspect its target in the agent."); continue } + guard FileManager.default.fileExists(atPath: url.path) else { + record(.missing, "Optional file absent. The agent may use defaults or another scope."); continue + } + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true, let size = values.fileSize, size <= maximumBytes, maximumBytes > 0, + let handle = try? FileHandle(forReadingFrom: url) else { + record(.unverified, "Unreadable, non-regular or above the scan size limit."); continue + } + let data = try? handle.read(upToCount: maximumBytes + 1) + try? handle.close() + guard let data, data.count <= maximumBytes, let body = String(data: data, encoding: .utf8) else { + record(.unverified, "Could not read a bounded UTF-8 configuration."); continue + } + if url.pathExtension == "toml" { + issues += AgentConfigurationAuditor.auditCodexConfig(body, url: url, configuration: configuration, runtime: runtime) + record(.checked, "MCP executable references" + (runtime == .codex ? " and supported misplaced settings" : "") + ". Full TOML schema and runtime connectivity are not validated.") + } else { + let json = runtime == .devin ? stripJSONComments(body) : body + guard let object = try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] else { + issues.append(issue(runtime, url, "json", "Configuration is not a JSON object", "This file could not be parsed as an object. The agent may reject these settings.", "Correct the JSON syntax in the source file, then recheck. Configuration contents are not included in this report.")) + record(.unverified, "JSON syntax check failed; remaining checks could not run."); continue + } + if let raw = object["mcpServers"] { + if let servers = raw as? [String: Any] { + for name in servers.keys.sorted() { + let key = SHA256.hash(data: Data(name.utf8)).prefix(8).map { String(format: "%02x", $0) }.joined() + guard let server = servers[name] as? [String: Any] else { + issues.append(issue(runtime, url, "mcp-\(key)", "Invalid MCP entry", "An MCP entry is not an object.", "Review the mcpServers structure in this file.")); continue + } + if server["disabled"] as? Bool == true || server["enabled"] as? Bool == false { continue } + let remote = server["url"] is String || server["serverUrl"] is String + if remote { continue } // URLs may contain secrets; never emit or request them. + guard let command = server["command"] as? String, !command.isEmpty else { + issues.append(issue(runtime, url, "mcp-\(key)", "MCP launcher is missing", "A local server entry has no command or remote URL.", "Add the documented launcher or remote transport fields, or remove the unused server through the agent.")); continue + } + // Runtime substitutions and relative commands need the agent's launch context. + guard !command.contains("$"), !command.contains("{{"), + !(command.contains("/") && !command.hasPrefix("/") && !command.hasPrefix("~/")) else { continue } + if !AgentConfigurationAuditor.commandExists(command, cwd: nil, configURL: url, configuration: configuration) { + issues.append(issue(runtime, url, "mcp-\(key)", "MCP launcher not found", "A configured executable was not found in the checked paths. The agent's environment may differ.", "Review the command in this file. Confirm its executable in the agent's environment, repair its path or disable the unused server.")) + } + } + } else { + issues.append(issue(runtime, url, "servers", "Invalid MCP server map", "mcpServers must be an object.", "Use the agent's documented server configuration format, then recheck.")) + } + } + record(.checked, "JSON object and declared MCP executable references. No connections or commands executed.") + } + } + return .init(issues: issues, scannedFiles: files.filter { $0.status == .checked }.map(\.path), files: files) + } + + private static func issue(_ runtime: AgentRuntime, _ url: URL, _ key: String, _ title: String, _ detail: String, _ remediation: String) -> ConfigurationHealthIssue { + .init(id: runtime.rawValue + "-" + url.path + "-" + key, severity: .error, runtime: runtime, + title: title, detail: detail, path: url.path, line: 1, remediation: remediation) + } + + /// Preserve quoted URLs, escapes and newlines; strip only comments outside strings. + static func stripJSONComments(_ body: String) -> String { + let chars = Array(body) + var output = "", index = 0, quoted = false, escaped = false + while index < chars.count { + let c = chars[index] + if quoted { + output.append(c) + if c == "\"" && !escaped { quoted = false } + escaped = c == "\\" && !escaped + index += 1; continue + } + if c == "\"" { quoted = true; output.append(c); index += 1; continue } + if c == "/", index + 1 < chars.count, chars[index + 1] == "/" { + while index < chars.count && chars[index] != "\n" { output.append(" "); index += 1 } + } else if c == "/", index + 1 < chars.count, chars[index + 1] == "*" { + output += " "; index += 2 + var closed = false + while index < chars.count { + if chars[index] == "*", index + 1 < chars.count, chars[index + 1] == "/" { output += " "; index += 2; closed = true; break } + output.append(chars[index] == "\n" ? "\n" : " "); index += 1 + } + if !closed { return "invalid unterminated comment" } + } else { output.append(c); index += 1 } + } + return output + } +} diff --git a/Sources/ContextCore/ExternalSkillSources.swift b/Sources/ContextCore/ExternalSkillSources.swift new file mode 100644 index 0000000..96c07ee --- /dev/null +++ b/Sources/ContextCore/ExternalSkillSources.swift @@ -0,0 +1,154 @@ +import Foundation + +public struct ExternalSkillSourceEvidence: Sendable, Equatable { + public enum Kind: String, Sendable { + case github = "GitHub source recorded" + case verified = "GitHub source verified" + case tool = "Installed tool source" + case repository = "Tracked in local repository" + case plugin = "Plugin owned" + case unresolved = "Source unresolved" + } + + public let kind: Kind + public let sourceURL: String? + public let owner: String? + public let note: String + + public init(kind: Kind, sourceURL: String? = nil, owner: String? = nil, note: String) { + self.kind = kind + self.sourceURL = sourceURL + self.owner = owner + self.note = note + } +} + +public struct ExternalSkillSourceAudit: Sendable { + public let byPath: [String: ExternalSkillSourceEvidence] + public let unavailableTools: [String] +} + +/// Read-only adapters for installed external tools. No update/check/audit --yes command is used. +public struct ExternalSkillSources: Sendable { + public init() {} + + public func lookup(skillPath: String, folder: URL) async -> ExternalSkillSourceEvidence { + let audit = await audit(skillPaths: [skillPath], folder: folder) + return audit.byPath[skillPath] ?? ExternalSkillSourceEvidence(kind: .unresolved, + note: "Source status could not be resolved.") + } + + public func audit(skillPaths: [String], folder: URL) async -> ExternalSkillSourceAudit { + let asm = try? await Self.run(tool: "asm", arguments: ["list", "--json"], folder: folder) + let skillsGlobal = try? await Self.run(tool: "npx", arguments: ["--yes", "skills@1.7.0", "list", "-g", "--json"], folder: folder) + let skillsProject = try? await Self.run(tool: "npx", arguments: ["--yes", "skills@1.7.0", "list", "--json"], folder: folder) + let unavailable = (asm == nil ? ["ASM"] : []) + (skillsGlobal == nil || skillsProject == nil ? ["Vercel skills"] : []) + var evidenceByPath: [String: ExternalSkillSourceEvidence] = [:] + let verifiedSources = VerifiedSkillSources() + for path in skillPaths { + var evidence = Self.resolve(skillPath: path, asm: asm ?? Data(), skillsLists: [skillsGlobal ?? Data(), skillsProject ?? Data()]) + if evidence.kind == .unresolved { + if let verified = verifiedSources.lookup(skillPath: path) { + evidence = verified + } else if let repository = await Self.repositorySource(skillPath: path) { + evidence = repository + } else if !unavailable.isEmpty { + evidence = ExternalSkillSourceEvidence(kind: .unresolved, + note: "Inventory unavailable: \(unavailable.joined(separator: ", ")). Source status cannot be verified.") + } + } + evidenceByPath[path] = evidence + } + return ExternalSkillSourceAudit(byPath: evidenceByPath, unavailableTools: unavailable) + } + + public static func resolve(skillPath: String, asm: Data, skillsLists: [Data]) -> ExternalSkillSourceEvidence { + let canonical = canonicalPath(skillPath) + if let entries = try? JSONSerialization.jsonObject(with: asm) as? [[String: Any]] { + for entry in entries where canonicalPath(entry["path"] as? String ?? "") == canonical { + let provider = entry["provider"] as? String ?? "" + if provider == "plugin" || provider == "codex-plugin" { + return ExternalSkillSourceEvidence(kind: .plugin, + owner: entry["providerLabel"] as? String ?? "Plugin manager", + note: "ASM found plugin files. Check the owning agent to verify whether the plugin is enabled and to manage updates.") + } + } + } + for data in skillsLists { + guard let entries = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { continue } + for entry in entries where canonicalPath(entry["path"] as? String ?? "") == canonical { + if let url = safeGitHubURL(entry["sourceUrl"] as? String) { + return ExternalSkillSourceEvidence(kind: .github, sourceURL: url, + owner: "Vercel skills", note: "Recorded by skills list. Installed content and local edits have not been compared with GitHub.") + } + } + } + return ExternalSkillSourceEvidence(kind: .unresolved, + note: "Neither installed tool recorded a verified upstream for this path. It may be locally authored or an untracked external copy.") + } + + private static func canonicalPath(_ path: String) -> String { + let url = URL(fileURLWithPath: path) + let folder = url.lastPathComponent == "SKILL.md" ? url.deletingLastPathComponent() : url + return folder.resolvingSymlinksInPath().standardizedFileURL.path + } + + private static func safeGitHubURL(_ raw: String?) -> String? { + guard let raw, let parts = URLComponents(string: raw), parts.scheme == "https", + parts.host == "github.com", parts.user == nil, parts.password == nil, + parts.query == nil, parts.fragment == nil, + parts.path.split(separator: "/").count == 2 else { return nil } + return raw.hasSuffix(".git") ? String(raw.dropLast(4)) : raw + } + + static func repositorySource(skillPath: String) async -> ExternalSkillSourceEvidence? { + let source = URL(fileURLWithPath: skillPath).resolvingSymlinksInPath() + let folder = source.lastPathComponent == "SKILL.md" ? source.deletingLastPathComponent() : source + guard let rootData = try? await run(tool: "git", arguments: ["-C", folder.path, "rev-parse", "--show-toplevel"], folder: folder), + let reportedRoot = String(data: rootData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !reportedRoot.isEmpty else { return nil } + let rootPath = URL(fileURLWithPath: reportedRoot).resolvingSymlinksInPath().standardizedFileURL.path + guard source.path.hasPrefix(rootPath + "/") else { return nil } + let relative = String(source.path.dropFirst(rootPath.count + 1)) + guard (try? await run(tool: "git", arguments: ["-C", rootPath, "ls-files", "--error-unmatch", "--", relative], folder: folder)) != nil, + let originData = try? await run(tool: "git", arguments: ["-C", rootPath, "remote", "get-url", "origin"], folder: folder), + let raw = String(data: originData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else { return nil } + let sshURL = raw.hasPrefix("git@github.com:") ? "https://github.com/" + raw.dropFirst("git@github.com:".count) : raw + guard let url = safeGitHubURL(String(sshURL)) else { return nil } + return ExternalSkillSourceEvidence(kind: .repository, sourceURL: url, + owner: "Git repository", note: "This skill file is tracked in the local repository. This is its repository origin, not a Vercel skills update record.") + } + + private static func run(tool: String, arguments: [String], folder: URL) async throws -> Data { + try await Task.detached(priority: .utility) { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let environment = ProcessInfo.processInfo.environment + let search = (environment["PATH"] ?? "").split(separator: ":").map(String.init) + [ + "\(home)/.local/share/mise/installs/node/lts/bin", "\(home)/.local/share/mise/shims", + "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin" + ] + guard let binary = search.map({ URL(fileURLWithPath: $0).appendingPathComponent(tool) }) + .first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) else { + throw CocoaError(.fileNoSuchFile) + } + let process = Process() + process.executableURL = binary + process.arguments = arguments + process.currentDirectoryURL = folder + process.environment = environment.merging(["DISABLE_TELEMETRY": "1"]) { _, new in new } + let output = Pipe() + process.standardOutput = output + process.standardError = FileHandle.nullDevice + try process.run() + DispatchQueue.global().asyncAfter(deadline: .now() + 45) { + if process.isRunning { process.terminate() } + } + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0, data.count <= 5_000_000 else { + throw CocoaError(.fileReadUnknown) + } + return data + }.value + } +} diff --git a/Sources/ContextCore/MemoryDocumentManager.swift b/Sources/ContextCore/MemoryDocumentManager.swift new file mode 100644 index 0000000..fa71ff1 --- /dev/null +++ b/Sources/ContextCore/MemoryDocumentManager.swift @@ -0,0 +1,139 @@ +import Foundation +import CryptoKit + +public struct MemoryEditPlan: Identifiable, Sendable { + public let id: UUID + public let entry: MemoryEntry + public let before: String + public let after: String + public var archive = false +} + +public struct MemoryEditReceipt: Identifiable, Codable, Sendable { + public let id: UUID + public let path: String + public let date: Date + public let beforeHash: String + public let afterHash: String + public var completed: Bool + public var restored: Bool + public var archived: Bool? = nil +} + +/// Edits existing, explicitly selected documents. No instruction discovery or agent configuration is changed. +public actor MemoryDocumentManager { + private let storage: URL + private let home: URL + private var plans: [UUID: MemoryEditPlan] = [:] + public init(storage: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support/ContextDaddy/MemoryHistory"), + home: URL = FileManager.default.homeDirectoryForCurrentUser) { + self.storage = storage.standardizedFileURL; self.home = home.resolvingSymlinksInPath() + } + public func prepare(entry: MemoryEntry, text: String) throws -> MemoryEditPlan { + guard entry.editable else { throw error("This source is managed by its agent or linked. Edit its owner instead.") } + try writable(entry.path) + let before = try MemoryInventory.read(entry) + guard !before.truncated, text.utf8.count <= SkillDocumentReader.maximumBytes, !text.contains("\0") else { + throw error("Only complete UTF-8 documents up to 256 KiB can be edited.") + } + guard before.text != text else { throw error("There are no changes to save.") } + let plan = MemoryEditPlan(id: UUID(), entry: entry, before: before.text, after: text) + plans[plan.id] = plan + return plan + } + public func apply(_ id: UUID) throws -> MemoryEditReceipt { + guard let plan = plans.removeValue(forKey: id) else { throw error("Preview expired. Review the changes again.") } + try writable(plan.entry.path) + guard try MemoryInventory.read(plan.entry).text == plan.before else { throw error("The file changed since preview. Reopen it before saving.") } + try safeStorage() + let backup = storage.appendingPathComponent(id.uuidString + ".md") + var receipt = MemoryEditReceipt(id: id, path: plan.entry.path, date: Date(), beforeHash: hash(plan.before), afterHash: hash(plan.after), completed: false, restored: false) + receipt.archived = plan.archive + if !plan.archive { + try Data(plan.before.utf8).write(to: backup, options: .withoutOverwriting) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: backup.path) + } + try save(receipt) + let file = URL(fileURLWithPath: plan.entry.path) + if plan.archive { + try FileManager.default.moveItem(at: file, to: backup) + receipt.completed = true; try save(receipt); return receipt + } + let mode = try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] + try Data(plan.after.utf8).write(to: file, options: .atomic) + if let mode { try FileManager.default.setAttributes([.posixPermissions: mode], ofItemAtPath: file.path) } + guard try MemoryInventory.read(plan.entry).text == plan.after else { throw error("Save verification failed. The backup is retained in History.") } + receipt.completed = true + try save(receipt) + return receipt + } + public func prepareArchive(entry: MemoryEntry) throws -> MemoryEditPlan { + guard entry.editable else { throw error("This source is managed or linked. Archive is unavailable.") } + try writable(entry.path) + let document = try MemoryInventory.read(entry) + guard !document.truncated else { throw error("An oversized document cannot be archived through this preview.") } + var plan = MemoryEditPlan(id: UUID(), entry: entry, before: document.text, after: "") + plan.archive = true; plans[plan.id] = plan; return plan + } + public func history() throws -> [MemoryEditReceipt] { + guard FileManager.default.fileExists(atPath: storage.path) else { return [] } + try safeStorage() + return try FileManager.default.contentsOfDirectory(at: storage, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "json" }.map { + guard MemoryPaths.same($0.path, $0.resolvingSymlinksInPath().path) else { throw error("Linked history receipt rejected.") } + return try JSONDecoder().decode(MemoryEditReceipt.self, from: Data(contentsOf: $0)) + }.sorted { $0.date > $1.date } + } + public func restore(_ id: UUID) throws { + try safeStorage() + guard var receipt = try history().first(where: { $0.id == id }), !receipt.restored else { throw error("Recovery receipt unavailable.") } + if receipt.archived == true { + try writable(receipt.path, mustExist: false) + let destination = URL(fileURLWithPath: receipt.path) + guard MemoryPaths.canonical(destination.path).hasPrefix(MemoryPaths.canonical(home.path) + "/"), + MemoryPaths.same(destination.deletingLastPathComponent().path, destination.deletingLastPathComponent().resolvingSymlinksInPath().path), + !FileManager.default.fileExists(atPath: destination.path), + (try? FileManager.default.attributesOfItem(atPath: destination.path)) == nil else { throw error("The original location is occupied or linked. Restore will not overwrite it.") } + let backup = storage.appendingPathComponent(id.uuidString + ".md") + guard MemoryPaths.same(backup.path, backup.resolvingSymlinksInPath().path), hash(try String(contentsOf: backup, encoding: .utf8)) == receipt.beforeHash else { throw error("Backup integrity check failed.") } + try FileManager.default.moveItem(at: backup, to: destination) + receipt.restored = true; try save(receipt); return + } + try writable(receipt.path) + let current = try SkillDocumentReader.read(url: URL(fileURLWithPath: MemoryPaths.canonical(receipt.path)), documentKind: .rule) + guard !current.truncated, hash(current.text) == receipt.afterHash else { throw error("The document changed after this edit. Restore would overwrite newer work.") } + let backup = storage.appendingPathComponent(id.uuidString + ".md") + guard MemoryPaths.same(backup.path, backup.resolvingSymlinksInPath().path) else { throw error("Linked backup rejected.") } + let original = try String(contentsOf: backup, encoding: .utf8) + guard hash(original) == receipt.beforeHash else { throw error("Backup integrity check failed.") } + let mode = try FileManager.default.attributesOfItem(atPath: receipt.path)[.posixPermissions] + try Data(original.utf8).write(to: URL(fileURLWithPath: receipt.path), options: .atomic) + if let mode { try FileManager.default.setAttributes([.posixPermissions: mode], ofItemAtPath: receipt.path) } + receipt.restored = true; try save(receipt) + } + private func writable(_ path: String, mustExist: Bool = true) throws { + let url = URL(fileURLWithPath: path).standardizedFileURL + let parts = url.pathComponents + guard MemoryPaths.canonical(url.path).hasPrefix(MemoryPaths.canonical(home.path) + "/"), MemoryPaths.same(url.path, url.resolvingSymlinksInPath().path), + !parts.contains(where: { ["plugins", ".git", ".ssh", ".aws", ".kube"].contains($0) }), + !MemoryPaths.canonical(url.path).hasPrefix(MemoryPaths.canonical(home.appendingPathComponent(".codex/memories").path) + "/") else { + throw error("This location is linked, managed or outside your home. Changes are unavailable here.") + } + let named = ["AGENTS.md", "AGENTS.override.md", "CLAUDE.md", "CLAUDE.local.md", "GROK.md", ".cursorrules"].contains(url.lastPathComponent) + let rule = (path.contains("/.claude/rules/") || path.contains("/.cursor/rules/")) && ["md", "mdc"].contains(url.pathExtension) + let memory = MemoryPaths.canonical(path).hasPrefix(MemoryPaths.canonical(home.appendingPathComponent(".claude/projects").path) + "/") && path.contains("/memory/") && url.pathExtension == "md" && !parts.contains("team") + guard named || rule || memory else { throw error("This document format has no supported editing adapter.") } + guard mustExist else { return } + let attributes = try FileManager.default.attributesOfItem(atPath: path) + guard attributes[.type] as? FileAttributeType == .typeRegular, (attributes[.referenceCount] as? NSNumber)?.intValue == 1 else { throw error("Only independent regular documents can be edited.") } + } + private func safeStorage() throws { + guard MemoryPaths.same(storage.path, storage.resolvingSymlinksInPath().path) else { throw error("Linked history location rejected.") } + try FileManager.default.createDirectory(at: storage, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + } + private func save(_ receipt: MemoryEditReceipt) throws { + try JSONEncoder().encode(receipt).write(to: storage.appendingPathComponent(receipt.id.uuidString + ".json"), options: .atomic) + } + private func hash(_ text: String) -> String { SHA256.hash(data: Data(text.utf8)).map { String(format: "%02x", $0) }.joined() } + private func error(_ text: String) -> SkillManagementError { .init(message: text) } +} diff --git a/Sources/ContextCore/MemoryInventory.swift b/Sources/ContextCore/MemoryInventory.swift new file mode 100644 index 0000000..d72a0c8 --- /dev/null +++ b/Sources/ContextCore/MemoryInventory.swift @@ -0,0 +1,100 @@ +import Foundation +import CryptoKit + +public struct MemoryEntry: Identifiable, Sendable, Equatable { + public let path: String + public let category: String + public let agent: AgentRuntime? + public let scope: String + public let access: String + public let bytes: Int64 + public let modified: Date + public let editable: Bool + public var id: String { path } + public var name: String { URL(fileURLWithPath: path).lastPathComponent } +} + +public struct MemoryInventory: Sendable { + public let entries: [MemoryEntry] + public let notes: [String] + public let checkedAt: Date + public init(entries: [MemoryEntry], notes: [String], checkedAt: Date) { self.entries = entries; self.notes = notes; self.checkedAt = checkedAt } + + /// Only metadata is read here. Contents are opened explicitly in the inspector or comparison. + public static func scan(items: [AIContextItem], home: URL = FileManager.default.homeDirectoryForCurrentUser, + folder: URL? = nil, limit: Int = 3000) throws -> Self { + let home = home.resolvingSymlinksInPath() + var entries = items.filter { [.instruction, .rule].contains($0.kind) }.compactMap { item -> MemoryEntry? in + guard let metadata = try? FileManager.default.attributesOfItem(atPath: item.path) else { return nil } + return MemoryEntry(path: item.path, category: item.kind == .rule ? "Rule" : "Instructions", + agent: AgentRuntime(rawValue: item.provider.rawValue), scope: item.scope.rawValue, + access: "\(item.applicability.rawValue) · potential access", bytes: (metadata[.size] as? NSNumber)?.int64Value ?? item.logicalBytes, + modified: metadata[.modificationDate] as? Date ?? item.modified, editable: item.resolvedPath == nil && !item.path.contains("/plugins/") && item.provider != .gemini) + } + var notes = ["Files show potential availability, not what a running agent loaded. Imports, managed policies, custom memory roots and cloud memories are not resolved.", + "Codex memory storage is indexed as local evidence and is read-only here. Cursor, Devin and Grok managed memory has no verified local adapter; use their own memory controls."] + var visited = 0 + let deadline = Date().addingTimeInterval(10) + func walk(_ root: URL, agent: AgentRuntime, scope: String, depth: Int = 0) throws { + try Task.checkCancellation() + guard depth < 5, visited < limit, Date() < deadline else { notes.append("Partial memory scan: depth, time or entry limit reached."); return } + guard FileManager.default.fileExists(atPath: root.path) else { return } + guard root.standardizedFileURL.path == root.resolvingSymlinksInPath().path else { notes.append("Linked memory location skipped: \(root.path)"); return } + let children: [URL] + do { children = try BoundedDirectoryReader.children(root) } + catch { notes.append("Memory directory unavailable: \(root.path)"); return } + for file in children.sorted(by: { $0.path < $1.path }) { + visited += 1 + guard visited <= limit, Date() < deadline else { notes.append("Partial memory scan: entry or time limit reached."); return } + let metadata = try? file.resourceValues(forKeys: [.isSymbolicLinkKey, .isDirectoryKey, .isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) + guard let metadata, metadata.isSymbolicLink != true else { continue } + if metadata.isDirectory == true { + guard !["rollout_summaries", "skills", "team", ".git"].contains(file.lastPathComponent) else { continue } + try walk(file, agent: agent, scope: scope, depth: depth + 1) + } else if metadata.isRegularFile == true, file.pathExtension.lowercased() == "md" { + entries.append(MemoryEntry(path: MemoryPaths.canonical(file.path), category: "Saved memory", agent: agent, scope: scope, + access: "Agent-owned store · loading unverified", bytes: Int64(metadata.fileSize ?? 0), + modified: metadata.contentModificationDate ?? .distantPast, editable: agent == .claude)) + } + } + } + try walk(home.appendingPathComponent(".codex/memories"), agent: .codex, scope: "User store") + let claudeProjects = home.appendingPathComponent(".claude/projects") + if FileManager.default.fileExists(atPath: claudeProjects.path), claudeProjects.path == claudeProjects.resolvingSymlinksInPath().path { + // Folder names are lossy encodings. Do not guess repository ownership from them. + if let roots = try? BoundedDirectoryReader.children(claudeProjects) { + for root in roots.prefix(500) { + try walk(root.appendingPathComponent("memory"), agent: .claude, scope: "Project store · folder mapping unverified") + } + if roots.count > 500 { notes.append("Partial memory scan: only 500 Claude project stores checked.") } + } else { notes.append("Claude memory stores could not be listed.") } + } + if folder != nil { notes.append("Instructions use the selected folder's applicable sources. Saved stores remain visible separately because folder ownership is not verified; do not assume they load in this folder.") } + let unique = Dictionary(grouping: entries, by: \.path).compactMap { $0.value.first }.sorted { $0.path < $1.path } + return Self(entries: unique, notes: Array(Set(notes)).sorted(), checkedAt: Date()) + } + + public static func read(_ entry: MemoryEntry) throws -> SkillDocument { + try SkillDocumentReader.read(url: URL(fileURLWithPath: MemoryPaths.canonical(entry.path)), documentKind: .rule) + } + + public static func compare(_ entries: [MemoryEntry]) throws -> (groups: [[MemoryEntry]], skipped: Int) { + var hashes: [String: [MemoryEntry]] = [:], skipped = 0 + for entry in entries { + try Task.checkCancellation() + guard let document = try? read(entry), !document.truncated else { skipped += 1; continue } + let hash = SHA256.hash(data: Data(document.text.utf8)).map { String(format: "%02x", $0) }.joined() + hashes[hash, default: []].append(entry) + } + return (hashes.values.filter { $0.count > 1 }.sorted { $0[0].path < $1[0].path }, skipped) + } +} + +/// Normalize only macOS's two system aliases; arbitrary user symlinks remain rejected. +enum MemoryPaths { + static func canonical(_ path: String) -> String { + if path.hasPrefix("/var/") || path.hasPrefix("/tmp/") { return "/private" + path } + return path + } + static func same(_ lhs: String, _ rhs: String) -> Bool { canonical(lhs) == canonical(rhs) } +} diff --git a/Sources/ContextCore/PluginActions.swift b/Sources/ContextCore/PluginActions.swift new file mode 100644 index 0000000..9678e86 --- /dev/null +++ b/Sources/ContextCore/PluginActions.swift @@ -0,0 +1,148 @@ +import Foundation + +public enum PluginAction: String, CaseIterable, Sendable, Identifiable { + case disable = "Disable", enable = "Enable", uninstall = "Uninstall" + public var id: String { rawValue } +} +public struct PluginActionPlan: Identifiable, Sendable { + public let id: UUID + public let owner: AgentRuntime + public let pluginKey: String + public let action: PluginAction + public let scope: String + public let workingDirectory: String + public let executable: String + public let arguments: [String] + public var explanation: String { + if action == .uninstall { + return owner == .claude ? "Claude will uninstall this registration and preserve persistent plugin data. Other scopes are not selected. Reinstallation may require network access; cache retention is controlled by Claude." + : "Codex will uninstall this plugin and remove its local cache. Reinstallation may require network access and the original version may no longer be available. This cannot be undone by ContextDaddy." + } + return "Claude will \(action.rawValue.lowercased()) this plugin in the selected scope. Cached files stay on disk. New sessions may be required. You can use the opposite action to change this setting again." + } +} +public struct PluginActionReceipt: Identifiable, Codable, Sendable { + public let id: UUID + public let pluginKey: String + public let owner: String + public let action: String + public let scope: String + public let workingDirectory: String + public let date: Date + public let status: String +} + +public actor PluginActionManager { + private let home: URL + private let storage: URL + private var plans: [UUID: PluginActionPlan] = [:] + private var expected: [UUID: PluginInventoryEntry] = [:] + public init(home: URL = FileManager.default.homeDirectoryForCurrentUser, + storage: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support/ContextDaddy/PluginHistory")) { + self.home = home.resolvingSymlinksInPath(); self.storage = storage.standardizedFileURL + } + public func prepare(entry: PluginInventoryEntry, action: PluginAction, registration: PluginRegistration? = nil) throws -> PluginActionPlan { + try verifyDefaultHome(entry.owner) + guard [.claude, .codex].contains(entry.owner), entry.pluginKey.range(of: "^[A-Za-z0-9_][A-Za-z0-9_.-]*@[A-Za-z0-9_][A-Za-z0-9_.-]*$", options: .regularExpression) != nil else { throw error("Unsupported plugin identity or owner.") } + let executable = try executable(entry.owner) + var arguments = ["plugin"], scope = "user", directory = home.path + if entry.owner == .claude { + guard entry.registryVerified, let registration, entry.registrations.contains(registration), ["user", "project", "local"].contains(registration.scope) else { throw error("Select a verified user, project or local registration first. Managed registrations are read-only.") } + scope = registration.scope + if scope != "user" { + guard let project = registration.project, project.hasPrefix(home.path + "/"), URL(fileURLWithPath: project).standardizedFileURL.path == URL(fileURLWithPath: project).resolvingSymlinksInPath().path else { throw error("The project registration has no safe working folder.") } + directory = project + } + arguments += [action.rawValue.lowercased(), entry.pluginKey, "--scope", scope, "--json"] + if action == .uninstall { arguments.append("--keep-data") } + } else { + guard action == .uninstall else { throw error("This Codex CLI exposes removal only. Use Codex settings for enablement.") } + guard entry.preferences.contains(where: { MemoryPaths.same($0.path, home.appendingPathComponent(".codex/config.toml").path) }) else { throw error("No user-scope Codex declaration was verified. Review this installation in Codex before removing it.") } + arguments += ["remove", entry.pluginKey, "--json"] + } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: directory, isDirectory: &isDirectory), isDirectory.boolValue else { throw error("The registered working folder is unavailable.") } + let plan = PluginActionPlan(id: UUID(), owner: entry.owner, pluginKey: entry.pluginKey, action: action, scope: scope, workingDirectory: directory, executable: executable, arguments: arguments) + plans[plan.id] = plan + expected[plan.id] = entry + return plan + } + public func apply(_ id: UUID) async throws -> PluginActionReceipt { + guard let plan = plans.removeValue(forKey: id) else { throw error("Preview expired. Review the action again.") } + guard let before = expected.removeValue(forKey: id) else { throw error("Plugin evidence expired.") } + try verifyDefaultHome(plan.owner) + guard try executable(plan.owner) == plan.executable else { throw error("The plugin manager changed since preview.") } + let checked = try PluginInventory.scan(home: home, folder: URL(fileURLWithPath: plan.workingDirectory)) + let targetSetting = plan.owner == .codex ? home.appendingPathComponent(".codex/config.toml").path + : plan.scope == "user" ? home.appendingPathComponent(".claude/settings.json").path + : URL(fileURLWithPath: plan.workingDirectory).appendingPathComponent(plan.scope == "local" ? ".claude/settings.local.json" : ".claude/settings.json").path + guard MemoryPaths.same(targetSetting, URL(fileURLWithPath: targetSetting).resolvingSymlinksInPath().path) else { + throw error("The target settings path is linked. Manage it through the owning agent.") + } + guard let current = checked.entries.first(where: { $0.id == before.id }), + current.registrations == before.registrations, + current.preferences.filter({ $0.path == targetSetting }) == before.preferences.filter({ $0.path == targetSetting }), + current.versions == before.versions, current.registryVerified == before.registryVerified else { + throw error("Plugin evidence changed since preview. Recheck and review the action again.") + } + let intent = receipt(plan, status: "Started · completion unverified") + try save(intent) + // No shell interpolation, input prompts, raw command output or credentials in receipts. + let status = await Task.detached(priority: .userInitiated) { + let process = Process() + process.executableURL = URL(fileURLWithPath: plan.executable) + process.arguments = plan.arguments + process.currentDirectoryURL = URL(fileURLWithPath: plan.workingDirectory) + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = [FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/bin").path, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].joined(separator: ":") + process.environment = environment + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { try process.run() } catch { return "Could not launch the plugin manager. No success was verified." } + let deadline = Date().addingTimeInterval(45) + while process.isRunning && Date() < deadline { try? await Task.sleep(for: .milliseconds(100)) } + if process.isRunning { process.terminate(); return "Timed out · result unknown. Recheck before retrying." } + return process.terminationStatus == 0 ? "Owner command completed · rescan required" : "Owner command failed (exit \(process.terminationStatus)) · recheck before retrying" + }.value + let result = receipt(plan, status: status) + try save(result) + return result + } + public func history() throws -> [PluginActionReceipt] { + guard FileManager.default.fileExists(atPath: storage.path) else { return [] } + try safeStorage() + return try FileManager.default.contentsOfDirectory(at: storage, includingPropertiesForKeys: nil).filter { $0.pathExtension == "json" }.map { + guard MemoryPaths.same($0.path, $0.resolvingSymlinksInPath().path) else { throw error("Linked history rejected.") } + return try JSONDecoder().decode(PluginActionReceipt.self, from: Data(contentsOf: $0)) + }.sorted { $0.date > $1.date } + } + public func recordVerification(_ id: UUID, verified: Bool) throws { + guard let receipt = try history().first(where: { $0.id == id }) else { throw error("Plugin receipt unavailable.") } + try save(.init(id: receipt.id, pluginKey: receipt.pluginKey, owner: receipt.owner, action: receipt.action, scope: receipt.scope, + workingDirectory: receipt.workingDirectory, date: receipt.date, + status: receipt.status + (verified ? " · requested local state verified" : " · requested state not verified"))) + } + private func executable(_ owner: AgentRuntime) throws -> String { + let name = owner == .claude ? "claude" : "codex" + let candidates = [home.appendingPathComponent(".local/bin/\(name)").path, "/opt/homebrew/bin/\(name)", "/usr/local/bin/\(name)"] + guard let path = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) else { throw error("\(owner.rawValue)'s command-line manager is not installed in a supported location. Use its own plugin settings.") } + return URL(fileURLWithPath: path).resolvingSymlinksInPath().path + } + private func verifyDefaultHome(_ owner: AgentRuntime) throws { + let variable = owner == .codex ? "CODEX_HOME" : "CLAUDE_CONFIG_DIR" + if let value = ProcessInfo.processInfo.environment[variable], !value.isEmpty, + !MemoryPaths.same(URL(fileURLWithPath: value).standardizedFileURL.path, home.appendingPathComponent(owner == .codex ? ".codex" : ".claude").path) { + throw error("A custom agent home is active. This action only supports the default location shown in the inventory.") + } + } + private func receipt(_ plan: PluginActionPlan, status: String) -> PluginActionReceipt { + .init(id: plan.id, pluginKey: plan.pluginKey, owner: plan.owner.rawValue, action: plan.action.rawValue, scope: plan.scope, workingDirectory: plan.workingDirectory, date: Date(), status: status) + } + private func safeStorage() throws { + guard MemoryPaths.same(storage.path, storage.resolvingSymlinksInPath().path) else { throw error("Linked history rejected.") } + try FileManager.default.createDirectory(at: storage, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + } + private func save(_ receipt: PluginActionReceipt) throws { try safeStorage(); try JSONEncoder().encode(receipt).write(to: storage.appendingPathComponent(receipt.id.uuidString + ".json"), options: .atomic) } + private func error(_ text: String) -> SkillManagementError { .init(message: text) } +} diff --git a/Sources/ContextCore/PluginInventory.swift b/Sources/ContextCore/PluginInventory.swift new file mode 100644 index 0000000..28996ce --- /dev/null +++ b/Sources/ContextCore/PluginInventory.swift @@ -0,0 +1,303 @@ +import CryptoKit +import CoreFoundation +import Foundation + +public struct PluginPreference: Sendable, Equatable, Identifiable { + public let path: String + public let enabled: Bool + public var id: String { path } +} + +public struct PluginRegistration: Sendable, Equatable, Identifiable { + public let path: String + public let scope: String + public let project: String? + public var id: String { path + scope + (project ?? "") } +} + +public struct PluginCacheVersion: Sendable, Equatable, Identifiable { + public let path: String + public let version: String + public let bytes: Int64 + public let sizeComplete: Bool + public let skillNames: [String] + public let fingerprints: Set + public let components: [String] + public let modified: Date? + public var id: String { path } +} + +public struct PluginInventoryEntry: Sendable, Equatable, Identifiable { + public let owner: AgentRuntime + public let marketplace: String + public let name: String + public let summary: String + public let versions: [PluginCacheVersion] + public let registrations: [PluginRegistration] + public let registryVerified: Bool + public let preferences: [PluginPreference] + public let localMatches: [String] + public var id: String { owner.rawValue + ":" + marketplace + ":" + name } + public var pluginKey: String { name + "@" + marketplace } + public var bytes: Int64 { versions.reduce(0) { $0 + $1.bytes } } + public var sizeComplete: Bool { versions.allSatisfy(\.sizeComplete) } + public var skillNames: [String] { Array(Set(versions.flatMap(\.skillNames))).sorted() } + public var unreferencedVersions: [PluginCacheVersion] { + guard registryVerified else { return [] } + return versions.filter { !isReferenced($0) } + } + public func isReferenced(_ version: PluginCacheVersion) -> Bool { + registrations.contains { Self.pathKey($0.path) == Self.pathKey(version.path) } + } + private static func pathKey(_ path: String) -> String { + let value = URL(fileURLWithPath: path).standardizedFileURL.path + // macOS directory enumeration and URL normalization disagree on these aliases. + for prefix in ["/private/var/", "/private/tmp/"] where value.hasPrefix(prefix) { + return String(value.dropFirst("/private".count)) + } + return value + } + public var settingLabel: String { + guard !preferences.isEmpty else { return "Enablement unverified" } + if Set(preferences.map(\.enabled)).count > 1 { return "Mixed settings" } + return preferences[0].enabled ? "Enabled in checked settings" : "Disabled in checked settings" + } + public var reviewReason: String { + if !unreferencedVersions.isEmpty { return "\(unreferencedVersions.count) versions not referenced by the checked registry" } + if versions.count > 1 { return "\(versions.count) cached versions; current installation needs verification" } + if !localMatches.isEmpty { return "Matching instructions also exist in your local library" } + if preferences.isEmpty { return "Check whether this plugin is enabled" } + return "Review its settings and included capabilities" + } + public var managementBrief: String { + """ + Review \(pluginKey) using \(owner.rawValue)'s plugin manager. + \(reviewReason). + Cache paths: + \(versions.map(\.path).joined(separator: "\n")) + Registry references: + \(registrations.map { "\($0.scope): \($0.project ?? "User scope") → \($0.path)" }.joined(separator: "\n")) + Explicit settings (not proof of runtime loading): + \(preferences.map { "\($0.path): enabled=\($0.enabled)" }.joined(separator: "\n")) + Verify the intended folder and scope in the owning manager. Disable only if unwanted; uninstall only after reviewing dependencies and recovery. An unreferenced cache version is a review candidate, not proof that a running session or custom path cannot use it. Do not delete cache directories based on age or name alone. Rescan ContextDaddy after the change. + """ + } +} + +public struct PluginInventorySnapshot: Sendable, Equatable { + public let entries: [PluginInventoryEntry] + public let notes: [String] + public let checkedSources: [String] + public let generatedAt: Date + public var versionCount: Int { entries.reduce(0) { $0 + $1.versions.count } } + public var repeatedPluginCount: Int { entries.filter { $0.versions.count > 1 }.count } + public var unreferencedVersionCount: Int { entries.reduce(0) { $0 + $1.unreferencedVersions.count } } + public var isPartial: Bool { + notes.contains { note in ["Scan limit", "Entry limit", "Could not", "Skipped", "Unsupported JSON"].contains { note.hasPrefix($0) } } + } +} + +/// Reads only known cache metadata, plugin registration and explicit plugin settings. +/// Does not execute plugins, contact services, infer activation, or traverse links. +public enum PluginInventory { + public static func scan(home: URL = FileManager.default.homeDirectoryForCurrentUser, + folder: URL? = nil, localSkills: [SkillRecord] = [], + maximumEntries: Int = 60_000, timeout: TimeInterval = 12) throws -> PluginInventorySnapshot { + var reader = Reader(home: home.resolvingSymlinksInPath().standardizedFileURL, + remaining: maximumEntries, deadline: Date().addingTimeInterval(timeout)) + return try reader.scan(folder: folder, localSkills: localSkills) + } + + private struct Reader { + let home: URL + var remaining: Int + let deadline: Date + var notes: [String] = [] + var sources: [String] = [] + let fm = FileManager.default + + mutating func scan(folder: URL?, localSkills: [SkillRecord]) throws -> PluginInventorySnapshot { + var entries: [PluginInventoryEntry] = [] + let registryPath = home.appendingPathComponent(".claude/plugins/installed_plugins.json") + let registry = json(registryPath) + let rawPlugins = registry?["plugins"] as? [String: Any] + var registrations: [String: [PluginRegistration]] = [:] + var registryVerified = (registry?["version"] as? Int) == 2 && rawPlugins != nil + if let rawPlugins { + for (key, raw) in rawPlugins { + guard let list = raw as? [[String: Any]] else { registryVerified = false; continue } + for item in list { + guard let path = item["installPath"] as? String, path.hasPrefix("/"), + let scope = item["scope"] as? String, ["user", "project", "local", "managed"].contains(scope), + scope == "user" || scope == "managed" || (item["projectPath"] as? String)?.hasPrefix("/") == true + else { registryVerified = false; continue } + registrations[key, default: []].append(.init(path: URL(fileURLWithPath: path).standardizedFileURL.path, + scope: scope, project: item["projectPath"] as? String)) + } + } + } + if !registryVerified { notes.append("Claude installation registry is unavailable or unsupported; unreferenced versions cannot be established.") } + var settings: [AgentRuntime: [String: [PluginPreference]]] = [:] + for owner in [AgentRuntime.codex, .claude] { + let relative = owner == .codex ? ".codex/config.toml" : ".claude/settings.json" + var files = [home.appendingPathComponent(relative)] + if let folder { + var current = folder.resolvingSymlinksInPath().standardizedFileURL + var ancestors: [URL] = [] + for _ in 0..<32 { + if current == home || current.path == "/" { break } + ancestors.append(current) + current.deleteLastPathComponent() + } + for parent in ancestors.reversed() { + files.append(parent.appendingPathComponent(relative)) + if owner == .claude { files.append(parent.appendingPathComponent(".claude/settings.local.json")) } + } + } + for file in files { + let values: [String: Bool] + if owner == .claude { + guard let obj = json(file) else { continue } + values = (obj["enabledPlugins"] as? [String: Any] ?? [:]).reduce(into: [:]) { result, pair in + if let value = pair.value as? NSNumber, CFGetTypeID(value) == CFBooleanGetTypeID() { result[pair.key] = value.boolValue } + } + } else { + guard let data = read(file), let text = String(data: data, encoding: .utf8) else { continue } + values = PluginInventory.codexPreferences(text) + } + for (key, enabled) in values { settings[owner, default: [:]][key, default: []].append(.init(path: file.path, enabled: enabled)) } + } + } + for owner in [AgentRuntime.claude, .codex] { + let root = home.appendingPathComponent(owner == .claude ? ".claude/plugins/cache" : ".codex/plugins/cache") + guard safeDirectory(root) else { + notes.append("\(owner.rawValue) default cache is absent, linked or inaccessible."); continue + } + sources.append(root.path) + for market in children(root) where safeDirectory(market) { + for plugin in children(market) where safeDirectory(plugin) { + try Task.checkCancellation() + var versions: [PluginCacheVersion] = [] + var summary = "" + for version in children(plugin) where safeDirectory(version) { + if summary.isEmpty { + for manifest in [".codex-plugin/plugin.json", ".claude-plugin/plugin.json"] { + if let value = json(version.appendingPathComponent(manifest))?["description"] as? String { summary = String(value.prefix(600)); break } + } + } + versions.append(measure(version)) + } + let key = plugin.lastPathComponent + "@" + market.lastPathComponent + let refs = owner == .claude ? registrations[key] ?? [] : [] + let fingerprints = Set(versions.flatMap(\.fingerprints)) + let matches = localSkills.filter { $0.ownership == .local && $0.contentFingerprint.map(fingerprints.contains) == true }.map(\.id) + entries.append(.init(owner: owner, marketplace: market.lastPathComponent, name: plugin.lastPathComponent, + summary: summary, versions: versions.sorted { $0.version.localizedStandardCompare($1.version) == .orderedDescending }, + registrations: refs, registryVerified: owner == .claude && registryVerified, + preferences: settings[owner]?[key] ?? [], localMatches: matches.sorted())) + } + } + } + // Registered installations without cache files remain visible as broken/missing installs. + for (key, refs) in registrations where !entries.contains(where: { $0.owner == .claude && $0.pluginKey == key }) { + let parts = key.split(separator: "@", maxSplits: 1).map(String.init) + guard parts.count == 2 else { continue } + entries.append(.init(owner: .claude, marketplace: parts[1], name: parts[0], summary: "Registered installation with no discovered default-cache directory.", versions: [], registrations: refs, registryVerified: registryVerified, preferences: settings[.claude]?[key] ?? [], localMatches: [])) + } + notes.append("Default Codex and Claude caches only. Cursor, Devin and Grok plugin inventories, custom homes, managed settings and launch overrides are not resolved. Recorded use is unavailable; modification time is not usage.") + return .init(entries: entries.sorted { a, b in + if a.versions.count != b.versions.count { return a.versions.count > b.versions.count } + return a.id.localizedStandardCompare(b.id) == .orderedAscending + }, notes: Array(Set(notes)).sorted(), checkedSources: Array(Set(sources)).sorted(), generatedAt: Date()) + } + + func safeDirectory(_ url: URL) -> Bool { + guard url.resolvingSymlinksInPath().standardizedFileURL.path == url.standardizedFileURL.path, + let a = try? fm.attributesOfItem(atPath: url.path) else { return false } + return a[.type] as? FileAttributeType == .typeDirectory + } + mutating func children(_ url: URL) -> [URL] { + guard remaining > 0, Date() < deadline else { notes.append("Scan limit reached; some plugins or version contents are missing."); return [] } + do { + let found = try BoundedDirectoryReader.children(url, timeout: min(2, max(0.01, deadline.timeIntervalSinceNow))).sorted { $0.path < $1.path } + let count = min(remaining, found.count) + remaining -= count + if count < found.count { notes.append("Entry limit reached; inventory is partial.") } + return Array(found.prefix(count)) + } catch { notes.append("Could not enumerate \(url.path)."); return [] } + } + mutating func read(_ url: URL) -> Data? { + guard fm.fileExists(atPath: url.path) else { return nil } + guard url.resolvingSymlinksInPath().standardizedFileURL.path == url.standardizedFileURL.path, + let a = try? fm.attributesOfItem(atPath: url.path), a[.type] as? FileAttributeType == .typeRegular, + ((a[.size] as? NSNumber)?.intValue ?? Int.max) <= 512 * 1024, + let handle = try? FileHandle(forReadingFrom: url) else { notes.append("Skipped linked, unreadable or oversized metadata: \(url.path)."); return nil } + defer { try? handle.close() } + guard let data = try? handle.read(upToCount: 512 * 1024 + 1), data.count <= 512 * 1024 else { return nil } + sources.append(url.path) + return data + } + mutating func json(_ url: URL) -> [String: Any]? { + guard let data = read(url) else { return nil } + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { notes.append("Unsupported JSON metadata: \(url.path)."); return nil } + return obj + } + mutating func measure(_ root: URL) -> PluginCacheVersion { + var bytes: Int64 = 0, complete = true + var names: [String] = [], fingerprints = Set(), components = Set() + var queue: [(URL, Int)] = [(root, 0)] + let beforeNotes = notes.count + while let (directory, depth) = queue.popLast() { + guard depth < 16, remaining > 0, Date() < deadline else { complete = false; continue } + for child in children(directory) { + guard let a = try? fm.attributesOfItem(atPath: child.path) else { complete = false; continue } + let type = a[.type] as? FileAttributeType + if type == .typeSymbolicLink { complete = false; continue } + if type == .typeDirectory { + if [".git", "node_modules", ".venv"].contains(child.lastPathComponent) { complete = false; continue } + if ["agents", "commands", "hooks"].contains(child.lastPathComponent) { components.insert(child.lastPathComponent.capitalized) } + queue.append((child, depth + 1)) + } else if type == .typeRegular { + bytes += (a[.size] as? NSNumber)?.int64Value ?? 0 + if child.lastPathComponent == "SKILL.md" { + names.append(child.deletingLastPathComponent().lastPathComponent) + if let data = read(child) { fingerprints.insert(SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()) } + } + if [".mcp.json", "mcp.json"].contains(child.lastPathComponent) { components.insert("MCP") } + } else { complete = false } + } + } + return .init(path: root.path, version: root.lastPathComponent, bytes: bytes, sizeComplete: complete && notes.count == beforeNotes, + skillNames: names.sorted(), fingerprints: fingerprints, components: components.sorted(), + modified: (try? fm.attributesOfItem(atPath: root.path)[.modificationDate]) as? Date) + } + } + + /// Only the documented literal table/boolean form is recognized. Ambiguous tables are omitted. + static func codexPreferences(_ text: String) -> [String: Bool] { + // Without a full TOML parser, a header-looking line inside a multiline + // string must never be presented as an actual enablement declaration. + guard !text.contains("\"\"\""), !text.contains("'''") else { return [:] } + var result: [String: Bool] = [:], seen = Set(), invalid = Set() + var key: String? + for raw in text.components(separatedBy: .newlines) { + let line = raw.trimmingCharacters(in: .whitespaces) + if line.hasPrefix("[") { + key = nil + let pattern = #"^\[plugins\."([A-Za-z0-9_.@-]+)"\]\s*(?:#.*)?$"# + if let range = line.range(of: pattern, options: .regularExpression) { + let header = String(line[range]); let pieces = header.split(separator: "\"") + if pieces.count >= 3 { + let candidate = String(pieces[1]); key = candidate + if !seen.insert(candidate).inserted { invalid.insert(candidate) } + } + } + } else if let key, line.hasPrefix("enabled") { + let parts = line.split(separator: "#", maxSplits: 1)[0].split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) } + guard parts.count == 2, parts[0] == "enabled", ["true", "false"].contains(parts[1]), result[key] == nil else { invalid.insert(key); continue } + result[key] = parts[1] == "true" + } + } + return result.filter { !invalid.contains($0.key) } + } +} diff --git a/Sources/ContextCore/SkillActivityHistory.swift b/Sources/ContextCore/SkillActivityHistory.swift new file mode 100644 index 0000000..ed59342 --- /dev/null +++ b/Sources/ContextCore/SkillActivityHistory.swift @@ -0,0 +1,371 @@ +import Darwin +import Foundation + +public enum SkillActivityEvidence: String, Sendable { + case toolCall = "Skill tool call" + case fileRead = "Skill file read" + case pathReference = "Skill path in a tool call" +} + +public struct SkillActivityObservation: Sendable { + public let name: String + public let runtime: AgentRuntime + public let evidence: SkillActivityEvidence + public let session: String + public let day: String + public let project: String? + public let skillPath: String? + public let failed: Bool +} + +public struct SkillActivityCoverage: Sendable { + public let runtime: AgentRuntime + public let files: Int + public let partial: Bool + public let note: String + public let skippedRecords: Int + public let unreadableFiles: Int + + public init(runtime: AgentRuntime, files: Int, partial: Bool, note: String, + skippedRecords: Int = 0, unreadableFiles: Int = 0) { + self.runtime = runtime + self.files = files + self.partial = partial + self.note = note + self.skippedRecords = skippedRecords + self.unreadableFiles = unreadableFiles + } +} + +public struct SkillActivitySummary: Sendable { + public let observations: [SkillActivityObservation] + public init(observations: [SkillActivityObservation]) { self.observations = observations } + public var toolCalls: Int { observations.filter { $0.evidence == .toolCall }.count } + public var failedToolCalls: Int { observations.filter { $0.evidence == .toolCall && $0.failed }.count } + public var fileReadSessions: Int { Set(observations.filter { $0.evidence == .fileRead }.map { "\($0.runtime.rawValue):\($0.session)" }).count } + public var pathReferenceSessions: Int { Set(observations.filter { $0.evidence == .pathReference }.map { "\($0.runtime.rawValue):\($0.session)" }).count } + public var lastSeen: String? { observations.map(\.day).filter { !$0.isEmpty }.max() } + public var isEmpty: Bool { observations.isEmpty } +} + +public struct SkillActivitySnapshot: Sendable { + public let observations: [SkillActivityObservation] + public let coverage: [SkillActivityCoverage] + public let scannedAt: Date + + public func summary(for skill: SkillRecord, runtime: AgentRuntime? = nil, folder: String? = nil, + sinceDay: String? = nil) -> SkillActivitySummary { + let names = Set([Self.normalized(skill.name), + Self.normalized(URL(fileURLWithPath: skill.id).deletingLastPathComponent().lastPathComponent)]) + let paths = Set(([skill.id] + skill.exposures.flatMap { [$0.logicalPath, $0.resolvedPath] }) + .map { URL(fileURLWithPath: $0).resolvingSymlinksInPath().standardizedFileURL.path }) + let selectedFolder = folder?.trimmingCharacters(in: .whitespacesAndNewlines) + return SkillActivitySummary(observations: observations.filter { observation in + guard names.contains(Self.normalized(observation.name)), + runtime == nil || runtime == observation.runtime else { return false } + if let sinceDay, observation.day < sinceDay { return false } + if let path = observation.skillPath { + let resolved = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path + guard paths.contains(resolved) else { return false } + } + if let selectedFolder, !selectedFolder.isEmpty { + guard let project = observation.project else { return false } + let root = URL(fileURLWithPath: selectedFolder).standardizedFileURL.path + let actual = URL(fileURLWithPath: project).standardizedFileURL.path + guard actual == root || actual.hasPrefix(root + "/") else { return false } + } + return true + }) + } + + private static func normalized(_ name: String) -> String { + String(name.split(separator: ":").last ?? Substring(name)).lowercased() + } +} + +/// An on-demand, local-only backfill. It reads structured tool metadata from +/// transcripts and retains neither prompts, responses, nor command bodies. +public struct SkillActivityHistory: Sendable { + public struct Limits: Sendable { + public var maximumFiles = 12_000 + public var maximumBytes: Int64 = 20_000_000_000 + public var maximumSeconds: TimeInterval = 90 + public var maximumLineBytes = 8_000_000 + public init() {} + } + + private let home: URL + private let limits: Limits + + public init(home: URL = FileManager.default.homeDirectoryForCurrentUser, limits: Limits = .init()) { + self.home = home + self.limits = limits + } + + public func scan() -> SkillActivitySnapshot { + var reader = Reader(home: home, limits: limits) + return reader.scan() + } +} + +private struct Reader { + let home: URL + let limits: SkillActivityHistory.Limits + init(home: URL, limits: SkillActivityHistory.Limits) { + self.home = home + self.limits = limits + } + private let manager = FileManager.default + private let pathPattern = try! NSRegularExpression(pattern: #"(/[\w .~+@%()\-]+(?:/[\w .~+@%()\-]+)*/skills/([^/\s"'`]+)/SKILL\.md)"#, options: [.caseInsensitive]) + private var observations: [SkillActivityObservation] = [] + private var coverage: [SkillActivityCoverage] = [] + private var seen = Set() + private var claudeCalls: [String: SkillActivityObservation] = [:] + private var claudeFailures = Set() + private var totalFiles = 0 + private var totalBytes: Int64 = 0 + private var deadline: Date { started.addingTimeInterval(limits.maximumSeconds) } + private let started = Date() + + mutating func scan() -> SkillActivitySnapshot { + for runtime in AgentRuntime.allCases { + scan(runtime) + } + for (id, call) in claudeCalls { + record(.init(name: call.name, runtime: .claude, evidence: .toolCall, session: call.session, + day: call.day, project: call.project, skillPath: nil, failed: claudeFailures.contains(id)), key: "claude:\(id)") + } + return SkillActivitySnapshot(observations: observations, coverage: coverage, scannedAt: Date()) + } + + private mutating func scan(_ runtime: AgentRuntime) { + let relative: String = switch runtime { + case .codex: ".codex/sessions" + case .claude: ".claude/projects" + case .cursor: ".cursor/projects" + case .devin: ".local/share/devin/cli/transcripts" + case .grok: ".grok/sessions" + } + let root = home.appendingPathComponent(relative, isDirectory: true) + guard manager.fileExists(atPath: root.path) else { + coverage.append(.init(runtime: runtime, files: 0, partial: true, note: "No local history folder")) + return + } + guard (try? root.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) != true, + let enumerator = manager.enumerator(at: root, includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey], + options: [.skipsPackageDescendants]) else { + coverage.append(.init(runtime: runtime, files: 0, partial: true, note: "History folder is linked or unreadable")) + return + } + var files = 0 + var partial = false + var skippedRecords = 0 + var unreadableFiles = 0 + var limitReached = false + for case let url as URL in enumerator { + if Task.isCancelled || Date() >= deadline || totalFiles >= limits.maximumFiles || totalBytes >= limits.maximumBytes { + partial = true; limitReached = true; break + } + guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey]) else { + partial = true; unreadableFiles += 1; continue + } + if values.isSymbolicLink == true { enumerator.skipDescendants(); continue } + if values.isDirectory == true { continue } + guard values.isRegularFile == true, accepts(url, runtime: runtime) else { continue } + let size = Int64(values.fileSize ?? 0) + guard size >= 0, size <= limits.maximumBytes - totalBytes else { partial = true; limitReached = true; break } + totalFiles += 1; totalBytes += size; files += 1 + do { + if runtime == .devin { try scanDevinFile(url) } + else { + let skipped = try scanLines(url, runtime: runtime) + skippedRecords += skipped + if skipped > 0 { partial = true } + } + } catch { partial = true; unreadableFiles += 1 } + } + var reasons: [String] = [] + if skippedRecords > 0 { reasons.append("\(skippedRecords) oversized records skipped") } + if unreadableFiles > 0 { reasons.append("\(unreadableFiles) files unreadable") } + if limitReached { reasons.append("scan limit reached") } + coverage.append(.init(runtime: runtime, files: files, partial: partial, + note: partial ? reasons.joined(separator: ", ") : files == 0 ? "No matching local session files" : "Local session files scanned", + skippedRecords: skippedRecords, unreadableFiles: unreadableFiles)) + } + + private func accepts(_ url: URL, runtime: AgentRuntime) -> Bool { + switch runtime { + case .codex, .claude: url.pathExtension == "jsonl" + case .cursor: url.pathExtension == "jsonl" && url.path.contains("/agent-transcripts/") + case .devin: url.pathExtension == "json" + case .grok: url.pathExtension == "jsonl" && url.lastPathComponent != "prompt_history.jsonl" + } + } + + /// Returns the number of oversized lines skipped. + private mutating func scanLines(_ url: URL, runtime: AgentRuntime) throws -> Int { + guard let file = fopen(url.path, "rb") else { throw CocoaError(.fileReadNoPermission) } + defer { fclose(file) } + var pointer: UnsafeMutablePointer? + var capacity = 0 + defer { free(pointer) } + var codexProject: String? + var codexHeaderLines = 0 + var skippedLines = 0 + while Date() < deadline && !Task.isCancelled { + let count = getline(&pointer, &capacity, file) + if count < 0 { break } + if count > limits.maximumLineBytes { skippedLines += 1; continue } + guard let pointer else { continue } + let line = Data(bytesNoCopy: pointer, count: count, deallocator: .none) + switch runtime { + case .codex: + if codexProject == nil && codexHeaderLines < 4 { + codexHeaderLines += 1 + if let row = object(line), string(row["type"]) == "session_meta" { + codexProject = string(dict(row["payload"])?["cwd"]) + } + } + if line.range(of: Data("SKILL.md".utf8)) != nil { scanCodex(line, url: url, project: codexProject) } + case .claude: + if line.range(of: Data("\"Skill\"".utf8)) != nil || line.range(of: Data("tool_use_id".utf8)) != nil { + scanClaude(line, url: url) + } + case .cursor, .grok: + if line.range(of: Data("SKILL.md".utf8)) != nil { + if runtime == .cursor { scanCursor(line, url: url) } + else { scanGrok(line, url: url) } + } + case .devin: break + } + } + if Date() >= deadline || Task.isCancelled { throw CocoaError(.fileReadUnknown) } + if ferror(file) != 0 { throw CocoaError(.fileReadUnknown) } + return skippedLines + } + + private mutating func scanCodex(_ line: Data, url: URL, project: String?) { + guard let row = object(line), string(row["type"]) == "response_item", + let payload = dict(row["payload"]), ["function_call", "custom_tool_call"].contains(string(payload["type"]) ?? "") else { return } + let input = string(payload["arguments"]) ?? string(payload["input"]) ?? "" + for (path, name) in skillPaths(in: input) { + let session = url.path + record(.init(name: name, runtime: .codex, evidence: .pathReference, session: session, + day: day(row["timestamp"]), project: project, skillPath: path, failed: false), + key: "codex:\(session):\(path)") + } + } + + private mutating func scanClaude(_ line: Data, url: URL) { + guard let row = object(line), let message = dict(row["message"]), let blocks = message["content"] as? [Any] else { return } + let type = string(row["type"]) + for value in blocks { + guard let block = dict(value) else { continue } + if type == "assistant", string(block["type"]) == "tool_use", string(block["name"]) == "Skill", + let id = string(block["id"]), let input = dict(block["input"]), let name = skillName(string(input["skill"])) { + claudeCalls[id] = .init(name: name, runtime: .claude, evidence: .toolCall, + session: string(row["sessionId"]) ?? url.path, day: day(row["timestamp"]), + project: string(row["cwd"]), skillPath: nil, failed: false) + } else if type == "user", string(block["type"]) == "tool_result", string(block["tool_use_id"]) != nil, + block["is_error"] as? Bool == true, let id = string(block["tool_use_id"]) { + claudeFailures.insert(id) + } + } + } + + private mutating func scanDevinFile(_ url: URL) throws { + let data = try Data(contentsOf: url, options: [.mappedIfSafe]) + guard data.count <= 32_000_000, let root = object(data), let steps = root["steps"] as? [Any] else { + throw CocoaError(.fileReadCorruptFile) + } + let session = string(root["session_id"]) ?? url.path + for value in steps { + guard let step = dict(value), let calls = step["tool_calls"] as? [Any] else { continue } + for item in calls { + guard let call = dict(item), string(call["function_name"]) == "skill", + let arguments = dict(call["arguments"]), let name = skillName(string(arguments["skill"])) else { continue } + let id = string(call["tool_call_id"]) ?? "\(session):\(string(step["step_id"]) ?? ""):\(name)" + record(.init(name: name, runtime: .devin, evidence: .toolCall, session: session, + day: day(step["timestamp"]), project: nil, skillPath: nil, failed: false), key: "devin:\(id)") + } + } + } + + private mutating func scanCursor(_ line: Data, url: URL) { + guard let row = object(line), let message = dict(row["message"]), let blocks = message["content"] as? [Any] else { return } + for value in blocks { + guard let block = dict(value), string(block["type"]) == "tool_use", string(block["name"]) == "Read", + let input = dict(block["input"]) else { continue } + for candidate in input.values.compactMap(string) { + for (path, name) in skillPaths(in: candidate) { + let session = url.path + record(.init(name: name, runtime: .cursor, evidence: .fileRead, session: session, + day: fileDay(url), project: nil, skillPath: path, failed: false), + key: "cursor:\(session):\(path)") + } + } + } + } + + private mutating func scanGrok(_ line: Data, url: URL) { + guard let row = object(line), let params = dict(row["params"]), let update = dict(params["update"]), + let metadata = dict(update["_meta"]), let tool = dict(metadata["x.ai/tool"]), + string(tool["name"]) == "read_file", let input = dict(update["rawInput"]) else { return } + let session = string(params["sessionId"]) ?? url.path + for candidate in input.values.compactMap(string) { + for (path, name) in skillPaths(in: candidate) { + let id = string(update["toolCallId"]) ?? path + record(.init(name: name, runtime: .grok, evidence: .fileRead, session: session, + day: day(row["timestamp"]), project: nil, skillPath: path, failed: false), + key: "grok:\(session):\(id)") + } + } + } + + private mutating func record(_ observation: SkillActivityObservation, key: String) { + if seen.insert(key).inserted { observations.append(observation) } + } + + private func skillPaths(in text: String) -> [(String, String)] { + let range = NSRange(text.startIndex.. String? { + guard let value, let name = value.split(separator: ":").last, !name.isEmpty, name.utf8.count <= 120 else { return nil } + return String(name) + } + + private func day(_ value: Any?) -> String { + if let text = string(value), text.count >= 10, text[text.index(text.startIndex, offsetBy: 4)] == "-" { + return String(text.prefix(10)) + } + if let number = value as? NSNumber { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: Date(timeIntervalSince1970: number.doubleValue)) + } + return "" + } + + private func fileDay(_ url: URL) -> String { + guard let date = try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate else { return "" } + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + private func object(_ data: Data) -> [String: Any]? { + guard let value = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { return nil } + return value as? [String: Any] + } + private func dict(_ value: Any?) -> [String: Any]? { value as? [String: Any] } + private func string(_ value: Any?) -> String? { value as? String } +} diff --git a/Sources/ContextCore/SkillCleanupPlanner.swift b/Sources/ContextCore/SkillCleanupPlanner.swift new file mode 100644 index 0000000..aae6321 --- /dev/null +++ b/Sources/ContextCore/SkillCleanupPlanner.swift @@ -0,0 +1,152 @@ +import Foundation +import CryptoKit + +public enum SkillCleanupCategory: String, CaseIterable, Sendable { + case ready = "Ready to preview", decision = "Needs a decision", managed = "Plugin ownership", independent = "Keep independent" +} + +public enum SkillCleanupAction: String, Sendable { + case consolidate, compare, scope, managed, independent +} + +public struct SkillCleanupRecommendation: Identifiable, Sendable { + public let id: String + public let title: String + public let reason: String + public let category: SkillCleanupCategory + public let action: SkillCleanupAction + public let records: [SkillRecord] + public let canonicalID: String? + public let plans: [SkillChangePlan] + public let globalLinks: [String] + public var agents: String { Set(records.flatMap(\.exposedRuntimes).map(\.rawValue)).sorted().joined(separator: ", ") } + /// A kept decision expires when instruction content, exposures or policies change. + public var decisionKey: String { + let evidence = records.sorted { $0.id < $1.id }.map { + $0.id + ($0.contentFingerprint ?? "unknown-\($0.modified.timeIntervalSince1970)") + + $0.exposures.map(\.id).sorted().joined() + + $0.policies.map { $0.runtime.rawValue + $0.mode.rawValue }.sorted().joined() + }.joined() + return SHA256.hash(data: Data((id + evidence).utf8)).map { String(format: "%02x", $0) }.joined() + } +} + +public struct SkillCleanupAssessment: Sendable { + public let records: [SkillRecord] + public let recommendations: [SkillCleanupRecommendation] + public let cacheCount: Int + public let archiveCount: Int + public var localRecords: [SkillRecord] { records.filter { $0.ownership == .local && !SkillCleanupPlanner.isArchived($0) } } + public var localNames: Int { Set(localRecords.map { $0.name.lowercased() }).count } + public var readyPlans: [SkillChangePlan] { recommendations.filter { $0.category == .ready }.flatMap(\.plans) } +} + +public enum SkillCleanupPlanner { + public static func isArchived(_ record: SkillRecord) -> Bool { + func archived(_ path: String) -> Bool { + let components = URL(fileURLWithPath: path).deletingLastPathComponent().deletingLastPathComponent().pathComponents + return components.contains { ["fleet-archive", "worktree-archive", "archives", "SkillHistory"].contains($0) } + } + return archived(record.id) && record.exposures.allSatisfy { archived($0.logicalPath) } + } + + public static func assess(records: [SkillRecord], manager: SkillLibraryManager) async -> SkillCleanupAssessment { + let current = records.filter { !isArchived($0) } + let findings = SkillRedundancyAnalyzer.analyze(records: current).findings + let byID = Dictionary(uniqueKeysWithValues: current.map { ($0.id, $0) }) + var recommendations: [SkillCleanupRecommendation] = [] + for finding in findings { + if Task.isCancelled { break } + let members = finding.members.compactMap { byID[$0.id] } + let local = members.filter { $0.ownership == .local } + if local.isEmpty { + // One owner/version review, never a promise that cache files can be deleted. + guard finding.kind == .exactCopy else { continue } + recommendations.append(.init(id: finding.id, title: "Review \(members.first?.name ?? "plugin") installations", + reason: "\(members.count) matching cached instructions. Activation and obsolete versions are unverified. Manage these through the owning plugin installer.", + category: .managed, action: .managed, records: members, canonicalID: nil, plans: [], globalLinks: [])) + continue + } + if finding.kind != .exactCopy { + let separateProjects = finding.kind == .versionDrift && members.allSatisfy { + !$0.exposures.isEmpty && $0.exposures.allSatisfy { $0.scope == .project } + } && !hasOverlappingProjects(members) + recommendations.append(.init(id: finding.id, + title: separateProjects ? "Keep project versions of \(local[0].name) independent" : finding.kind == .versionDrift ? "Choose a version of \(local[0].name)" : "Review \(local.map(\.name).joined(separator: " + "))", + reason: separateProjects ? "These versions belong to separate project scopes. No overlapping folder exposure was found; a matching name does not make them competing installations." : finding.kind == .versionDrift ? "Same name, different instructions. Compare content and affected agents before retiring a source." : "Descriptions overlap; this is a review lead, not proof of redundancy. Compare responsibilities before combining or archiving.", + category: separateProjects ? .independent : .decision, action: separateProjects ? .independent : .compare, + records: members, canonicalID: nil, plans: [], globalLinks: [])) + continue + } + guard local.count > 1 else { continue } + // Partition by checkout: no auto-selected shared source across independent projects. + let ordered = local.sorted { lhs, rhs in + let l = lhs.id.contains("/.agents/skills/"), r = rhs.id.contains("/.agents/skills/") + if l != r { return l } + if lhs.exposures.count != rhs.exposures.count { return lhs.exposures.count > rhs.exposures.count } + return lhs.id < rhs.id + } + let keep = ordered[0] + var plans: [SkillChangePlan] = [], reasons: [String] = [] + for duplicate in ordered.dropFirst() { + do { + let plan = try await manager.prepareConsolidation(duplicate: URL(fileURLWithPath: duplicate.id), canonical: URL(fileURLWithPath: keep.id)) + // External relative references make changing a folder's location significant. + let text = try BoundedTextReader.read(url: URL(fileURLWithPath: keep.id), maximumBytes: 64 * 1024) + if text.contains("../") { reasons.append("External relative references require review."); continue } + plans.append(plan) + } catch { reasons.append(error.localizedDescription) } + } + let independent = !reasons.isEmpty && reasons.allSatisfy { $0.hasPrefix("Independent repository") } + recommendations.append(.init(id: finding.id, title: "Keep one source for \(keep.name)", + reason: plans.isEmpty ? Array(Set(reasons)).sorted().joined(separator: " ") : "\(plans.count) complete folders match, including support files and permissions. Existing agent routes remain as links.\(reasons.isEmpty ? "" : " Other copies need separate review.")", + category: plans.isEmpty ? (independent ? .independent : .decision) : .ready, + action: plans.isEmpty ? (independent ? .independent : .compare) : .consolidate, + records: members, canonicalID: keep.id, plans: plans, globalLinks: [])) + } + // Narrow only a global alias when a project route for the same provider already exists. + for record in current where record.ownership == .local { + let globals = record.exposures.filter { exposure in + guard exposure.scope == .global, exposure.logicalPath != exposure.resolvedPath else { return false } + let folder = URL(fileURLWithPath: exposure.logicalPath).deletingLastPathComponent() + guard (try? folder.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true else { return false } + return record.exposures.contains { $0.scope == .project && $0.provider == exposure.provider } + } + guard !globals.isEmpty else { continue } + recommendations.append(.init(id: "scope:" + record.id, title: "Keep \(record.name) project-scoped?", + reason: "The same source already has project routes and global links. Removing only the selected global links narrows availability outside those projects; the source and project access remain. Decide whether this workflow is truly project-specific.", + category: .decision, action: .scope, records: [record], canonicalID: record.id, plans: [], + globalLinks: Array(Set(globals.map { URL(fileURLWithPath: $0.logicalPath).deletingLastPathComponent().path })).sorted())) + } + let order: [SkillCleanupCategory: Int] = [.ready: 0, .decision: 1, .independent: 2, .managed: 3] + recommendations.sort { + if $0.category != $1.category { return order[$0.category]! < order[$1.category]! } + if $0.plans.count != $1.plans.count { return $0.plans.count > $1.plans.count } + return $0.id < $1.id + } + return SkillCleanupAssessment(records: records, recommendations: recommendations, + cacheCount: records.filter { $0.ownership != .local }.count, + archiveCount: records.filter { isArchived($0) }.count) + } + + private static func hasOverlappingProjects(_ records: [SkillRecord]) -> Bool { + func owner(_ exposure: SkillExposure) -> String? { + for marker in ["/.agents/skills/", "/.codex/skills/", "/.claude/skills/", "/.cursor/skills/", "/.grok/skills/"] { + if let range = exposure.logicalPath.range(of: marker) { return String(exposure.logicalPath[.. i { + for left in records[i].exposures { + for right in records[j].exposures { + // Unknown ownership must remain reviewable, not be declared independent. + guard let a = owner(left), let b = owner(right) else { return true } + if a == b || a.hasPrefix(b + "/") || b.hasPrefix(a + "/") { return true } + } + } + } + } + return false + } +} diff --git a/Sources/ContextCore/SkillFolderContext.swift b/Sources/ContextCore/SkillFolderContext.swift new file mode 100644 index 0000000..23739d3 --- /dev/null +++ b/Sources/ContextCore/SkillFolderContext.swift @@ -0,0 +1,43 @@ +import Foundation + +public struct SkillFolderAgent: Identifiable, Sendable { + public let runtime: AgentRuntime + public let records: [SkillRecord] + public let sources: [AIContextContribution] + public var id: String { runtime.rawValue } + public var summary: SkillGovernanceSummary { .init(records: records, runtime: runtime) } + public var names: Int { Set(records.map { $0.name.lowercased() }).count } + public var globalCount: Int { records.filter { $0.exposures.contains { $0.scope == .global } }.count } + public var projectCount: Int { records.count - globalCount } +} + +public struct SkillFolderContext: Sendable { + public let path: String + public let agents: [SkillFolderAgent] + public let records: [SkillRecord] + public let coverage: AIContextCoverage + + public static func resolve(report: AIContextDiscoveryReport, directory: URL) -> Self { + let path = directory.resolvingSymlinksInPath().standardizedFileURL.path + let rankings = AIContextDiscovery.rankings(items: report.items, in: directory, preserveSkillAliases: true) + let agents = AgentRuntime.allCases.map { runtime in + let sources = rankings.first { $0.provider.rawValue == runtime.rawValue }?.sources.filter { $0.origin != .installedOnly } ?? [] + let scoped = AIContextDiscoveryReport(items: sources.map(\.item), folderRankings: [], coverage: report.coverage, elapsed: report.elapsed) + let records = SkillPolicyResolver.resolve(report: scoped).records.filter { $0.policy(for: runtime)?.isExposed == true } + return SkillFolderAgent(runtime: runtime, records: records, sources: sources) + } + let grouped = Dictionary(grouping: agents.flatMap(\.records), by: \.id) + let records = grouped.values.compactMap { copies -> SkillRecord? in + guard let first = copies.first else { return nil } + let exposures = Dictionary(grouping: copies.flatMap(\.exposures), by: \.id).values.compactMap(\.first).sorted { $0.id < $1.id } + let policies = agents.map { agent in + agent.records.first { $0.id == first.id }?.policy(for: agent.runtime) + ?? SkillRuntimePolicy(runtime: agent.runtime, mode: .unsupported, explicit: true, + reason: "No applicable exposure in this folder.", invocation: "", isExposed: false) + } + return SkillRecord(id: first.id, name: first.name, description: first.description, logicalBytes: first.logicalBytes, + modified: first.modified, exposures: exposures, policies: policies, contentFingerprint: first.contentFingerprint) + }.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + return Self(path: path, agents: agents, records: records, coverage: report.coverage) + } +} diff --git a/Sources/ContextCore/SkillInventoryAnswers.swift b/Sources/ContextCore/SkillInventoryAnswers.swift new file mode 100644 index 0000000..5553ec6 --- /dev/null +++ b/Sources/ContextCore/SkillInventoryAnswers.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Summarizes physical definitions, never counting an exposure link as another skill. +public struct SkillInventoryAnswers: Sendable { + public let local: [SkillRecord] + public let pluginCount: Int + public let archivedCount: Int + public let systemCount: Int + public let uniqueNames: Int + public let uniqueInstructions: Int + public let unverifiedInstructions: Int + public let extraInstructionCopies: Int + public let globalCount: Int + public let projectOnlyCount: Int + public let noAccessCount: Int + + public init(records: [SkillRecord]) { + let physical = Dictionary(grouping: records, by: \.id).values.compactMap(\.first) + archivedCount = physical.filter { SkillCleanupPlanner.isArchived($0) }.count + let current = physical.filter { !SkillCleanupPlanner.isArchived($0) } + pluginCount = current.filter { $0.ownership == .plugin }.count + local = current.filter { $0.ownership == .local } + systemCount = current.count - pluginCount - local.count + uniqueNames = Set(local.map { $0.name.lowercased() }).count + let fingerprints = local.compactMap(\.contentFingerprint) + uniqueInstructions = Set(fingerprints).count + unverifiedInstructions = local.count - fingerprints.count + extraInstructionCopies = fingerprints.count - uniqueInstructions + globalCount = local.filter { $0.exposures.contains { $0.scope == .global } }.count + projectOnlyCount = local.filter { !$0.exposures.contains { $0.scope == .global } && $0.exposures.contains { $0.scope == .project } }.count + noAccessCount = local.filter { $0.exposedRuntimes.isEmpty }.count + } + + public func count(for runtime: AgentRuntime) -> Int { + local.filter { $0.policy(for: runtime)?.isExposed == true }.count + } + + /// Includes counterpart sources outside the selected location, within the scanned scope. + /// This is an instruction match only; mutation still requires whole-folder verification. + public static func matchingCopyIDs(near selectedIDs: Set, records: [SkillRecord]) -> Set { + let groups = Dictionary(grouping: records.filter { $0.contentFingerprint != nil }, by: { $0.contentFingerprint! }) + return Set(groups.values.flatMap { group -> [String] in + let ids = Set(group.map(\.id)) + return ids.count > 1 && !ids.isDisjoint(with: selectedIDs) ? Array(ids) : [] + }) + } +} diff --git a/Sources/ContextCore/SkillInvocationEditor.swift b/Sources/ContextCore/SkillInvocationEditor.swift new file mode 100644 index 0000000..6f87723 --- /dev/null +++ b/Sources/ContextCore/SkillInvocationEditor.swift @@ -0,0 +1,105 @@ +import Foundation + +/// Surgical edits to simple policy mappings. Ambiguous YAML is left for the content editor. +public enum SkillInvocationEditor { + public struct Result: Sendable { + public let files: [String: Data] + public let before: String + public let after: String + public let explanation: String + } + + public static func edit(files: [String: Data], runtime: AgentRuntime, automatic: Bool) throws -> Result { + var result = files + let path: String + let before: String + let after: String + let explanation: String + switch runtime { + case .codex: + path = "agents/openai.yaml" + guard let text = String(data: files[path] ?? Data(), encoding: .utf8) else { throw failure() } + before = text + var lines = text.components(separatedBy: "\n") + let policies = lines.indices.filter { lines[$0].hasPrefix("policy:") } + guard policies.count <= 1, !text.contains("<<:"), !text.contains("\t") else { throw failure() } + if let start = policies.first { + guard lines[start].trimmingCharacters(in: .whitespaces) == "policy:" else { throw failure() } + let end = lines.indices.dropFirst(start + 1).first { + !lines[$0].isEmpty && !lines[$0].hasPrefix(" ") && !lines[$0].hasPrefix("#") + } ?? lines.count + let block = Array(lines[(start + 1)..