diff --git a/INSTALL.md b/INSTALL.md index eb0b9b9..1b2c79c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -265,6 +265,41 @@ `Copy-Item` перезапишут персону, настройки и скиллы. Ключ и память лежат в других файлах и не трогаются. +### Обновление само, по ночам + +С версии 1.5.0 установщик заводит задачу Планировщика **`AILabAgentUpdate`**: раз в сутки, +около 03:00 (плюс случайный разброс до 45 минут, чтобы машины не били в GitHub разом), +она проверяет, не вышла ли новая сборка, и если вышла — ставит её. + +| Шаг | Действие | +|---|---| +| 1 | читает установленную версию из `.ailab-install.json` | +| 2 | спрашивает у GitHub последний релиз | +| 3 | сравнивает **по числам** (1.4.10 новее, чем 1.4.9 — не строкой) | +| 4 | если новее — скачивает архив и запускает установку | + +**Обновление не ставится, пока открыто окно агента.** Установщик подменяет файлы движка +и приложения, а занятые файлы перезаписать нельзя. Проверка просто отложится до следующей +ночи — окно никто не закроет за вас. Держать приложение открытым сутками можно, но тогда +обновление может ждать долго. + +**Молча ставится только обновление.** Если ключ ещё не вписан, скрипт ничего не делает: +первая установка требует Блокнота, а это не ночная операция. + +**Журнал:** `%LOCALAPPDATA%\hermes\logs\update-check.log` — одна строка на запуск: +`актуально`, `отложено`, `обновление установлено` либо причина отказа. + +**Отключить:** + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "$env:LOCALAPPDATA\hermes\update-check.ps1" -Off +``` + +Задача снимется, агент останется работать. Вернуть — запустить установку заново. + +**Проверить, не дожидаясь ночи:** запустите тот же файл без ключей — он сделает одну +проверку и, если есть что ставить, поставит. + Посмотреть, что стоит: ```powershell @@ -300,7 +335,7 @@ ### Номер сборки в заголовке окна -В заголовке окна, слева, виден чип **`AILab v1.4.0`** — какая сборка агента стоит. +В заголовке окна, слева, виден чип **`AILab v1.5.0`** — какая сборка агента стоит. Наведение показывает дату установки и имя исходного файла. Чип живёт плагином (`desktop-plugins/version-badge`) и берёт версию из файла @@ -310,6 +345,18 @@ Если чипа нет — плагины отключены в настройках приложения (`Настройки → Плагины`). +### Время текущей сессии в композере + +В строке управления композером, **слева от чипа расхода Polza.ai**, виден счётчик времени +текущей сессии: `0:07` → `1:05:32`. Наведение показывает название сессии, когда она начата +и сколько идёт. + +Счётчик живёт плагином (`desktop-plugins/session-time`) и берёт **настоящее время старта +сессии**, а не момент открытия окна: при перезапуске приложения отсчёт не начинается +заново. Пока сессии нет (вкладка «Новая сессия») — чипа нет вовсе, а не «0:00». + +Отключить — `Настройки → Плагины`, плагин `session-time`. + ### Боковая навигация Строка **«Сообщения»** из боковой панели убрана: мессенджеры не подключены, а вкладка ведёт diff --git a/README.md b/README.md index ef61281..7784213 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ | `config.yaml` | модель, провайдер (Polza.ai), инструменты, язык — **без ключей** | | `skills/` | 58 штатных скиллов Hermes (документы, таблицы, PDF, поиск, почта, заметки, код) | | `INSTALL.md` | пошаговая инструкция для получателя | +| `update-check.ps1` | ночная проверка обновлений; ставится задачей Планировщика `AILabAgentUpdate` | +| `desktop-plugins/` | четыре плагина окна: `version-badge`, `hide-messaging`, `session-time`, `polza-limit` | | `.env.template` | шаблон `.env`, устанавливается как `.env.EXAMPLE` | Ключ API живёт только в `.env` получателя: в `config.yaml` стоит @@ -81,6 +83,8 @@ | Веб-поиск | через MCP-серверы, подключаются отдельно | | Голос | **включён**: распознавание речи (локальный Whisper, без ключей) и синтез русским голосом (`edge`) | | Номер сборки | виден в заголовке окна чипом `AILab v<версия>` (плагин `version-badge`) | +| Время сессии | счётчик в композере слева от чипа Polza.ai (плагин `session-time`) | +| Обновления | проверяются сами раз в сутки ночью, ставятся без человека (задача `AILabAgentUpdate`) | | Навигация | строка «Сообщения» убрана из боковой панели (плагин `hide-messaging`) | | Настройки | экспорт, импорт и сброс — рабочие; после «Сброса» запустите установку заново | | Управление рабочим столом | **выключено** (`computer_use`) | @@ -127,10 +131,17 @@ ## Обновить агента у получателя -Поднять `version` в `distribution.yaml`, закоммитить, поставить тег, пересобрать пакет -`build-package.ps1` и передать новый архив. Получателю достаточно положить новый -`ailab-<версия>.bundle` рядом со `setup.cmd` и запустить его снова: маркер в доме -подскажет, что это обновление, а `.env` с ключом, память и переписка не трогаются. +**С v1.5.0 обновляется само.** Установщик заводит задачу Планировщика `AILabAgentUpdate`: +раз в сутки ночью она сравнивает установленную версию с последним релизом на GitHub и, +если вышла новая, ставит её. Условие одно — окно агента в этот момент должно быть закрыто, +иначе проверка отложится до следующей ночи. Журнал: `%LOCALAPPDATA%\hermes\logs\update-check.log`. +Отключается `update-check.ps1 -Off`. + +**Вручную** (когда нужно сразу): поднять `version` в `distribution.yaml`, закоммитить, +поставить тег, пересобрать пакет `build-package.ps1` и передать новый архив. Получателю +достаточно положить новый `ailab-<версия>.bundle` рядом со `setup.cmd` и запустить его +снова: маркер в доме подскажет, что это обновление, а `.env` с ключом, память и переписка +не трогаются. ## Проверка перед передачей diff --git a/desktop-plugins/session-time/plugin.js b/desktop-plugins/session-time/plugin.js new file mode 100644 index 0000000..62c26f0 --- /dev/null +++ b/desktop-plugins/session-time/plugin.js @@ -0,0 +1,146 @@ +/** + * session-time — счётчик времени текущей сессии в строке управления композера, + * слева от чипа Polza.ai (composer.actions рендерится перед пилюлей модели, + * а `order` меньше — значит левее). + * + * Время берётся из истинного старта сессии, а не из «прикрепления окна»: + * RPC `session.list` отдаёт строку с `started_at` (epoch-СЕКУНДЫ), плагин + * находит в ней сфокусированную сессию и считает разницу. Раз в минуту список + * перезапрашивается, раз в секунду пересчитывается только цифра. + * + * Почему не `$sessionStartedAt`/`runtimeStartedAt`: эти атомы плагинам НЕ + * отданы — `host.state` их не содержит (проверено по sdk/index.ts). Доступны + * `focusedStoredSessionId`, `profile`, `focusedUsage` и прочие, но не время. + * + * Композер виден в ОБОИХ режимах интерфейса: SIMPLE_POLICY прячет статусбар, + * но не строку контролов. Поэтому чип работает и в простом режиме — в отличие + * от элемента `session-timer` в статусбаре. + */ + +import { COMPOSER_AREAS, cn, host, Tip, useQuery, useValue } from '@hermes/plugin-sdk' +import { useEffect, useState } from 'react' +import { jsx, jsxs } from 'react/jsx-runtime' + +const ID = 'session-time' + +const QUERY_KEY = [ID, 'sessions'] +const REFRESH_MS = 60_000 +const LIST_LIMIT = 50 + +/** `started_at` приходит в epoch-СЕКУНДАХ; миллисекундные значения не трогаем. */ +function toMs(value) { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return null + } + + return value < 1e12 ? value * 1000 : value +} + +const pad = n => String(n).padStart(2, '0') + +/** 1:05:32, а до часа — 5:32: короткая форма для узкого места в композере. */ +function formatElapsed(ms) { + const total = Math.max(0, Math.floor(ms / 1000)) + const h = Math.floor(total / 3600) + const m = Math.floor((total % 3600) / 60) + const s = total % 60 + + return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}` +} + +function formatClock(ms) { + const d = new Date(ms) + + return `${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +function formatDate(ms) { + const d = new Date(ms) + + return `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${formatClock(ms)}` +} + +function findRow(rows, storedId) { + if (!storedId) { + return null + } + + return ( + rows.find(row => row && (row.id === storedId || row.resolved_id === storedId)) ?? null + ) +} + +function SessionTimeChip() { + const storedId = useValue(host.state.focusedStoredSessionId) + const profile = useValue(host.state.profile) + + const { data } = useQuery({ + queryFn: async () => host.request('session.list', { limit: LIST_LIMIT, profile }), + queryKey: [...QUERY_KEY, profile], + refetchInterval: REFRESH_MS, + staleTime: REFRESH_MS + }) + + const rows = (data && data.sessions) || [] + const row = findRow(rows, storedId) + const startedAt = toMs(row && row.started_at) + + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000) + + return () => clearInterval(timer) + }, []) + + // Свежий черновик (сессии ещё нет) — чипа нет вовсе, а не «0:00». + if (!startedAt) { + return null + } + + const elapsed = now - startedAt + + return jsx(Tip, { + label: jsxs('div', { + style: { display: 'flex', flexDirection: 'column', gap: '2px', fontSize: '11px', width: '190px' }, + children: [ + jsx('span', { children: row?.title || 'Текущая сессия' }), + jsx('span', { + style: { color: 'var(--ui-text-tertiary)' }, + children: `начата ${formatDate(startedAt)}` + }), + jsx('span', { + style: { color: 'var(--ui-text-tertiary)' }, + children: `идёт ${formatElapsed(elapsed)}` + }) + ] + }), + placement: 'control', + children: jsx('span', { + className: cn( + 'flex h-(--composer-control-size) items-center gap-1 rounded-md px-1.5 text-xs', + 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover)' + ), + 'data-slot': 'session-time-chip', + style: { fontVariantNumeric: 'tabular-nums' }, + children: [ + jsx('i', { 'aria-hidden': 'true', className: 'codicon codicon-clock' }), + jsx('span', { children: formatElapsed(elapsed) }) + ] + }) + }) +} + +export default { + id: ID, + name: 'Session time', + defaultEnabled: true, + register(ctx) { + ctx.register({ + id: 'chip', + area: COMPOSER_AREAS.actions, + order: 5, + render: () => jsx(SessionTimeChip, {}) + }) + } +} diff --git a/distribution.yaml b/distribution.yaml index 269e82c..520c892 100644 --- a/distribution.yaml +++ b/distribution.yaml @@ -1,5 +1,5 @@ name: ailab_scrume_agent -version: 1.4.4 +version: 1.5.0 description: "Личный помощник: документы, письма, договоры, расчёты, поиск, задачи." hermes_requires: ">=0.21.6" author: "AI laboratory SCRUM company" diff --git a/update-check.ps1 b/update-check.ps1 new file mode 100644 index 0000000..b6a0da3 --- /dev/null +++ b/update-check.ps1 @@ -0,0 +1,188 @@ +<# + update-check.ps1 — ночная проверка обновлений дистрибутива AILab SCRUME Agent. + + Запускается задачей Планировщика 'AILabAgentUpdate' (создаётся установщиком). + Порядок: узнать установленную версию → спросить у GitHub последний релиз → + сравнить по ЧИСЛАМ → если новее, скачать, сверить и поставить. + + Ключевые решения (осознанные, не «по умолчанию»): + + * Обновление НЕ ставится, пока работает Hermes.exe: установщик подменяет + файлы движка и окна, а занятые файлы он перезаписать не может. В этом + случае пишем «отложено» и выходим — следующая ночь попробует снова. + Насильно закрывать окно агента ночью скрипт не имеет права. + * Установка идёт БЕЗ человека, поэтому запускается только если ключ уже + вписан (.env на месте). Иначе это была бы первая установка, а её нельзя + делать молча — там нужен Блокнот для ключа. + * Скрипт зовёт setup.ps1 напрямую, а НЕ setup.cmd: у того в конце `pause`, + и задача зависла бы навсегда, ожидая нажатия клавиши. + + Отключить: update-check.ps1 -Off (снимает задачу Планировщика) +#> + +[CmdletBinding()] +param( + [switch]$Off, + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +$HomeDir = Join-Path $env:LOCALAPPDATA 'hermes' +$Marker = Join-Path $HomeDir '.ailab-install.json' +$EnvFile = Join-Path $HomeDir '.env' +$LogDir = Join-Path $HomeDir 'logs' +$LogFile = Join-Path $LogDir 'update-check.log' +$TaskName = 'AILabAgentUpdate' +$Repo = 'AILABSCRUME/AI_Lab_SCRUME_Co' +$ApiUrl = "https://api.github.com/repos/$Repo/releases/latest" +$ZipUrl = "https://github.com/$Repo/releases/latest/download/agent-setup.zip" +$WorkDir = Join-Path $env:LOCALAPPDATA 'ailab-setup' + +function Write-Log { + param([string]$Message) + $stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') + if (-not (Test-Path $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null } + Add-Content -Path $LogFile -Value "$stamp $Message" -Encoding UTF8 +} + +# ---------- отключение ---------- +if ($Off) { + try { + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction Stop + Write-Log 'задача снята (ключ -Off)' + Write-Host 'Автообновление отключено.' + } catch { + Write-Log 'задача уже отсутствовала (ключ -Off)' + Write-Host 'Автообновление и так выключено.' + } + exit 0 +} + +# ---------- только для уже установленного агента ---------- +if (-not (Test-Path $Marker)) { + Write-Log 'пропуск: след установки не найден (агент не установлен)' + exit 0 +} + +if (-not (Test-Path $EnvFile)) { + Write-Log 'пропуск: ключ ещё не вписан — молча ставить нельзя' + exit 0 +} + +# ---------- версии ---------- +function Get-NumericVersion { + param([string]$Text) + if ([string]::IsNullOrWhiteSpace($Text)) { return $null } + $m = [regex]::Match($Text, '(\d+(?:\.\d+)*)') + if (-not $m.Success) { return $null } + $parts = @($m.Groups[1].Value.Split('.') | ForEach-Object { [int]$_ }) + while ($parts.Count -lt 4) { $parts += 0 } + return $parts +} + +function Compare-Version { + param($A, $B) + for ($i = 0; $i -lt 4; $i++) { + if ($A[$i] -lt $B[$i]) { return -1 } + if ($A[$i] -gt $B[$i]) { return 1 } + } + return 0 +} + +$installedRaw = $null +try { + $installedRaw = (Get-Content -Path $Marker -Raw -Encoding UTF8 | ConvertFrom-Json).version +} catch { + Write-Log "ошибка: не прочитал след установки — $($_.Exception.Message)" + exit 1 +} + +$installed = Get-NumericVersion $installedRaw +if ($null -eq $installed) { + Write-Log "ошибка: версия в следе не распознана ('$installedRaw')" + exit 1 +} + +# ---------- спросить GitHub ---------- +$latest = $null +try { + $resp = Invoke-RestMethod -Uri $ApiUrl -Headers @{ + 'User-Agent' = 'ailab-update-check' + 'Accept' = 'application/vnd.github+json' + } -TimeoutSec 45 + $latest = Get-NumericVersion $resp.tag_name +} catch { + Write-Log "проверка не удалась (сеть/GitHub): $($_.Exception.Message)" + exit 0 +} + +if ($null -eq $latest) { + Write-Log "проверка не удалась: не разобрал тег релиза" + exit 0 +} + +$cmp = Compare-Version $installed $latest + +if ($cmp -ge 0) { + Write-Log ("актуально: установлено {0}, на GitHub {1}" -f $installedRaw, $resp.tag_name) + exit 0 +} + +# ---------- есть новее ---------- +if (-not $Force) { + $running = @(Get-Process -Name 'Hermes' -ErrorAction SilentlyContinue) + if ($running.Count -gt 0) { + Write-Log ("отложено: есть {0} — установка {1} поверх {2} невозможна при работающем окне" -f $resp.tag_name, $installedRaw, $resp.tag_name) + exit 0 + } +} + +Write-Log ("найдено обновление: {0} -> {1}, качаю" -f $installedRaw, $resp.tag_name) + +# ---------- скачать и распаковать ---------- +try { + if (-not (Test-Path $WorkDir)) { New-Item -ItemType Directory -Path $WorkDir -Force | Out-Null } + $zip = Join-Path $WorkDir 'agent-setup.zip' + if (Test-Path $zip) { Remove-Item -Path $zip -Force } + + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $ZipUrl -OutFile $zip -TimeoutSec 600 + + $size = (Get-Item $zip).Length + if ($size -lt 1MB) { + Write-Log "обновление прервано: архив подозрительно мал ($size байт)" + exit 1 + } + + $unpacked = Join-Path $WorkDir 'ailab' + if (Test-Path $unpacked) { Remove-Item -Path $unpacked -Recurse -Force } + Expand-Archive -Path $zip -DestinationPath $unpacked -Force +} catch { + Write-Log "обновление прервано на загрузке: $($_.Exception.Message)" + exit 1 +} + +$setup = Join-Path $unpacked 'setup.ps1' +if (-not (Test-Path $setup)) { + Write-Log 'обновление прервано: в архиве нет setup.ps1' + exit 1 +} + +# ---------- поставить (setup.ps1 напрямую: у setup.cmd в конце pause) ---------- +try { + $out = & powershell -NoProfile -ExecutionPolicy Bypass -File $setup 2>&1 | Out-String + $code = $LASTEXITCODE +} catch { + Write-Log "обновление упало: $($_.Exception.Message)" + exit 1 +} + +if ($code -eq 0) { + Write-Log ("обновление установлено: {0} -> {1}. Протокол: {2}" -f $installedRaw, $resp.tag_name, (Join-Path $unpacked 'setup-log.txt')) +} else { + $tail = ($out -split "`n" | Select-Object -Last 3) -join ' | ' + Write-Log ("обновление упало (код $code): $tail") +} + +exit $code