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