Created
August 11, 2026 20:55
-
-
Save dfinke/3bfd3bebc38d92b0af2c15d85dd11e19 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| [CmdletBinding()] | |
| param( | |
| [ValidateSet('init', 'ingest', 'index', 'query', 'lint', 'help')] | |
| [string]$Operation = 'help', | |
| [string]$Path, | |
| [string]$Question, | |
| [string]$Root = (Split-Path -Parent $PSScriptRoot) | |
| ) | |
| Set-StrictMode -Version Latest | |
| $ErrorActionPreference = 'Stop' | |
| function Get-WikiPath { | |
| param([string]$Name) | |
| switch ($Name) { | |
| 'Wiki' { Join-Path $Root 'wiki'; break } | |
| 'Topics' { Join-Path $Root 'wiki\topics'; break } | |
| 'Sources' { Join-Path $Root 'wiki\sources'; break } | |
| 'Index' { Join-Path $Root 'wiki\index.md'; break } | |
| 'Log' { Join-Path $Root 'wiki\log.md'; break } | |
| 'Schema' { Join-Path $Root 'AGENTS.md'; break } | |
| default { throw "Unknown wiki path: $Name" } | |
| } | |
| } | |
| function Ensure-WikiLayout { | |
| foreach ($directory in @((Get-WikiPath 'Wiki'), (Get-WikiPath 'Topics'), (Get-WikiPath 'Sources'))) { | |
| if (-not (Test-Path -LiteralPath $directory)) { | |
| New-Item -ItemType Directory -Path $directory -Force | Out-Null | |
| } | |
| } | |
| if (-not (Test-Path -LiteralPath (Get-WikiPath 'Log'))) { | |
| @('# Wiki log', '', 'Append-only record of wiki operations.', '') | Set-Content -LiteralPath (Get-WikiPath 'Log') -Encoding utf8NoBOM | |
| } | |
| } | |
| function Get-MarkdownMetadata { | |
| param([string]$FilePath) | |
| $content = Get-Content -LiteralPath $FilePath -Raw -Encoding utf8 | |
| $metadata = [ordered]@{} | |
| $body = $content | |
| if ($content -match '(?s)^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?(.*)$') { | |
| $frontMatter = $Matches[1] | |
| $body = $Matches[2] | |
| foreach ($line in ($frontMatter -split '\r?\n')) { | |
| if ($line -match '^\s*([^:#][^:]*?)\s*:\s*["'']?(.*?)["'']?\s*$') { | |
| $metadata[$Matches[1].Trim()] = $Matches[2].Trim() | |
| } | |
| } | |
| } | |
| [pscustomobject]@{ Path = $FilePath; Metadata = $metadata; Body = $body.Trim() } | |
| } | |
| function ConvertTo-Slug { | |
| param([string]$Text) | |
| $slug = ($Text.ToLowerInvariant() -replace '[^a-z0-9]+', '-') -replace '(^-|-$)', '' | |
| if ([string]::IsNullOrWhiteSpace($slug)) { return 'untitled' } | |
| return $slug | |
| } | |
| function Get-RelativeRawPath { | |
| param([string]$FilePath) | |
| $relative = [IO.Path]::GetRelativePath($Root, $FilePath) | |
| return ($relative -replace '\\', '/') | |
| } | |
| function Get-SourceTitle { | |
| param($Document) | |
| $label = [string]$Document.Metadata['label'] | |
| $date = [string]$Document.Metadata['date'] | |
| $rawName = [IO.Path]::GetFileNameWithoutExtension($Document.Path) | |
| $firstLine = ($Document.Body -split '\r?\n' | Where-Object { $_.Trim() } | Select-Object -First 1) | |
| if ($firstLine -match '^\[[^]]+\]\s*(.*)$') { $firstLine = $Matches[1] } | |
| if ([string]::IsNullOrWhiteSpace($firstLine)) { $firstLine = $rawName } | |
| $firstLine = ($firstLine -replace '\s+', ' ').Trim() | |
| if ($firstLine.Length -gt 90) { $firstLine = $firstLine.Substring(0, 87) + '...' } | |
| return "$date · $label · $firstLine" | |
| } | |
| function Add-WikiLog { | |
| param([string]$Action, [string]$Details) | |
| $stamp = Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK' | |
| Add-Content -LiteralPath (Get-WikiPath 'Log') -Encoding utf8NoBOM -Value @("## [$stamp] $Action | $Details", '') | |
| } | |
| function Get-WikiDocuments { | |
| Ensure-WikiLayout | |
| Get-ChildItem -LiteralPath (Get-WikiPath 'Wiki') -Recurse -File -Filter '*.md' | | |
| Where-Object { $_.Name -notin @('index.md', 'log.md') } | | |
| ForEach-Object { Get-MarkdownMetadata $_.FullName } | |
| } | |
| function Update-WikiIndex { | |
| Ensure-WikiLayout | |
| $documents = @(Get-WikiDocuments | Sort-Object Path) | |
| $lines = [System.Collections.Generic.List[string]]::new() | |
| $lines.Add('# Personal Wiki Index') | |
| $lines.Add('') | |
| $lines.Add('Content-oriented catalog of the maintained wiki. Raw diary entries remain immutable under [`2025/`](../2025/).') | |
| $lines.Add('') | |
| $lines.Add("_Last rebuilt: $(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK')_") | |
| $lines.Add('') | |
| $groups = $documents | Group-Object { if ($_.Metadata.Contains('type')) { [string]$_.Metadata['type'] } else { 'unclassified' } } | Sort-Object Name | |
| foreach ($group in $groups) { | |
| $lines.Add("## $($group.Name)") | |
| $lines.Add('') | |
| foreach ($document in ($group.Group | Sort-Object Path)) { | |
| $relative = [IO.Path]::GetRelativePath((Get-WikiPath 'Wiki'), $document.Path) -replace '\\', '/' | |
| $title = [IO.Path]::GetFileNameWithoutExtension($document.Path) -replace '-', ' ' | |
| $heading = ($document.Body -split '\r?\n' | Where-Object { $_ -match '^# ' } | Select-Object -First 1) | |
| if ($heading) { $title = $heading.Substring(2).Trim() } | |
| $summary = ($document.Body -split '\r?\n' | Where-Object { $_.Trim() -and $_ -notmatch '^#' -and $_ -notmatch '^---' } | Select-Object -First 1) | |
| if ([string]::IsNullOrWhiteSpace($summary)) { $summary = 'No summary recorded.' } | |
| $summary = ($summary -replace '[\[\]]', '') -replace '\s+', ' ' | |
| if ($summary.Length -gt 180) { $summary = $summary.Substring(0, 177) + '...' } | |
| $lines.Add("- [$title]($relative) — $summary") | |
| } | |
| $lines.Add('') | |
| } | |
| $lines.Add('## Navigation') | |
| $lines.Add('') | |
| $lines.Add('- [Overview](overview.md) — current synthesis and open questions') | |
| $lines.Add('- [Wiki log](log.md) — chronological operation history') | |
| $lines.Add('- [Schema](../AGENTS.md) — maintenance contract for agents') | |
| $lines.Add('- [Raw sources](../2025/) — immutable captured diary entries') | |
| $lines | Set-Content -LiteralPath (Get-WikiPath 'Index') -Encoding utf8NoBOM | |
| return "Indexed $($documents.Count) wiki pages." | |
| } | |
| function Invoke-WikiIngest { | |
| param([string]$InputPath) | |
| Ensure-WikiLayout | |
| if ([string]::IsNullOrWhiteSpace($InputPath)) { throw 'ingest requires -Path pointing to a raw markdown file or directory.' } | |
| $resolved = (Resolve-Path -LiteralPath $InputPath).Path | |
| $items = @() | |
| if ((Get-Item -LiteralPath $resolved).PSIsContainer) { | |
| $items = @(Get-ChildItem -LiteralPath $resolved -Recurse -File -Filter '*.md' | Where-Object { $_.FullName -notlike "$(Get-WikiPath 'Wiki')*" -and $_.Name -ne 'llm-wiki.md' }) | |
| } else { | |
| $items = @(Get-Item -LiteralPath $resolved) | |
| } | |
| if ($items.Count -eq 0) { throw 'No markdown source files found.' } | |
| $created = 0 | |
| foreach ($item in $items) { | |
| $document = Get-MarkdownMetadata $item.FullName | |
| $date = [string]$document.Metadata['date'] | |
| if ([string]::IsNullOrWhiteSpace($date)) { $date = $item.LastWriteTime.ToString('yyyy-MM-dd') } | |
| $slug = ConvertTo-Slug ([IO.Path]::GetFileNameWithoutExtension($item.Name)) | |
| $target = Join-Path (Get-WikiPath 'Sources') "entry-$date-$slug.md" | |
| $rawLink = Get-RelativeRawPath $item.FullName | |
| $title = Get-SourceTitle $document | |
| $label = [string]$document.Metadata['label'] | |
| $createdAt = [string]$document.Metadata['created'] | |
| $body = $document.Body | |
| $urls = [regex]::Matches($body, 'https?://[^\s)]+') | ForEach-Object { $_.Value } | Select-Object -Unique | |
| $out = [System.Collections.Generic.List[string]]::new() | |
| $out.Add('---') | |
| $out.Add("type: source") | |
| $out.Add("date: '$date'") | |
| $out.Add("source_file: '$rawLink'") | |
| $out.Add("label: '$label'") | |
| $out.Add('status: captured') | |
| $out.Add('---') | |
| $out.Add('') | |
| $out.Add("# $title") | |
| $out.Add('') | |
| $out.Add("> This page is an indexed interpretation of the immutable source. The source itself is [`$rawLink`](../../$rawLink).") | |
| $out.Add('') | |
| $out.Add('## Captured content') | |
| $out.Add('') | |
| $out.Add($body) | |
| $out.Add('') | |
| if ($urls.Count -gt 0) { | |
| $out.Add('## External links') | |
| $out.Add('') | |
| foreach ($url in $urls) { $out.Add("- $url") } | |
| $out.Add('') | |
| } | |
| $out.Add('## Maintainer review') | |
| $out.Add('') | |
| $out.Add('- LLM synthesis and cross-link review: pending.') | |
| $out.Add("- Captured at: $createdAt") | |
| $out | Set-Content -LiteralPath $target -Encoding utf8NoBOM | |
| $created++ | |
| } | |
| Update-WikiIndex | Out-Null | |
| Add-WikiLog 'ingest' "Captured $created source page(s) from $InputPath." | |
| return "Ingested $created source page(s)." | |
| } | |
| function Search-Wiki { | |
| param([string]$Text) | |
| if ([string]::IsNullOrWhiteSpace($Text)) { throw 'query requires -Question or -Path containing the query.' } | |
| Ensure-WikiLayout | |
| $terms = @($Text.ToLowerInvariant() -split '\s+' | Where-Object { $_.Length -ge 2 } | ForEach-Object { [regex]::Escape($_) }) | |
| $results = foreach ($document in Get-WikiDocuments) { | |
| $lower = $document.Body.ToLowerInvariant() | |
| $hits = @($terms | Where-Object { $lower -match $_ }).Count | |
| if ($hits -gt 0) { | |
| $relative = [IO.Path]::GetRelativePath($Root, $document.Path) -replace '\\', '/' | |
| $snippet = ($document.Body -split '\r?\n' | Where-Object { $line = $_.ToLowerInvariant(); @($terms | Where-Object { $line -match $_ }).Count -gt 0 } | Select-Object -First 1) | |
| [pscustomobject]@{ Score = $hits; File = $relative; Snippet = ($snippet -replace '\s+', ' ').Trim() } | |
| } | |
| } | |
| $results | Sort-Object Score -Descending, File | Format-Table -AutoSize | |
| Add-WikiLog 'query' "Searched wiki for: $Text" | |
| } | |
| function Test-WikiHealth { | |
| Ensure-WikiLayout | |
| $documents = @(Get-WikiDocuments) | |
| $allPages = @{} | |
| foreach ($document in $documents) { $allPages[[IO.Path]::GetFullPath($document.Path)] = $true } | |
| $broken = [System.Collections.Generic.List[string]]::new() | |
| $inbound = @{} | |
| foreach ($document in $documents) { | |
| $links = [regex]::Matches($document.Body, '\[[^]]+\]\(([^)#]+)(?:#[^)]+)?\)') | |
| foreach ($link in $links) { | |
| $target = $link.Groups[1].Value | |
| if ($target -match '^(https?|mailto):') { continue } | |
| $absolute = [IO.Path]::GetFullPath((Join-Path (Split-Path -Parent $document.Path) $target)) | |
| if (-not (Test-Path -LiteralPath $absolute)) { $broken.Add("$($document.Path): $target") } | |
| elseif ($absolute -like "$(Get-WikiPath 'Wiki')*") { $inbound[$absolute] = [int]$inbound[$absolute] + 1 } | |
| } | |
| } | |
| $orphans = @($documents | Where-Object { $path = [IO.Path]::GetFullPath($_.Path); -not $inbound.ContainsKey($path) -and $_.Path -notlike "$(Get-WikiPath 'Wiki')\sources*" } | ForEach-Object { [IO.Path]::GetRelativePath($Root, $_.Path) -replace '\\', '/' }) | |
| [pscustomobject]@{ | |
| WikiPages = $documents.Count | |
| BrokenLinks = $broken.Count | |
| OrphanPages = $orphans.Count | |
| SourcePages = @($documents | Where-Object { $_.Metadata['type'] -eq 'source' }).Count | |
| BrokenLinkDetails = ($broken -join '; ') | |
| OrphanPageDetails = ($orphans -join '; ') | |
| } | Format-List | |
| Add-WikiLog 'lint' "Health check: $($documents.Count) pages, $($broken.Count) broken links, $($orphans.Count) orphans." | |
| } | |
| switch ($Operation) { | |
| 'init' { | |
| Ensure-WikiLayout | |
| if (-not (Test-Path -LiteralPath (Get-WikiPath 'Index'))) { Update-WikiIndex | Out-Null } | |
| Add-WikiLog 'init' 'Initialized wiki layout.' | |
| Write-Output "Wiki initialized at $(Get-WikiPath 'Wiki')." | |
| } | |
| 'ingest' { Invoke-WikiIngest -InputPath $Path } | |
| 'index' { Update-WikiIndex } | |
| 'query' { Search-Wiki -Text $(if ($Question) { $Question } else { $Path }) } | |
| 'lint' { Test-WikiHealth } | |
| default { | |
| @('LLM Wiki tool', '', 'Usage:', ' pwsh .\tools\LlmWiki.ps1 init', ' pwsh .\tools\LlmWiki.ps1 ingest -Path .\2025\12\03\entry.md', ' pwsh .\tools\LlmWiki.ps1 index', ' pwsh .\tools\LlmWiki.ps1 query -Question "agent skills"', ' pwsh .\tools\LlmWiki.ps1 lint', '', 'Raw sources are never modified. Ingest creates source pages, index rebuilds navigation, query searches the wiki, and lint reports link/orphan issues.') -join [Environment]::NewLine | |
| } | |
| } | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment