Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
2e3a18e
pair journal coffee and alcohol with the night that followed
abdulsaheel Oct 17, 2026
aa6660e
pair tagged evenings with the next night even when it wasn't journaled
abdulsaheel Oct 17, 2026
986b060
fall back to an attach copy when sqlite has no vacuum into
abdulsaheel Oct 17, 2026
f989167
stop manual database exports piling up in temp
abdulsaheel Oct 17, 2026
683ff77
remove unused debug_mode flag
abdulsaheel Oct 18, 2026
3733f28
drop lib/debug from the contributing layout map
abdulsaheel Oct 18, 2026
fd1c659
restoring an older backup lowered the prune edge
abdulsaheel Oct 17, 2026
82390c5
the weekly energy balance subtracted today's burn so far from a mean …
abdulsaheel Oct 17, 2026
15367a3
stop adding a medication by an existing name from wiping it
abdulsaheel Oct 17, 2026
8a74687
stop concurrent journal writes on one day from erasing each other's f…
abdulsaheel Oct 17, 2026
85391ba
changing a medication's time made every past dose read as missed
abdulsaheel Oct 17, 2026
9911857
restoring a backup overwrote edits made after it
abdulsaheel Oct 17, 2026
7179727
freshness refresh pulled and decoded 30 day bundles on every derive pass
abdulsaheel Oct 17, 2026
a932bce
stop a restored backup's sensors from filling the sensor cap
abdulsaheel Oct 17, 2026
078b64d
delete everything left coach chats on disk
abdulsaheel Oct 19, 2026
884631f
ask before deleting a sent issue report
abdulsaheel Oct 19, 2026
ed35228
delete everything left plaintext copies of the database in the cache
abdulsaheel Oct 19, 2026
ecc7cab
reject self-referencing ctes in the coach sql guard
abdulsaheel Oct 19, 2026
0dc6236
coach journal logging no longer wipes the day's existing entry
abdulsaheel Oct 19, 2026
2495668
coach history trim could delete the question mid-turn
abdulsaheel Oct 19, 2026
ff78bca
deleting a report left its github issue up
abdulsaheel Oct 19, 2026
3455090
sweep old issue report files out of temp storage
abdulsaheel Oct 19, 2026
dd7bf07
report issues no longer turn reporter text into github mentions
abdulsaheel Oct 19, 2026
ed9d1ae
coach answers no longer fetch markdown images
abdulsaheel Oct 19, 2026
6e2f2ce
sensor cap refused to re-pair a mi band when both slots were full
abdulsaheel Oct 22, 2026
0c1aa13
restoring an older backup undid medication doses marked after it
abdulsaheel Oct 22, 2026
7511aff
delete everything and delete days could be undone by a derive pass al…
abdulsaheel Oct 22, 2026
4145eb2
cycle tab decoded 120 full day bundles on the ui isolate to read thre…
abdulsaheel Oct 22, 2026
e9fa645
pre-3.27 export copied the whole substrate archive just to delete it
abdulsaheel Oct 22, 2026
33df498
restoring an older backup reverted medication schedules and food log …
abdulsaheel Oct 28, 2026
9f98445
restore rolled the step calibration back to the backup's older fit
abdulsaheel Oct 28, 2026
6801394
two cycle symptom chips tapped quickly lost the first one
abdulsaheel Oct 28, 2026
404df7c
lab results in a different unit were judged against the catalogue range
abdulsaheel Oct 28, 2026
3fefd3b
reset test looked for the bare wipe call after it moved behind the de…
abdulsaheel Oct 28, 2026
0e63e67
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 28, 2026
9a68434
add the coach answer text to the gallery and type the russian report …
abdulsaheel Oct 28, 2026
805709a
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 28, 2026
0fb2d61
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 28, 2026
4bc6320
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 28, 2026
918802d
a data reset waited on its own derive hold before the wipe
abdulsaheel Oct 28, 2026
6978f13
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 29, 2026
e87baf8
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 29, 2026
40586f1
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 29, 2026
2da5315
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 29, 2026
53ab009
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 29, 2026
fb25aab
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 29, 2026
696825d
reset: wipe directly now that the derive latch is always held by then
abdulsaheel Oct 29, 2026
ecb6789
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 29, 2026
318b892
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 29, 2026
6e38a4a
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 30, 2026
edc5ce9
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 30, 2026
ac15515
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 30, 2026
ffbf670
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 30, 2026
892531f
Merge branch 'fix/health-export-and-import' into fix/data-and-backup
abdulsaheel Oct 30, 2026
2ed9405
leave the report worker out of this change
abdulsaheel Oct 30, 2026
0b26d84
delete selected-day exports with everything else, and keep the report…
abdulsaheel Oct 30, 2026
d8edbb5
count only whole worn days in the weekly energy balance
abdulsaheel Oct 30, 2026
fc42b1c
do not save a coach chat back after the data was deleted
abdulsaheel Oct 30, 2026
44f9c79
lab units, sensor picker at the cap, and wording fixes
abdulsaheel Oct 30, 2026
604b482
queue coach chat saves behind the reset's sweep
abdulsaheel Oct 30, 2026
df182d2
lab results in another unit print as stored, and purge export sidecars
abdulsaheel Oct 30, 2026
923a5b9
Merge remote-tracking branch 'origin/main' into fix/data-and-backup
abdulsaheel Oct 30, 2026
22a586f
test the coach save the sweep has to wait for
abdulsaheel Oct 30, 2026
2e35f7e
keep the export being handed back, and let a long lab value wrap
abdulsaheel Oct 30, 2026
6de9420
a deleted coach chat stays deleted, and the history budget counts bytes
abdulsaheel Oct 30, 2026
b81feed
keep the open coach chat fixed while a reply is coming in
abdulsaheel Oct 30, 2026
20f3d63
coach chat saves: unlock after the save, unique ids, no save from mid…
abdulsaheel Oct 30, 2026
def14fa
add the past-chats list to the gallery
abdulsaheel Oct 30, 2026
4fdab7e
let a restored mi band re-pair at the sensor limit
abdulsaheel Oct 30, 2026
e5b186b
keep counting a restored sensor's re-pair against the limit
abdulsaheel Oct 30, 2026
e2cc40b
pin the sensor limit for a restored ring or band
abdulsaheel Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ lib/cloud/ optional companion/backend + cloud import clients
lib/coach/ read-only SQL coach over allow-listed views
lib/compute/ runs the analytics pipeline, writes results
lib/data/ local storage + the repository seam the UI reads from
lib/debug/ debug-mode flags
lib/ecg/ ECG readings
lib/gestures/ device action / gesture dispatch
lib/gps/ GPS route tracking for outdoor activities
Expand Down
6 changes: 3 additions & 3 deletions lib/coach/coach_actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ class CoachActions {
}
final known = {for (final f in await repo.getJournalFields()) f.key: f};
final at = str(a['time']).isEmpty ? null : minuteOfDay(a['time']);
final merged = {...await repo.getJournalMetrics(d)};
final updates = <String, JournalMetricValue>{};
final written = <String>[];
for (final e in raw.entries) {
final key = str(e.key);
Expand All @@ -364,13 +364,13 @@ class CoachActions {
if (v == null) {
throw CoachActionError('Field "$key" needs a number.');
}
merged[key] = JournalMetricValue(
updates[key] = JournalMetricValue(
v,
atMinuteOfDay: spec.hasTime ? at : null,
);
written.add(key);
}
await repo.postJournalMetrics(d, merged);
await repo.editJournalMetrics(d, (day) => day.addAll(updates));
return jsonEncode({'saved': true, 'date': d, 'fields': written});
}

Expand Down
42 changes: 42 additions & 0 deletions lib/coach/coach_db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,17 @@ class CoachDb {
final refs = _tableRefs(toks, allowed: {...allowedViews, ...cte});
if (refs.isEmpty) throw SqlGuardError('No table reference found.');

// (h2) RECURSIVE is optional in SQLite: a CTE that reads itself in its own
// body recurses whether or not the keyword is there, and its working
// table is invisible to layer 2. Without this, dropping the keyword
// from a rejected query gets an unbounded generator past both layers,
// and the read-only handle can't be closed until the statement ends.
final selfRef = _selfReferencingCte(toks, cte);
if (selfRef != null) {
throw SqlGuardError(
'Recursive CTEs are not allowed: "$selfRef" reads from itself.');
}

// (i) Auto-append a LIMIT to protect context.
if (!RegExp(r'\blimit\b', caseSensitive: false).hasMatch(lower)) {
body = '$body LIMIT $rowCap';
Expand Down Expand Up @@ -327,6 +338,37 @@ class CoachDb {
return names;
}

/// The first CTE whose own `AS (...)` body has its name in a table position,
/// or null. Table positions come from [_tableRefs] run over the body alone,
/// so a column that merely shares the CTE's name doesn't count.
static String? _selfReferencingCte(List<_Tok> toks, Set<String> cte) {
for (var i = 1; i < toks.length; i++) {
final prev = toks[i - 1];
final t = toks[i];
if (!t.ident || !cte.contains(t.text)) continue;
final opensCte = (prev.ident && prev.text == 'with') ||
(!prev.ident && prev.text == ',');
if (!opensCte) continue;
var j = i + 1;
if (j < toks.length && !toks[j].ident && toks[j].text == '(') {
j = _skipParens(toks, j);
}
if (j >= toks.length || !toks[j].ident || toks[j].text != 'as') continue;
var k = j + 1;
while (k < toks.length &&
toks[k].ident &&
(toks[k].text == 'not' || toks[k].text == 'materialized')) {
k++;
}
if (k >= toks.length || toks[k].ident || toks[k].text != '(') continue;
final end = _skipParens(toks, k);
final body = toks.sublist(k + 1, end > k + 1 ? end - 1 : k + 1);
final refs = _tableRefs(body, allowed: {...allowedViews, ...cte});
if (refs.contains(t.text)) return t.text;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
return null;
}

/// Index just past the balanced ')' that matches the '(' at [open].
static int _skipParens(List<_Tok> toks, int open) {
var depth = 0;
Expand Down
146 changes: 139 additions & 7 deletions lib/coach/coach_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';

import '../ai/journal_ai.dart' show mergeJournalEntry;
import '../data/day_label.dart';
import '../data/db.dart';
import '../data/local_repository.dart';
import '../models/payloads.dart' show todayHeadlineOf;
import '../sync/reset_gate.dart';
import 'coach_actions.dart';
import 'coach_chat_completions.dart';
import 'coach_config.dart';
Expand Down Expand Up @@ -167,6 +169,14 @@ class CoachEngine {
final CoachConfig config;
final LocalRepository api;
final String storageKey; // per-user, so accounts don't share a transcript

/// The data generation this engine started under. A reply still in flight
/// when "Delete everything" runs lands after the chat files were swept, and
/// saving it would write the deleted conversation back.
/// An engine built while a reset is still running (the UI is not blocked
/// during one) would otherwise take the new generation and save after the
/// sweep; -1 never matches, so it never saves.
final int _resetGen = ResetGate.active ? -1 : ResetGate.generation;
Comment thread
abdulsaheel marked this conversation as resolved.
Resolved
final http.Client _http;

/// Count of [send] calls currently inside their provider call(s). A local
Expand Down Expand Up @@ -256,6 +266,11 @@ class CoachEngine {
/// Max characters of running history resent on each turn.
static const int kMaxHistoryChars = 120000;

/// What an over-budget tool result in the current turn is replaced with.
static const String _kDroppedToolResult =
'(earlier result dropped to fit the conversation size limit — '
're-run the query if it is still needed)';

/// Hard ceiling on one serialized provider request body.
static const int kMaxRequestBytes = 400 * 1024;

Expand All @@ -272,7 +287,10 @@ class CoachEngine {
int _historyChars() {
var n = 0;
for (final m in _history) {
n += jsonEncode(m).length;
// Bytes, as the limit says and as the request size is checked: a
// character count lets non-ASCII history through the trim and then
// over kMaxRequestBytes.
n += utf8.encode(jsonEncode(m)).length;
}
return n;
}
Expand All @@ -296,6 +314,39 @@ class CoachEngine {
}
return;
}
// The current turn starts at the LAST user message and is never dropped:
// a tool-heavy turn (a handful of 16k run_sql results, each re-escaped by
// jsonEncode) can outgrow the budget by itself, and walking the history
// down past the question sent the provider a bare system prompt — the
// model then answered "how can I help?" or the provider 400'd on a request
// with no user message, and the question was gone from the saved session.
if (_history.any((m) => m['role'] == 'user')) {
// Whole older turns go first, oldest first. The search from index 1
// stops at or before the current question, so it is never removed.
while (_historyChars() > kMaxHistoryChars &&
_history.lastIndexWhere((m) => m['role'] == 'user') > 0) {
_history.removeRange(
0, _history.indexWhere((m) => m['role'] == 'user', 1));
}
// Still over with only the current turn left: replace this turn's
// oldest tool results with a stub. The message itself stays so its
// tool_call_id still pairs with the assistant turn that requested it.
for (var i = 0;
i < _history.length && _historyChars() > kMaxHistoryChars;
i++) {
final m = _history[i];
if (m['role'] == 'tool' && m['content'] != _kDroppedToolResult) {
_history[i] = {...m, 'content': _kDroppedToolResult};
}
}
if (_historyChars() > kMaxHistoryChars) {
throw CoachException(
'This turn exceeded the conversation size limit. '
'Start a new chat or ask a narrower question.',
);
}
return;
}
while (_historyChars() > kMaxHistoryChars && _history.length > 1) {
_history.removeAt(0);
while (_history.length > 1 && _history.first['role'] != 'user') {
Expand All @@ -319,7 +370,60 @@ class CoachEngine {
Future<Directory> _dir() => getApplicationDocumentsDirectory();
Future<File> _sessionFile(String id) async => File('${(await _dir()).path}/coach_s_${storageKey}_$id.json');
Future<File> _indexFile() async => File('${(await _dir()).path}/coach_idx_$storageKey.json');
String _newId() => '${DateTime.now().millisecondsSinceEpoch}';
/// Millisecond stamp first so ids still read and sort by time, then a random
/// suffix: two screens' engines can start a chat in the same millisecond.
String _newId() => '${DateTime.now().millisecondsSinceEpoch}-'
'${_idRandom.nextInt(1 << 32).toRadixString(16).padLeft(8, '0')}';
static final _idRandom = Random();

/// Deletes every saved chat for every storage key. The transcripts live as
/// files beside the database, not in it, so wiping the database leaves
/// them, and the key is the same 'local' after a reset — the next profile
/// would reopen the previous one's conversations, tool results included.
/// Returns the number of files that could not be deleted.
static Future<int> deleteAllSavedSessions() =>
_serialFiles(_deleteAllSavedSessions);

/// One queue for every chat-file write and the reset's sweep. A save that
/// had already passed its reset check could otherwise still be writing
/// while the sweep listed the folder, and land after it.
static Future<void> _filesTail = Future<void>.value();

/// Chats deleted in this process. Each screen has its own engine, so a
/// second screen on the same chat still holds its id; this is what stops
/// that engine's next save. Ids are minted fresh per chat and only reopened
/// from the saved list, so a deleted one never legitimately comes back.
/// Keyed `storageKey/id`, since chats of different accounts sit side by side.
static final Set<String> _deletedSessionIds = {};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/// Test seam: awaited by a save right after its reset check passes, so a
/// test can hold a save mid-write while a reset sweeps.
@visibleForTesting
static Future<void> Function()? debugAfterSaveCheck;
static Future<T> _serialFiles<T>(Future<T> Function() body) {
final run = _filesTail.then((_) => body());
_filesTail = run.then((_) {}, onError: (_) {});
return run;
}

static Future<int> _deleteAllSavedSessions() async {
_deletedSessionIds.clear();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
final dir = await getApplicationDocumentsDirectory();
if (!await dir.exists()) return 0;
var failed = 0;
await for (final e in dir.list(followLinks: false)) {
if (e is! File) continue;
final name = e.uri.pathSegments.last;
if (!name.endsWith('.json')) continue;
if (!name.startsWith('coach_s_') && !name.startsWith('coach_idx_')) continue;
try {
await e.delete();
} catch (_) {
failed++;
}
}
return failed;
}

/// All saved sessions for this user, most recent first.
Future<List<CoachSessionMeta>> listSessions() async {
Expand Down Expand Up @@ -379,8 +483,16 @@ class CoachEngine {
}

/// Persist the current session (caps history, updates the index).
Future<void> persist() async {
if (transcript.isEmpty) return;
Future<void> persist() => _serialFiles(_persist);

Future<void> _persist() async {
// Checked inside the queue: a reset that started while this waited has
// swept the files by now, and this must not write the chat back.
if (transcript.isEmpty || ResetGate.changedSince(_resetGen)) return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// Deleted from another screen's engine on the same chat: its late save
// must not write the file and index entry back.
if (_deletedSessionIds.contains('$storageKey/$_sessionId')) return;
await debugAfterSaveCheck?.call();
if (_sessionId.isEmpty) _sessionId = _newId();
final now = DateTime.now().millisecondsSinceEpoch;
if (_createdAt == 0) _createdAt = now;
Expand Down Expand Up @@ -422,7 +534,11 @@ class CoachEngine {
}

/// Delete a session (and start fresh if it was the current one).
Future<void> deleteSession(String id) async {
Future<void> deleteSession(String id) =>
_serialFiles(() => _deleteSession(id));

Future<void> _deleteSession(String id) async {
_deletedSessionIds.add('$storageKey/$id');
try {
final f = await _sessionFile(id);
if (await f.exists()) await f.delete();
Expand Down Expand Up @@ -858,8 +974,24 @@ class CoachEngine {
summary: 'Add journal for $journalDate: tags ${args['tags'] ?? []}, note "${args['note'] ?? ''}".',
args: args,
), () async {
await api.postJournal(journalDate,
((args['tags'] as List?) ?? const []).map((e) => '$e').toList(), '${args['note'] ?? ''}');
// postJournal replaces the day's whole row, so the stored day is
// read and merged first — otherwise "log that I had coffee" wipes
// every tag and note already logged for that day.
Map<String, dynamic>? row;
for (final e in await api.getJournal(range: 'all')) {
if (e['date'] == journalDate) row = e;
}
final merged = mergeJournalEntry(
existingTags: ((row?['tags'] as List?) ?? const [])
.map((e) => '$e')
.toList(),
existingNote: (row?['note'] as String?) ?? '',
newTags: ((args['tags'] as List?) ?? const [])
.map((e) => '$e')
.toList(),
newNote: '${args['note'] ?? ''}',
);
await api.postJournal(journalDate, merged.tags, merged.note);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return 'Journal saved.';
});
case 'log_period':
Expand Down
24 changes: 23 additions & 1 deletion lib/compute/derivation_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3059,6 +3059,27 @@ class DerivationEngine {
_running = false;
}
}

/// Run [body] once no pass is in flight, holding the latch so none starts
/// until it returns. For user deletes: a pass that built its substrate
/// before the delete keeps writing `day_result`, `metric_series` and the
/// baselines after it commits, and the deleted days come back with no
/// substrate left to ever re-derive or remove them. Waits rather than
/// skips, like [_withRunLock]'s import caller. [body] must not call a
/// locked entry point (it would be skipped as busy).
static Future<T> whileIdle<T>(Future<T> Function() body) async {
while (_running) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
// No await between the loop's last check and taking the latch.
_running = true;
try {
return await body();
} finally {
_running = false;
}
}

final Map<String, dynamic> _diag = {
'running': false,
'stage': 'idle',
Expand Down Expand Up @@ -7232,7 +7253,8 @@ class DerivationEngine {
static const int _maxRawHoldDays = 14;

/// Cursor holding the highest `rec_ts` cutoff the raw prune has applied.
static const String _prunedBeforeCursor = 'decoded_pruned_before';
static const String _prunedBeforeCursor =
LocalDb.kDecodedPrunedBeforeCursor;

/// The `rec_ts` below which decoded substrate may be deleted, or null when
/// nothing may be. PURE — the decision the raw prune is, separated from the
Expand Down
Loading
Loading