PSDiaryAI.psm1
|
#Requires -Version 5.1 <# .SYNOPSIS PSDiaryAI module for quick note-taking with automatic timestamping. .DESCRIPTION Provides a simple way to capture diary notes with timestamps and optional shorthand labels. Notes are organized as timestamped Markdown files under year/month/day folders. .NOTES Author: Doug Finke Version: 0.1.0 #> #region Configuration # Root directory for all diary entries. Override with $env:PSDiaryAIRoot. $configuredDiaryRoot = $env:PSDiaryAIRoot $configuredDiaryRootSource = 'Environment' if ([string]::IsNullOrWhiteSpace($configuredDiaryRoot)) { $configuredDiaryRootSource = 'Default' $documentsFolder = [Environment]::GetFolderPath('MyDocuments') if ([string]::IsNullOrWhiteSpace($documentsFolder)) { $documentsFolder = $HOME } $configuredDiaryRoot = Join-Path $documentsFolder 'PSDiaryAI' } $script:DiaryRoot = $configuredDiaryRoot $script:DiaryRootSource = $configuredDiaryRootSource # Shorthand prefix mappings: prefix -> label # Add new prefixes here to extend functionality $script:PrefixMap = [ordered]@{ 'ac ' = '[AIChat]' # AI chat links or notes 'gh ' = '[GITHUB]' # GitHub links 'l ' = '[LOCAL]' # Local file paths 'lnk ' = '[LINK]' # General links 'n ' = '[NOTE]' # General notes 'sv ' = '[SHORTVID]' # Short video ideas 'x ' = '[X]' # X/Twitter links 'yt ' = '[YOUTUBE]' # YouTube links } $script:FormatDataPath = Join-Path $PSScriptRoot 'PSDiaryAI.format.ps1xml' if (Test-Path -LiteralPath $script:FormatDataPath) { Update-FormatData -PrependPath $script:FormatDataPath -ErrorAction SilentlyContinue } #endregion #region Private Functions function Get-DiaryLabel { <# .SYNOPSIS Converts shorthand prefixes to labels and returns formatted note. #> [CmdletBinding()] param([string]$Note) foreach ($prefix in $script:PrefixMap.Keys) { if ($Note.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { $label = $script:PrefixMap[$prefix] $content = $Note.Substring($prefix.Length) return "$label $content" } } # No matching prefix - return note as-is return $Note } function Get-DiaryLabelInfo { <# .SYNOPSIS Converts shorthand prefixes to label metadata and formatted note text. #> [CmdletBinding()] param([string]$Note) foreach ($prefix in $script:PrefixMap.Keys) { if ($Note.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { $label = $script:PrefixMap[$prefix] $content = $Note.Substring($prefix.Length) $labelName = $label.Trim('[', ']') return [PSCustomObject]@{ Label = $label LabelName = $labelName Content = $content FormattedNote = "$label $content" } } } return [PSCustomObject]@{ Label = $null LabelName = $null Content = $Note FormattedNote = $Note } } function ConvertTo-DiaryYamlString { [CmdletBinding()] param([AllowNull()][string]$Value) if ($null -eq $Value) { $Value = '' } return "'$($Value -replace "'", "''")'" } function Write-DiaryUtf8NoBom { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$Content ) $encoding = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($Path, $Content, $encoding) } function Get-DiaryEntryPreview { <# .SYNOPSIS Removes front matter and collapses entry content for scan-friendly output. #> [CmdletBinding()] param([string]$Content) $body = [regex]::Replace( $Content, '\A---\r?\n.*?\r?\n---\r?\n?', '', [System.Text.RegularExpressions.RegexOptions]::Singleline ) return (($body.Trim() -split '\r?\n' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) -join ' ') } function Get-DiaryDayFolder { <# .SYNOPSIS Gets or creates the year/month/day folder for a timestamp. #> [CmdletBinding()] param([DateTime]$Timestamp = (Get-Date)) if (-not (Test-Path -LiteralPath $script:DiaryRoot)) { New-Item -Path $script:DiaryRoot -ItemType Directory -Force | Out-Null } $folderPath = Join-Path $script:DiaryRoot $Timestamp.ToString('yyyy') $folderPath = Join-Path $folderPath $Timestamp.ToString('MM') $folderPath = Join-Path $folderPath $Timestamp.ToString('dd') if (-not (Test-Path -LiteralPath $folderPath)) { New-Item -Path $folderPath -ItemType Directory -Force | Out-Null } return $folderPath } function New-DiaryEntryPath { <# .SYNOPSIS Creates a collision-safe Markdown file path for a timestamped entry. #> [CmdletBinding()] param([DateTime]$Timestamp = (Get-Date)) $folderPath = Get-DiaryDayFolder -Timestamp $Timestamp $baseName = $Timestamp.ToString('yyyyMMddHHmmss') $entryPath = Join-Path $folderPath "$baseName.md" $suffix = 2 while (Test-Path -LiteralPath $entryPath) { $entryPath = Join-Path $folderPath ('{0}-{1:000}.md' -f $baseName, $suffix) $suffix++ } return $entryPath } function New-DiaryEntryMarkdown { <# .SYNOPSIS Builds Markdown content with lightweight YAML front matter. #> [CmdletBinding()] param( [Parameter(Mandatory)] [DateTime]$Timestamp, [Parameter(Mandatory)] [string]$FormattedNote, [AllowNull()] [string]$LabelName ) $offset = [TimeZoneInfo]::Local.GetUtcOffset($Timestamp) $created = ([DateTimeOffset]::new($Timestamp, $offset)).ToString('yyyy-MM-ddTHH:mm:sszzz') return (@( '---' "created: $(ConvertTo-DiaryYamlString $created)" "date: $(ConvertTo-DiaryYamlString $Timestamp.ToString('yyyy-MM-dd'))" "time: $(ConvertTo-DiaryYamlString $Timestamp.ToString('HH:mm:ss'))" "hour: $($Timestamp.ToString('HH'))" "label: $(ConvertTo-DiaryYamlString $LabelName)" "source: $(ConvertTo-DiaryYamlString 'psdiaryai')" '---' '' $FormattedNote ) -join "`n") + "`n" } function Get-DiaryEntryFiles { <# .SYNOPSIS Gets Markdown entry files that match the new diary path convention. #> [CmdletBinding()] param() if (-not (Test-Path -LiteralPath $script:DiaryRoot)) { return @() } $root = (Resolve-Path -LiteralPath $script:DiaryRoot).Path.TrimEnd('\', '/') $pattern = '^{0}[\\/](?<year>\d{{4}})[\\/](?<month>\d{{2}})[\\/](?<day>\d{{2}})[\\/](?<file>\d{{14}}(?:-\d{{3}})?\.md)$' -f [regex]::Escape($root) Get-ChildItem -LiteralPath $script:DiaryRoot -Recurse -File -Filter '*.md' | Where-Object { $_.FullName -match $pattern } | ForEach-Object { $stamp = [DateTime]::ParseExact( $_.BaseName.Substring(0, 14), 'yyyyMMddHHmmss', [Globalization.CultureInfo]::InvariantCulture ) $sequence = if ($_.BaseName.Length -gt 14 -and $_.BaseName -match '-(?<sequence>\d{3})$') { [int]$Matches.sequence } else { 1 } [PSCustomObject]@{ DateTime = $stamp Date = $stamp.ToString('yyyy-MM-dd') Time = $stamp.ToString('HH:mm:ss') Hour = [int]$stamp.ToString('HH') Sequence = $sequence Name = $_.Name Path = $_.FullName } } } #endregion #region Public Functions function Get-DiaryRoot { <# .SYNOPSIS Shows the current PSDiaryAI diary root. .DESCRIPTION Returns the folder PSDiaryAI uses for diary entry discovery and capture, plus whether the folder currently exists and how it was configured. #> [CmdletBinding()] param() $path = $script:DiaryRoot $exists = Test-Path -LiteralPath $path $resolvedPath = if ($exists) { (Resolve-Path -LiteralPath $path).Path } else { [System.IO.Path]::GetFullPath($path) } [PSCustomObject]@{ Path = $resolvedPath Exists = $exists Source = $script:DiaryRootSource EnvVar = 'PSDiaryAIRoot' EnvVarValue = $env:PSDiaryAIRoot } } function Set-DiaryRoot { <# .SYNOPSIS Sets the current PSDiaryAI diary root. .DESCRIPTION Updates the diary root for the current PowerShell session and sets $env:PSDiaryAIRoot for subsequent imports in the same process. Use -Persist to write the value to the current user's environment. #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory, Position = 0)] [string]$Path, [switch]$Create, [switch]$Persist, [switch]$PassThru ) $expandedPath = [Environment]::ExpandEnvironmentVariables($Path) $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($expandedPath) if ($Create -and -not (Test-Path -LiteralPath $resolvedPath)) { if ($PSCmdlet.ShouldProcess($resolvedPath, 'Create diary root folder')) { New-Item -Path $resolvedPath -ItemType Directory -Force | Out-Null } } if (-not (Test-Path -LiteralPath $resolvedPath)) { Write-Warning "Diary root does not exist yet: $resolvedPath. It will be created when you write a diary note, or pass -Create now." } if ($PSCmdlet.ShouldProcess($resolvedPath, 'Set diary root')) { $script:DiaryRoot = $resolvedPath $script:DiaryRootSource = if ($Persist) { 'UserEnvironment' } else { 'Session' } $env:PSDiaryAIRoot = $resolvedPath if ($Persist) { [Environment]::SetEnvironmentVariable('PSDiaryAIRoot', $resolvedPath, 'User') } } if ($PassThru) { Get-DiaryRoot } } function Write-DiaryNote { <# .SYNOPSIS Captures a timestamped note to today's diary. .DESCRIPTION Writes a note to a timestamped Markdown file under YYYY/MM/DD. Supports shorthand prefixes that convert to semantic labels: - n -> [NOTE] - x -> [X] (for X/Twitter links) - yt -> [YOUTUBE] (for YouTube links) - l -> [LOCAL] (for local file paths) - gh -> [GITHUB] (for GitHub links) - sv -> [SHORTVID] (for short video ideas) - lnk -> [LINK] (for general links) .PARAMETER Note The note text to capture. If omitted, prompts interactively. .EXAMPLE Write-DiaryNote "Remember to review PR" # Creates: YYYY/MM/DD/YYYYMMDDHHmmss.md .EXAMPLE d "n Call mom tomorrow" # Creates a Markdown entry with label NOTE .EXAMPLE d "yt https://youtube.com/watch?v=abc123" # Creates a Markdown entry with label YOUTUBE .EXAMPLE d "sv Quick PowerShell tip on arrays" # Creates a Markdown entry with label SHORTVID .EXAMPLE d "lnk https://example.com/useful-article" # Creates a Markdown entry with label LINK .EXAMPLE d # Prompts for note input interactively #> [CmdletBinding()] param( [Parameter(Position = 0, ValueFromPipeline)] [string]$Note, [switch]$AI, [string]$AIModel = 'openai:gpt-5.5' ) process { # Interactive mode: prompt until non-empty input if ([string]::IsNullOrWhiteSpace($Note)) { do { $Note = Read-Host -Prompt 'Enter note' } while ([string]::IsNullOrWhiteSpace($Note)) } $timestamp = Get-Date $labelInfo = Get-DiaryLabelInfo -Note $Note $entryPath = New-DiaryEntryPath -Timestamp $timestamp $entryContent = New-DiaryEntryMarkdown -Timestamp $timestamp -FormattedNote $labelInfo.FormattedNote -LabelName $labelInfo.LabelName Write-DiaryUtf8NoBom -Path $entryPath -Content $entryContent # Confirm to user Write-Host "Saved: $entryPath" -ForegroundColor Green if ($AI) { $aiPath = [System.IO.Path]::ChangeExtension($entryPath, '.ai.md') $modulePath = $PSCommandPath $job = Start-Job -Name "DiaryAI-$([System.IO.Path]::GetFileNameWithoutExtension($entryPath))" -ScriptBlock { param( [string]$ModulePath, [string]$EntryPath, [string]$Model ) Import-Module -Name $ModulePath -Force Invoke-DiaryEntryAI -Path $EntryPath -Model $Model | Out-Null } -ArgumentList $modulePath, $entryPath, $AIModel Write-Host "AI companion queued: $aiPath (Job Id: $($job.Id))" -ForegroundColor Cyan } } } function Show-DiaryPrefixes { <# .SYNOPSIS Displays all available shorthand prefixes and their corresponding labels. .DESCRIPTION Lists the current prefix mappings used for diary note categorization. Each prefix converts to a semantic label when capturing notes. .EXAMPLE Show-DiaryPrefixes # Displays: # Available diary prefixes: # n -> [NOTE] # x -> [X] # ... #> [CmdletBinding()] param() Write-Host "Available diary prefixes:" -ForegroundColor Cyan # Find max prefix length for alignment $maxPrefix = ($script:PrefixMap.Keys | ForEach-Object { $_.TrimEnd() }).ForEach({ $_.Length }) | Measure-Object -Maximum | Select-Object -ExpandProperty Maximum $maxLabel = ($script:PrefixMap.Values | ForEach-Object { $_ }).ForEach({ $_.Length }) | Measure-Object -Maximum | Select-Object -ExpandProperty Maximum foreach ($prefix in $script:PrefixMap.Keys) { $label = $script:PrefixMap[$prefix] $prefixPad = $prefix.TrimEnd().PadRight($maxPrefix) $labelPad = $label.PadRight($maxLabel) Write-Host (" {0} -> {1}" -f $prefixPad, $labelPad) -ForegroundColor Green } } function Get-DiaryEntries { <# .SYNOPSIS Lists timestamped Markdown diary entries. .DESCRIPTION Reads entries from the YYYY/MM/DD folder structure and can filter by today's entries, entries from a start date through today, a date range, or hour of day. .PARAMETER Today Filter to today's entries. .PARAMETER StartDate The start date for a range query. If -EndDate is omitted, today is used. Use -Date as a compatibility alias. .PARAMETER EndDate The end date for a range query. Use with -StartDate. To get one specific day, pass the same date for -StartDate and -EndDate. .PARAMETER Only Return only the start date instead of the range from start date to today. .PARAMETER Hour Filter to one hour of day, from 0 to 23. .PARAMETER IncludeContent Kept for compatibility. A one-line content preview is included by default. .PARAMETER Raw Return the full entry metadata object, including Date, Time, Hour, Sequence, Name, Path, and Content. .PARAMETER Full Return the readable note preview in an expanded list view without table truncation. Front matter is still hidden; use -Raw for metadata. .EXAMPLE Get-DiaryEntries -StartDate '2026-04-01' -EndDate '2026-04-30' -Hour 13 .EXAMPLE gde -Today .EXAMPLE gde -Yesterday .EXAMPLE gde 5/1 -Raw .EXAMPLE gde 5/10 -Only -Full #> [CmdletBinding(DefaultParameterSetName = 'All')] param( [Parameter(ParameterSetName = 'Today')] [switch]$Today, [Parameter(ParameterSetName = 'Yesterday')] [switch]$Yesterday, [Parameter(Mandatory, Position = 0, ParameterSetName = 'Range')] [Alias('Date')] [DateTime]$StartDate, [Parameter(Position = 1, ParameterSetName = 'Range')] [DateTime]$EndDate, [Parameter(ParameterSetName = 'Range')] [switch]$Only, [ValidateRange(0, 23)] [int]$Hour, [switch]$IncludeContent, [switch]$Raw, [switch]$Full ) if ($Raw -and $Full) { throw "Use either -Raw or -Full, not both." } if ($PSCmdlet.ParameterSetName -eq 'Range') { if ($Only -and $PSBoundParameters.ContainsKey('EndDate')) { throw "Use either -Only or -EndDate, not both." } if ($Only) { $EndDate = $StartDate } elseif (-not $PSBoundParameters.ContainsKey('EndDate')) { $EndDate = (Get-Date).Date } if ($EndDate -lt $StartDate) { throw "EndDate must be greater than or equal to StartDate." } } $entries = Get-DiaryEntryFiles $shouldDefaultToToday = $PSCmdlet.ParameterSetName -eq 'All' -and $MyInvocation.InvocationName -eq 'gde' $dateDescription = $null if ($PSCmdlet.ParameterSetName -eq 'Today' -or $shouldDefaultToToday) { $targetDate = Get-Date $entries = $entries | Where-Object { $_.DateTime.Date -eq $targetDate.Date } $dateDescription = $targetDate.ToString('yyyy-MM-dd') } elseif ($PSCmdlet.ParameterSetName -eq 'Yesterday') { $targetDate = (Get-Date).Date.AddDays(-1) $entries = $entries | Where-Object { $_.DateTime.Date -eq $targetDate.Date } $dateDescription = $targetDate.ToString('yyyy-MM-dd') } elseif ($PSCmdlet.ParameterSetName -eq 'Range') { $entries = $entries | Where-Object { $_.DateTime.Date -ge $StartDate.Date -and $_.DateTime.Date -le $EndDate.Date } $dateDescription = "$($StartDate.ToString('yyyy-MM-dd')) to $($EndDate.ToString('yyyy-MM-dd'))" } if ($PSBoundParameters.ContainsKey('Hour')) { $entries = $entries | Where-Object { $_.Hour -eq $Hour } } $entries = @($entries | Sort-Object DateTime, Sequence, Path) if (-not $entries) { $diaryRootInfo = Get-DiaryRoot if ($dateDescription) { Write-Warning "No diary entries found for $dateDescription in $($diaryRootInfo.Path)." } else { Write-Warning "No diary entries found in $($diaryRootInfo.Path)." } return } $entries | ForEach-Object { $content = Get-Content -LiteralPath $_.Path -Raw -Encoding UTF8 if ($Raw) { $_ | Add-Member -NotePropertyName Content -NotePropertyValue $content -PassThru } else { $entryView = [PSCustomObject]@{ DateTime = $_.DateTime Content = Get-DiaryEntryPreview -Content $content } if ($Full) { $entryView.PSObject.TypeNames.Insert(0, 'PSDiaryAI.Entry.Full') } else { $entryView.PSObject.TypeNames.Insert(0, 'PSDiaryAI.Entry') } $entryView } } } function Get-DiaryDates { <# .SYNOPSIS Lists all diary entry dates or searches for specific dates. .DESCRIPTION Scans the year/month/day/hour structure and returns diary dates as PSCustomObjects. Can filter by a specific date or use -Today to get today's entries. .PARAMETER Date Filter to a specific date (YYYY-MM-DD format). .PARAMETER Today Get today's diary entry date. .OUTPUTS PSCustomObject with Date, FolderPath, and EntryCount properties. .EXAMPLE Get-DiaryDates # Returns all diary dates as objects. .EXAMPLE Get-DiaryDates -Today # Returns today's date if it exists. .EXAMPLE Get-DiaryDates -Date '2025-12-10' # Returns the specified date if it exists. #> [CmdletBinding()] param( [DateTime]$Date, [switch]$Today ) $entries = Get-DiaryEntryFiles if (-not $entries) { return @() } $allDates = $entries | Group-Object Date | ForEach-Object { $parsedDate = [DateTime]::ParseExact($_.Name, 'yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture) $folderPath = Join-Path $script:DiaryRoot $parsedDate.ToString('yyyy') $folderPath = Join-Path $folderPath $parsedDate.ToString('MM') $folderPath = Join-Path $folderPath $parsedDate.ToString('dd') [PSCustomObject]@{ Date = $_.Name FolderPath = $folderPath EntryCount = $_.Count } } if ($Today) { $targetDate = Get-Date $result = $allDates | Where-Object { [DateTime]::Parse($_.Date).Date -eq $targetDate.Date } } elseif ($Date) { $result = $allDates | Where-Object { [DateTime]::Parse($_.Date).Date -eq $Date.Date } } else { $result = $allDates | Sort-Object Date -Descending } return $result } function Get-DiaryNotes { <# .SYNOPSIS Retrieves Markdown entry contents for diary dates. .DESCRIPTION Takes diary date objects from Get-DiaryDates and outputs the contents of their timestamped Markdown entry files in chronological order. .PARAMETER DiaryDate A PSCustomObject with Date and FolderPath properties. .EXAMPLE Get-DiaryDates | Get-DiaryNotes #> [CmdletBinding()] param( [Parameter(ValueFromPipeline)] [PSCustomObject]$DiaryDate ) process { $date = [DateTime]::Parse($DiaryDate.Date) $entries = Get-DiaryEntries -Date $date -Raw if ($entries) { ($entries | ForEach-Object { "### [$($_.Time)] $($_.Name)`n`n$($_.Content.Trim())" }) -join "`n`n" } else { Write-Warning "No Markdown entries found in $($DiaryDate.FolderPath)" } } } function Invoke-DiaryDigest { <# .SYNOPSIS Sends diary entries to AI for a connected digest. .DESCRIPTION Accepts objects from Get-DiaryEntries through the pipeline, converts them to compact JSON, and sends them to icc with a digest prompt. Pipe Get-DiaryEntries output to this command; use -Raw on Get-DiaryEntries when you need file metadata elsewhere, not for digesting. .PARAMETER InputObject Diary entry objects from Get-DiaryEntries. .PARAMETER Prompt The instruction sent to the AI model after the diary JSON context. .PARAMETER Model The model passed to icc. Defaults to openai:gpt-5.2. .EXAMPLE gde -StartDate 2/1 -EndDate 5/10 -Full | idd .EXAMPLE gde 12/17/25 -Full | idd 'categorize these and pull out next actions' #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(ValueFromPipeline)] [object]$InputObject, [Parameter(Position = 0)] [string]$Prompt = 'These are timestamped diary notes as JSON. Make them nice: organize them into categories, connect related ideas across time, identify recurring themes, extract action items, and highlight high-signal opportunities. Be direct, concise, and preserve useful specifics such as project names, links, repo paths, and dates.', [string]$Model = 'openai:gpt-5.2' ) begin { $entries = [System.Collections.Generic.List[object]]::new() $activity = 'Invoke-DiaryDigest' Write-Host "Diary digest: collecting entries..." -ForegroundColor Cyan Write-Progress -Activity $activity -Status 'Collecting diary entries' } process { if ($null -eq $InputObject) { return } $dateTimeText = $null if ($InputObject.PSObject.Properties['DateTime']) { try { $dateTimeText = ([DateTime]$InputObject.DateTime).ToString('yyyy-MM-dd HH:mm:ss') } catch { $dateTimeText = [string]$InputObject.DateTime } } if ($InputObject.PSObject.Properties['Content']) { $content = [string]$InputObject.Content } else { $content = [string]$InputObject } if ($content -match '\A---\r?\n') { $content = Get-DiaryEntryPreview -Content $content } $entries.Add([PSCustomObject]@{ DateTime = $dateTimeText Content = $content }) if ($entries.Count -eq 1 -or $entries.Count % 25 -eq 0) { Write-Progress -Activity $activity -Status "Collected $($entries.Count) diary entries" } } end { if ($entries.Count -eq 0) { Write-Progress -Activity $activity -Completed Write-Warning "No diary entries were provided. Pipe Get-DiaryEntries output to Invoke-DiaryDigest." return } Write-Host "Diary digest: collected $($entries.Count) entries." -ForegroundColor Cyan if ($PSCmdlet.ShouldProcess("$($entries.Count) diary entries", "Invoke diary digest with $Model")) { Write-Host "Diary digest: preparing compact JSON..." -ForegroundColor Cyan Write-Progress -Activity $activity -Status 'Preparing compact JSON' -PercentComplete 35 $iccCommand = Get-Command icc -ErrorAction SilentlyContinue if (-not $iccCommand) { Write-Progress -Activity $activity -Completed throw "The 'icc' command was not found. Import PSAISuite before running Invoke-DiaryDigest." } $dumpJsonCommand = Get-Command dumpJson -ErrorAction SilentlyContinue if ($dumpJsonCommand) { $json = $entries | dumpJson -Compress } else { $json = $entries | ConvertTo-Json -Compress -Depth 6 } Write-Host "Diary digest: sending $($entries.Count) entries to $Model..." -ForegroundColor Cyan Write-Progress -Activity $activity -Status "Sending entries to $Model" -PercentComplete 70 $result = $json | icc -Model $Model $Prompt Write-Progress -Activity $activity -Completed Write-Host "Diary digest: complete." -ForegroundColor Green $result } else { Write-Progress -Activity $activity -Completed } } } function Invoke-DiaryAnalysis { <# .SYNOPSIS Analyzes diary entries using AI. .DESCRIPTION Reads timestamped Markdown diary entries for a single date, date range, latest diary day, or pipeline input from Get-DiaryEntries. Entries are sent to Invoke-ChatCompletion with a custom prompt. .PARAMETER InputObject Diary entry objects from Get-DiaryEntries. .PARAMETER Prompt The instruction sent to the AI model after the diary JSON context. .PARAMETER DiaryDate The date of the diary entry to analyze (YYYY-MM-DD format). If omitted, analyzes the most recent entry. .PARAMETER StartDate The start date of a range to analyze. If -EndDate is omitted, today is used. .PARAMETER EndDate The end date of a range to analyze. Use with -StartDate. .PARAMETER Model The model passed to Invoke-ChatCompletion. Defaults to github:gpt-4.1. .EXAMPLE Invoke-DiaryAnalysis # Analyzes the latest diary entry. .EXAMPLE Invoke-DiaryAnalysis -DiaryDate '2025-12-03' # Analyzes the diary entry for December 3, 2025. .EXAMPLE Invoke-DiaryAnalysis -StartDate '2025-12-01' -EndDate '2025-12-06' # Analyzes timestamped diary entries from December 1-6, 2025. .EXAMPLE gde 5/1 | Invoke-DiaryAnalysis 'create a fact table i can create an xlsx from so i can do pivot analysis' #> [CmdletBinding(DefaultParameterSetName = 'Single')] param( [Parameter(ValueFromPipeline, ParameterSetName = 'Pipeline')] [object]$InputObject, [Parameter(Position = 0, ParameterSetName = 'Pipeline')] [Parameter(ParameterSetName = 'Single')] [Parameter(ParameterSetName = 'Range')] [string]$Prompt = 'Analyze these timestamped diary notes as JSON. Be direct, concise, and preserve useful specifics such as project names, links, repo paths, and dates.', [Parameter(Position = 0, ParameterSetName = 'Single')] [DateTime]$DiaryDate, [Parameter(Mandatory, ParameterSetName = 'Range')] [DateTime]$StartDate, [Parameter(ParameterSetName = 'Range')] [DateTime]$EndDate, [string]$Model = 'github:gpt-4.1' ) begin { if ($PSCmdlet.ParameterSetName -eq 'Pipeline') { $entries = [System.Collections.Generic.List[object]]::new() $activity = 'Invoke-DiaryAnalysis' Write-Host "Diary analysis: collecting entries..." -ForegroundColor Cyan Write-Progress -Activity $activity -Status 'Collecting diary entries' } } process { if ($PSCmdlet.ParameterSetName -ne 'Pipeline') { return } if ($null -eq $InputObject) { return } $dateTimeText = $null if ($InputObject.PSObject.Properties['DateTime']) { try { $dateTimeText = ([DateTime]$InputObject.DateTime).ToString('yyyy-MM-dd HH:mm:ss') } catch { $dateTimeText = [string]$InputObject.DateTime } } if ($InputObject.PSObject.Properties['Content']) { $content = [string]$InputObject.Content } else { $content = [string]$InputObject } if ($content -match '\A---\r?\n') { $content = Get-DiaryEntryPreview -Content $content } $entries.Add([PSCustomObject]@{ DateTime = $dateTimeText Content = $content }) if ($entries.Count -eq 1 -or $entries.Count % 25 -eq 0) { Write-Progress -Activity $activity -Status "Collected $($entries.Count) diary entries" } } end { if ($PSCmdlet.ParameterSetName -eq 'Pipeline') { if ($entries.Count -eq 0) { Write-Progress -Activity $activity -Completed Write-Warning "No diary entries were provided. Pipe Get-DiaryEntries output to Invoke-DiaryAnalysis." return } Invoke-DiaryEntrySetAnalysis -Entries $entries.ToArray() -Prompt $Prompt -Model $Model -Activity $activity return } if (-not (Get-Command Invoke-ChatCompletion -ErrorAction SilentlyContinue)) { throw "Invoke-ChatCompletion was not found. Import your AI chat module before running Invoke-DiaryAnalysis." } # Handle date range from timestamped Markdown entries. if ($PSCmdlet.ParameterSetName -eq 'Range') { if (-not $PSBoundParameters.ContainsKey('EndDate')) { $EndDate = (Get-Date).Date } if ($EndDate -lt $StartDate) { throw "EndDate must be greater than or equal to StartDate." } $rangeEntries = @(Get-DiaryEntries -StartDate $StartDate -EndDate $EndDate -Full) if (-not $rangeEntries) { Write-Warning "No diary entries found from $($StartDate.ToString('yyyy-MM-dd')) to $($EndDate.ToString('yyyy-MM-dd'))." return } Invoke-DiaryEntrySetAnalysis -Entries $rangeEntries -Prompt $Prompt -Model $Model return } # Single date mode analyzes the requested day, or the latest timestamped # diary day when no date is supplied. if ($DiaryDate) { $singleEntries = @(Get-DiaryEntries -StartDate $DiaryDate.Date -EndDate $DiaryDate.Date -Full) } else { $latestDate = Get-DiaryDates | Sort-Object Date -Descending | Select-Object -First 1 if (-not $latestDate) { Write-Warning "No diary entries found." return } $latestDateValue = [DateTime]::ParseExact($latestDate.Date, 'yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture) $singleEntries = @(Get-DiaryEntries -StartDate $latestDateValue -EndDate $latestDateValue -Full) } if (-not $singleEntries) { Write-Warning "No diary entries found for $($DiaryDate.ToString('yyyy-MM-dd'))." return } Invoke-DiaryEntrySetAnalysis -Entries $singleEntries -Prompt $Prompt -Model $Model } } function Get-DiaryEntryLinks { <# .SYNOPSIS Extracts distinct HTTP/HTTPS links from diary entry text. #> [CmdletBinding()] param([AllowNull()][string]$Content) if ([string]::IsNullOrWhiteSpace($Content)) { return @() } $links = [System.Collections.Generic.List[string]]::new() $matches = [regex]::Matches($Content, 'https?://[^\s<>"''`]+', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) foreach ($match in $matches) { $url = $match.Value.TrimEnd('.', ',', ';', ':', '!', '?', ')', ']', '}') if ($url -and -not $links.Contains($url)) { $links.Add($url) } } return @($links.ToArray()) } function Invoke-DiaryOpenAIWebResponse { <# .SYNOPSIS Invokes OpenAI Responses API with optional hosted web search. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ModelName, [Parameter(Mandatory)] [hashtable[]]$Messages, [switch]$RequireWebSearch ) $openAIKey = $env:OPENAI_API_KEY if ([string]::IsNullOrWhiteSpace($openAIKey)) { $openAIKey = $env:OpenAIKey } if ([string]::IsNullOrWhiteSpace($openAIKey)) { throw "OPENAI_API_KEY environment variable is not set. Set `$env:OPENAI_API_KEY before using the default web-capable diary AI model." } $headers = @{ Authorization = "Bearer $openAIKey" 'Content-Type' = 'application/json' } $body = @{ model = $ModelName input = $Messages } if ($RequireWebSearch) { $body.tools = @( @{ type = 'web_search' } ) $body.tool_choice = 'required' $body.include = @('web_search_call.action.sources') } try { $response = Invoke-RestMethod -Uri 'https://api.openai.com/v1/responses' -Method Post -Headers $headers -Body ($body | ConvertTo-Json -Depth 20) } catch { $statusCode = $null if ($_.Exception.Response -and $_.Exception.Response.StatusCode) { $statusCode = $_.Exception.Response.StatusCode.value__ } $details = $_.ErrorDetails.Message if ([string]::IsNullOrWhiteSpace($details)) { $details = $_.Exception.Message } if ($statusCode) { throw "OpenAI Responses API error (HTTP $statusCode): $details" } throw "OpenAI Responses API error: $details" } if ($response.error) { throw "OpenAI Responses API error: $($response.error.message)" } $textParts = [System.Collections.Generic.List[string]]::new() $citations = [System.Collections.Generic.List[object]]::new() if ($response.output_text) { $textParts.Add([string]$response.output_text) } foreach ($item in @($response.output)) { if ($item.type -ne 'message') { continue } foreach ($content in @($item.content)) { if ($content.type -eq 'output_text' -and $content.text) { $textParts.Add([string]$content.text) } foreach ($annotation in @($content.annotations)) { if ($annotation.url -or $annotation.title) { $citations.Add([PSCustomObject]@{ Title = [string]$annotation.title Url = [string]$annotation.url }) } } } } $text = (($textParts.ToArray() | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join "`n").Trim() if ([string]::IsNullOrWhiteSpace($text)) { throw "No text content in OpenAI Responses API response." } return [PSCustomObject]@{ Text = $text Citations = @($citations.ToArray()) Raw = $response } } function Invoke-DiaryEntryAI { <# .SYNOPSIS Creates an AI companion note for a timestamped diary entry. .DESCRIPTION Reads one saved diary Markdown entry plus the other entries from the same day, asks an AI model for a compact analysis, and writes a sibling .ai.md file. The original diary entry is never modified. .PARAMETER Path Path to a timestamped diary Markdown entry. .PARAMETER Model Model passed to Invoke-ChatCompletion. .PARAMETER PassThru Return the companion Markdown content after writing it. #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [string]$Path, [string]$Model = 'openai:gpt-5.5', [switch]$PassThru ) $resolvedPath = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path $aiPath = [System.IO.Path]::ChangeExtension($resolvedPath, '.ai.md') $errorPath = [System.IO.Path]::ChangeExtension($resolvedPath, '.ai.error.txt') try { if ($Model -ne 'openai:gpt-5.5' -and -not (Get-Command Invoke-ChatCompletion -ErrorAction SilentlyContinue)) { throw "Invoke-ChatCompletion was not found. Import your AI chat module before running Invoke-DiaryEntryAI." } $entryFile = Get-Item -LiteralPath $resolvedPath if ($entryFile.Name -notmatch '^(?<stamp>\d{14})(?:-\d{3})?\.md$') { throw "Path '$resolvedPath' is not a timestamped diary entry Markdown file." } $entryDateTime = [DateTime]::ParseExact( $Matches.stamp, 'yyyyMMddHHmmss', [Globalization.CultureInfo]::InvariantCulture ) $entryContent = Get-Content -LiteralPath $resolvedPath -Raw -Encoding UTF8 $entryPreview = Get-DiaryEntryPreview -Content $entryContent $entryLinks = @(Get-DiaryEntryLinks -Content $entryPreview) $hasEntryLinks = $entryLinks.Count -gt 0 $dayEntries = @(Get-DiaryEntries -StartDate $entryDateTime.Date -EndDate $entryDateTime.Date -Full) $dayJson = $dayEntries | Select-Object @{ Name = 'DateTime'; Expression = { ([DateTime]$_.DateTime).ToString('yyyy-MM-dd HH:mm:ss') } }, Content | ConvertTo-Json -Depth 6 -Compress $linkJson = $entryLinks | ConvertTo-Json -Compress if (-not $linkJson) { $linkJson = '[]' } $reviewedLinksInstruction = if ($hasEntryLinks) { @" HTTP/HTTPS links in the new entry as JSON: $linkJson Because links are present, use web search/opening to review the linked pages. In ## Reviewed Links, cite or name the links you used, summarize what each link added, and clearly distinguish what came from the diary note versus what came from web review. "@ } else { '' } $messages = @( @{ role = 'system' content = 'You are a terse diary companion. Help turn raw brain drops into useful memory, action, and connections. Do not flatter. Preserve concrete names, paths, links, and dates.' }, @{ role = 'user' content = @" New diary entry: DateTime: $($entryDateTime.ToString('yyyy-MM-dd HH:mm:ss')) Path: $resolvedPath Content: $entryPreview $reviewedLinksInstruction All diary entries from the same day as compact JSON: $dayJson Write compact Markdown with exactly these sections: ## Quick Read One or two sentences about what this entry is really saying. ## Tags Short tags as comma-separated values. ## Possible Actions Bullets for concrete next steps, only if implied by the note. ## Connections To Today Bullets connecting this note to other same-day entries. Say "No strong same-day connections yet." if none are evident. $(if ($hasEntryLinks) { '## Reviewed Links Bullets for each reviewed link. Include the URL or page title, what the web review added, and whether the insight came from the diary note or the linked page. ' }) ## Follow-Up Prompts Three useful prompts Doug could ask next. "@ } ) if ($Model -eq 'openai:gpt-5.5') { $openAIResponse = Invoke-DiaryOpenAIWebResponse -ModelName 'gpt-5.5' -Messages $messages -RequireWebSearch:$hasEntryLinks $aiContent = [string]$openAIResponse.Text -replace '\\n', "`n" -replace '\\r', "`r" if ($openAIResponse.Citations.Count -gt 0) { $citationLines = $openAIResponse.Citations | Where-Object { $_.Url -or $_.Title } | Select-Object -Unique Title, Url | ForEach-Object { if ($_.Title -and $_.Url) { "- $($_.Title): $($_.Url)" } elseif ($_.Url) { "- $($_.Url)" } else { "- $($_.Title)" } } if ($citationLines) { $aiContent = $aiContent.Trim() + "`n`n## Web Citations`n" + ($citationLines -join "`n") } } } else { $response = Invoke-ChatCompletion -Model $Model -Messages $messages $aiContent = [string]$response -replace '\\n', "`n" -replace '\\r', "`r" } $companion = @( '# Diary AI Companion' '' "**Entry:** $resolvedPath" "**Created:** $($entryDateTime.ToString('yyyy-MM-dd HH:mm:ss'))" "**Model:** $Model" '' $aiContent.Trim() '' ) -join "`n" Write-DiaryUtf8NoBom -Path $aiPath -Content ($companion + "`n") if (Test-Path -LiteralPath $errorPath) { Remove-Item -LiteralPath $errorPath -Force } $result = [PSCustomObject]@{ EntryPath = $resolvedPath CompanionPath = $aiPath Model = $Model } if ($PassThru) { $result | Add-Member -NotePropertyName Content -NotePropertyValue $companion -PassThru } else { $result } } catch { $errorText = @( "Diary AI companion failed." "Entry: $resolvedPath" "Model: $Model" "Time: $((Get-Date).ToString('o'))" '' [string]$_ ) -join "`n" Write-DiaryUtf8NoBom -Path $errorPath -Content ($errorText + "`n") throw } } function Search-DiaryEntries { <# .SYNOPSIS Searches for a term across all diary notes. .DESCRIPTION Uses Get-DiaryEntries to search timestamped Markdown files. .PARAMETER SearchTerm The term to search for in diary notes. .PARAMETER Today Filter to today's entries. .PARAMETER Yesterday Filter to yesterday's entries. .PARAMETER Yesterday Filter to yesterday's entries. .PARAMETER StartDate The start date for a range query. If -EndDate is omitted, today is used. Use -Date as a compatibility alias. .PARAMETER EndDate The end date for a range query. Use with -StartDate. To get one specific day, pass the same date for -StartDate and -EndDate. .PARAMETER Only Search only the start date instead of the range from start date to today. .PARAMETER Hour Filter to one hour of day, from 0 to 23. .EXAMPLE Search-DiaryEntries -SearchTerm "PowerShell" # Searches all timestamped Markdown diary entries for the term "PowerShell". .EXAMPLE sde powershell .EXAMPLE sde powershell -Today .EXAMPLE sde powershell -Yesterday .EXAMPLE sde powershell 5/1 .EXAMPLE sde powershell 5/1 -Only .EXAMPLE Search-DiaryEntries -SearchTerm "agent" -StartDate '2026-04-01' -EndDate '2026-04-30' -Hour 13 #> [CmdletBinding(DefaultParameterSetName = 'All')] param( [Parameter(Mandatory, Position = 0)] [string]$SearchTerm, [Parameter(ParameterSetName = 'Today')] [switch]$Today, [Parameter(ParameterSetName = 'Yesterday')] [switch]$Yesterday, [Parameter(Mandatory, Position = 1, ParameterSetName = 'Range')] [Alias('Date')] [DateTime]$StartDate, [Parameter(Position = 2, ParameterSetName = 'Range')] [DateTime]$EndDate, [Parameter(ParameterSetName = 'Range')] [switch]$Only, [ValidateRange(0, 23)] [int]$Hour ) $entryParams = @{} if ($PSCmdlet.ParameterSetName -eq 'Today') { $entryParams.Today = $true } elseif ($PSCmdlet.ParameterSetName -eq 'Yesterday') { $entryParams.Yesterday = $true } elseif ($PSCmdlet.ParameterSetName -eq 'Range') { if ($Only -and $PSBoundParameters.ContainsKey('EndDate')) { throw "Use either -Only or -EndDate, not both." } if ($Only) { $EndDate = $StartDate } elseif (-not $PSBoundParameters.ContainsKey('EndDate')) { $EndDate = (Get-Date).Date } $entryParams.StartDate = $StartDate $entryParams.EndDate = $EndDate } if ($PSBoundParameters.ContainsKey('Hour')) { $entryParams.Hour = $Hour } $entries = Get-DiaryEntries @entryParams -Raw if (-not $entries) { return } foreach ($entry in $entries) { Write-Verbose "Searching in: $($entry.Path)" Select-String -Path $entry.Path -Pattern $SearchTerm -SimpleMatch | ForEach-Object { [PSCustomObject]@{ Date = $entry.Date Time = $entry.Time Line = $_.Line FilePath = $entry.Path } } } } function Invoke-DiaryExecutive { <# .SYNOPSIS Turns diary entries into a ranked executive plan. .DESCRIPTION Reads diary entries for today, a date range, or entries since the last Diary Executive run, then asks an AI model to connect dots across the entries and produce a practical plan. The output is written as Markdown so it can be reviewed, edited, and used as a launch point for project folders. .PARAMETER Today Analyze today's diary entries. .PARAMETER Yesterday Analyze yesterday's diary entries. .PARAMETER StartDate The start date for a range query. If -EndDate is omitted, today is used. .PARAMETER EndDate The end date for a range query. .PARAMETER SinceLastRun Analyze entries newer than the timestamp stored in the state file. This is intended for scheduled automation. .PARAMETER OutputPath Folder where Diary Executive plan files and state are written. .PARAMETER StatePath JSON state file used by -SinceLastRun. Defaults under OutputPath. .PARAMETER Prompt Additional instruction to append to the Diary Executive prompt. .PARAMETER Model Model passed to Invoke-ChatCompletion. .PARAMETER PassThru Return the Markdown plan text after writing it to disk. .EXAMPLE Invoke-DiaryExecutive -StartDate 12/1/25 -EndDate 5/16/26 .EXAMPLE Invoke-DiaryExecutive -SinceLastRun -OutputPath .\DiaryExec .EXAMPLE Invoke-DiaryExecutive -Today -Prompt 'Focus on content ideas.' .EXAMPLE Invoke-DiaryExecutive -Yesterday -Prompt 'Focus on content ideas.' #> [CmdletBinding(DefaultParameterSetName = 'Range')] param( [Parameter(ParameterSetName = 'Today')] [switch]$Today, [Parameter(ParameterSetName = 'Yesterday')] [switch]$Yesterday, [Parameter(Position = 0, ParameterSetName = 'Range')] [DateTime]$StartDate, [Parameter(Position = 1, ParameterSetName = 'Range')] [DateTime]$EndDate, [Parameter(ParameterSetName = 'SinceLastRun')] [switch]$SinceLastRun, [string]$OutputPath = (Join-Path (Get-Location) 'DiaryExec'), [string]$StatePath, [string]$Prompt, [string]$Model = 'github:gpt-4.1', [ValidateRange(10, 200)] [int]$MaxEntriesPerChunk = 60, [ValidateRange(300, 4000)] [int]$MaxChunkSummaryChars = 900, [switch]$PassThru ) if (-not (Get-Command Invoke-ChatCompletion -ErrorAction SilentlyContinue)) { throw "Invoke-ChatCompletion was not found. Import your AI chat module before running Invoke-DiaryExecutive." } if (-not (Test-Path -LiteralPath $OutputPath)) { New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null } if (-not $StatePath) { $StatePath = Join-Path $OutputPath 'diary-executive-state.json' } $entryParams = @{} $rangeLabel = $null $lastRun = $null switch ($PSCmdlet.ParameterSetName) { 'Today' { $entryParams.Today = $true $rangeLabel = (Get-Date).ToString('yyyy-MM-dd') } 'Yesterday' { $targetDate = (Get-Date).Date.AddDays(-1) $entryParams.StartDate = $targetDate $entryParams.EndDate = $targetDate $rangeLabel = $targetDate.ToString('yyyy-MM-dd') } 'SinceLastRun' { if (Test-Path -LiteralPath $StatePath) { $state = Get-Content -LiteralPath $StatePath -Raw -Encoding UTF8 | ConvertFrom-Json if ($state.LastRun) { $lastRun = [DateTime]$state.LastRun } } if (-not $lastRun) { $lastRun = (Get-Date).Date } $entryParams.StartDate = $lastRun.Date $entryParams.EndDate = Get-Date $rangeLabel = "since $($lastRun.ToString('yyyy-MM-dd HH:mm:ss'))" } default { if (-not $PSBoundParameters.ContainsKey('StartDate')) { $StartDate = (Get-DiaryDates | Sort-Object Date | Select-Object -First 1 | ForEach-Object { [DateTime]::Parse($_.Date) }) } if (-not $StartDate) { Write-Warning "No diary entries found." return } if (-not $PSBoundParameters.ContainsKey('EndDate')) { $EndDate = (Get-Date).Date } if ($EndDate -lt $StartDate) { throw "EndDate must be greater than or equal to StartDate." } $entryParams.StartDate = $StartDate $entryParams.EndDate = $EndDate $rangeLabel = "$($StartDate.ToString('yyyy-MM-dd')) to $($EndDate.ToString('yyyy-MM-dd'))" } } $entries = @(Get-DiaryEntries @entryParams -Full) if ($lastRun) { $entries = @($entries | Where-Object { $_.DateTime -gt $lastRun }) } if (-not $entries) { Write-Warning "No diary entries found for $rangeLabel." if ($SinceLastRun) { [PSCustomObject]@{ LastRun = (Get-Date).ToString('o') } | ConvertTo-Json | Set-Content -LiteralPath $StatePath -Encoding UTF8 } return } $executivePrompt = @" You are Diary Executive, an analytical planning assistant for Doug Finke. You analyze timestamped diary entries and produce a practical, ranked plan. Be direct, specific, and useful. Preserve concrete dates, local paths, links, repo names, project names, and repeated phrases. Important current project status: - Already built, moved on, or discarded: PowerShell Agent Skills Runtime; CLI Over MCP Pattern Library; Agentic GitHub Operations. - Various degrees of completion: PowerShell Agent Workbench. - Cool but unclear how to proceed: AI-Native Excel / Data Analyst. - This thread is: Content Engine From Diary. Core thesis to preserve: The thing worth teaching is not "chat with AI from PowerShell"; it is "turn existing tools into agent native workflows." Produce Markdown with these sections: ## Executive Read A concise read of what the entries are really saying. ## Connected Dots Specific connections across entries. Include why each connection matters. ## Ranked Directions Ranked list of directions to consider next. For each: why now, likely output, first action. ## One-Hour Builds Small implementation ideas that could be started immediately. ## Deeper Research Research questions worth pursuing before building. ## Content Engine Video, post, workshop, or demo ideas implied by the entries. ## Project Folder Suggestions Folder names that could be created under DiaryExec, with one sentence purpose for each. ## Not Now Promising ideas that should be explicitly parked for now. Additional user instruction: $Prompt "@ function Invoke-DiaryExecutiveChat { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$UserContent ) $messages = @( @{ role = 'system' content = 'You are a terse, high-signal planning analyst. You connect dots across diary notes and turn them into concrete next actions.' }, @{ role = 'user' content = $UserContent } ) $chatResponse = Invoke-ChatCompletion -Model $Model -Messages $messages $chatText = [string]$chatResponse -replace '\\n', "`n" -replace '\\r', "`r" if ($chatText -match '(?i)tokens_limit_reached|payload too large|response status code does not indicate success|api error') { throw $chatText } return $chatText } function Compress-DiaryExecutiveText { [CmdletBinding()] param( [AllowNull()] [string]$Text, [int]$MaxLength = 900 ) if ($null -eq $Text) { return '' } $oneLine = (($Text -split '\r?\n' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) -join ' ') if ($oneLine.Length -le $MaxLength) { return $oneLine } return $oneLine.Substring(0, $MaxLength) + '...' } try { if ($entries.Count -gt $MaxEntriesPerChunk) { Write-Host "Diary Executive: chunking $($entries.Count) entries into groups of $MaxEntriesPerChunk..." -ForegroundColor Cyan $chunkPlans = [System.Collections.Generic.List[object]]::new() for ($i = 0; $i -lt $entries.Count; $i += $MaxEntriesPerChunk) { $chunkEntries = @($entries | Select-Object -Skip $i -First $MaxEntriesPerChunk) $chunkNumber = [int]([Math]::Floor($i / $MaxEntriesPerChunk) + 1) $chunkJson = $chunkEntries | Select-Object @{ Name = 'DateTime'; Expression = { ([DateTime]$_.DateTime).ToString('yyyy-MM-dd HH:mm:ss') } }, Content | ConvertTo-Json -Depth 6 -Compress $chunkContent = @" Diary range: $rangeLabel Chunk: $chunkNumber Chunk entry count: $($chunkEntries.Count) Diary entries as JSON: $chunkJson Analyze this chunk only. Produce compact Markdown with: ## Chunk Read What this chunk is really about. Maximum 80 words. ## Signals Concrete project/content/research signals with dates, paths, and links. Maximum 8 bullets. ## Candidate Actions Specific next actions implied by this chunk. Maximum 5 bullets. "@ Write-Host "Diary Executive: analyzing chunk $chunkNumber ($($chunkEntries.Count) entries)..." -ForegroundColor Cyan $chunkPlan = Invoke-DiaryExecutiveChat -UserContent $chunkContent $chunkPlans.Add([PSCustomObject]@{ Chunk = $chunkNumber EntryCount = $chunkEntries.Count Content = Compress-DiaryExecutiveText -Text $chunkPlan -MaxLength $MaxChunkSummaryChars }) } $summaryJson = $chunkPlans | ConvertTo-Json -Depth 6 -Compress $userContent = @" Diary range: $rangeLabel Entry count: $($entries.Count) These are chunk-level analyses of the diary entries as JSON: $summaryJson Synthesize the chunks into one final Diary Executive plan. $executivePrompt "@ } else { $diaryJson = $entries | Select-Object @{ Name = 'DateTime'; Expression = { ([DateTime]$_.DateTime).ToString('yyyy-MM-dd HH:mm:ss') } }, Content | ConvertTo-Json -Depth 6 -Compress $userContent = @" Diary range: $rangeLabel Entry count: $($entries.Count) Diary entries as JSON: $diaryJson $executivePrompt "@ } Write-Host "Diary Executive: synthesizing $($entries.Count) entries for $rangeLabel with $Model..." -ForegroundColor Cyan $plan = Invoke-DiaryExecutiveChat -UserContent $userContent } catch { throw "Diary Executive failed before writing plan/state: $_" } $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $planPath = Join-Path $OutputPath "diary-executive-$stamp.md" $header = "# Diary Executive Plan`n`n" $header += "**Range:** $rangeLabel`n" $header += "**Entries:** $($entries.Count)`n" $header += "**Model:** $Model`n" $header += "**Created:** $((Get-Date).ToString('yyyy-MM-dd HH:mm:ss'))`n`n" Set-Content -LiteralPath $planPath -Value ($header + $plan) -Encoding UTF8 [PSCustomObject]@{ LastRun = (Get-Date).ToString('o') LastPlanPath = $planPath EntryCount = $entries.Count Range = $rangeLabel } | ConvertTo-Json | Set-Content -LiteralPath $StatePath -Encoding UTF8 Write-Host "Diary Executive: wrote $planPath" -ForegroundColor Green $result = [PSCustomObject]@{ PlanPath = $planPath StatePath = $StatePath EntryCount = $entries.Count Range = $rangeLabel } if ($PassThru) { $result | Add-Member -NotePropertyName Plan -NotePropertyValue $plan -PassThru } else { $result } } function Invoke-DiaryEntrySetAnalysis { [CmdletBinding()] param( [Parameter(Mandatory)] [object[]]$Entries, [Parameter(Mandatory)] [string]$Prompt, [Parameter(Mandatory)] [string]$Model, [string]$Activity = 'Invoke-DiaryAnalysis' ) $analysisEntries = foreach ($entry in $Entries) { $dateTimeText = $null if ($entry.PSObject.Properties['DateTime']) { try { $dateTimeText = ([DateTime]$entry.DateTime).ToString('yyyy-MM-dd HH:mm:ss') } catch { $dateTimeText = [string]$entry.DateTime } } if ($entry.PSObject.Properties['Content']) { $content = [string]$entry.Content } else { $content = [string]$entry } if ($content -match '\A---\r?\n') { $content = Get-DiaryEntryPreview -Content $content } [PSCustomObject]@{ DateTime = $dateTimeText Content = $content } } $analysisEntries = @($analysisEntries) if ($analysisEntries.Count -eq 0) { Write-Warning "No diary entries were provided. Pipe Get-DiaryEntries output to Invoke-DiaryAnalysis." return } $dumpJsonCommand = Get-Command dumpJson -ErrorAction SilentlyContinue if ($dumpJsonCommand) { $json = $analysisEntries | dumpJson -Compress } else { $json = $analysisEntries | ConvertTo-Json -Compress -Depth 6 } $messages = @( @{ role = 'system' content = 'You are a diary analysis assistant. Analyze timestamped diary notes and transform them into the structure the user asks for. Be direct, terse, and preserve concrete dates, paths, links, project names, and entities.' }, @{ role = 'user' content = @" Diary entries as JSON: $json User request: $Prompt "@ } ) Write-Host "Diary analysis: sending $($analysisEntries.Count) entries to $Model..." -ForegroundColor Cyan Write-Progress -Activity $Activity -Status "Sending entries to $Model" -PercentComplete 70 try { $response = Invoke-ChatCompletion -Model $Model -Messages $messages } catch { Write-Progress -Activity $Activity -Completed throw "Failed to invoke AI chat completion: $_" } Write-Progress -Activity $Activity -Completed Write-Host "Diary analysis: complete." -ForegroundColor Green $response } #endregion #region Module Exports New-Alias -Name 'd' -Value 'Write-DiaryNote' -Force New-Alias -Name 'dp' -Value 'Show-DiaryPrefixes' -Force New-Alias -Name 'gde' -Value 'Get-DiaryEntries' -Force New-Alias -Name 'sde' -Value 'Search-DiaryEntries' -Force New-Alias -Name 'idd' -Value 'Invoke-DiaryDigest' -Force New-Alias -Name 'idx' -Value 'Invoke-DiaryExecutive' -Force Export-ModuleMember -Function Get-DiaryRoot, Set-DiaryRoot, Write-DiaryNote, Show-DiaryPrefixes, Get-DiaryEntries, Get-DiaryDates, Get-DiaryNotes, Invoke-DiaryDigest, Invoke-DiaryAnalysis, Invoke-DiaryEntryAI, Search-DiaryEntries, Invoke-DiaryExecutive -Alias d, dp, gde, sde, idd, idx #endregion |